From 2cbf4b8b9a1878ef2e15f5c81a814f72bfdec4a5 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 21:03:30 -0700 Subject: [PATCH 1/6] doc(ContextGraph): add API documentation --- .../Internal/ContextGraph.cs | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Internal/ContextGraph.cs b/src/JsonApiDotNetCore/Internal/ContextGraph.cs index 9c62ae4d94..c0c7f2274b 100644 --- a/src/JsonApiDotNetCore/Internal/ContextGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ContextGraph.cs @@ -6,10 +6,45 @@ namespace JsonApiDotNetCore.Internal { public interface IContextGraph { - object GetRelationship(TParent entity, string relationshipName); + /// + /// Gets the value of the navigation property, defined by the relationshipName, + /// on the provided instance. + /// + /// The resource instance + /// The navigation property name. + /// + /// + /// _graph.GetRelationship(todoItem, nameof(TodoItem.Owner)); + /// + /// + object GetRelationship(TParent resource, string propertyName); + + /// + /// Get the internal navigation property name for the specified public + /// relationship name. + /// + /// The public relationship name specified by a or + /// + /// + /// _graph.GetRelationshipName<TodoItem>("achieved-date"); + /// // returns "AchievedDate" + /// + /// string GetRelationshipName(string relationshipName); + + /// + /// Get the resource metadata by the DbSet property name + /// ContextEntity GetContextEntity(string dbSetName); + + /// + /// Get the resource metadata by the resource type + /// ContextEntity GetContextEntity(Type entityType); + + /// + /// Was built against an EntityFrameworkCore DbContext ? + /// bool UsesDbContext { get; } } @@ -40,14 +75,18 @@ internal ContextGraph(List entities, bool usesDbContext, List public bool UsesDbContext { get; } + /// public ContextEntity GetContextEntity(string entityName) => Entities.SingleOrDefault(e => string.Equals(e.EntityName, entityName, StringComparison.OrdinalIgnoreCase)); + /// public ContextEntity GetContextEntity(Type entityType) => Entities.SingleOrDefault(e => e.EntityType == entityType); + /// public object GetRelationship(TParent entity, string relationshipName) { var parentEntityType = entity.GetType(); @@ -62,6 +101,7 @@ public object GetRelationship(TParent entity, string relationshipName) return navigationProperty.GetValue(entity); } + /// public string GetRelationshipName(string relationshipName) { var entityType = typeof(TParent); From d92752b7d399684661b06976ab61d3d3f3c859fe Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 21:07:43 -0700 Subject: [PATCH 2/6] fix(#354): transform property name Should consider creating a new API on the graph that is more intuitive and doesn't require both steps. --- .../Services/EntityResourceService.cs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index dc78463b05..f3180709a6 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -122,17 +122,24 @@ public virtual async Task GetRelationshipsAsync(TId id, string relations public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - var entity = await _entities.GetAndIncludeAsync(id, relationshipName); + // compound-property -> CompoundProperty + var navigationPropertyName = _jsonApiContext.ContextGraph.GetRelationshipName(relationshipName); + if (navigationPropertyName == null) + throw new JsonApiException(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); + + var entity = await _entities.GetAndIncludeAsync(id, navigationPropertyName); + // TODO: it would be better if we could distinguish whether or not the relationship was not found, // vs the relationship not being set on the instance of T if (entity == null) { - throw new JsonApiException(404, $"Relationship {relationshipName} not found."); + throw new JsonApiException(404, $"Relationship '{navigationPropertyName}' not found."); } - var resource = (typeof(TResource) == typeof(TEntity)) ? entity as TResource : - _mapper.Map(entity); - var relationship = _jsonApiContext.ContextGraph.GetRelationship(resource, relationshipName); + var resource = MapOut(entity); + + var relationship = _jsonApiContext.ContextGraph.GetRelationship(resource, navigationPropertyName); + return relationship; } @@ -242,5 +249,10 @@ private async Task GetWithRelationshipsAsync(TId id) private bool ShouldIncludeRelationships() => (_jsonApiContext.QuerySet?.IncludedRelationships != null && _jsonApiContext.QuerySet.IncludedRelationships.Count > 0); + + private TResource MapOut(TEntity entity) + => (typeof(TResource) == typeof(TEntity)) + ? entity as TResource : + _mapper.Map(entity); } } From f02f264735da22c9c6696e87d948a8db18535eab Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 21:08:40 -0700 Subject: [PATCH 3/6] style(EntityResourceService) --- .../Services/EntityResourceService.cs | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index f3180709a6..cdd658d27d 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Services { - public class EntityResourceService : EntityResourceService, + public class EntityResourceService : EntityResourceService, IResourceService where TResource : class, IIdentifiable { @@ -21,7 +21,7 @@ public EntityResourceService( { } } - public class EntityResourceService : EntityResourceService, + public class EntityResourceService : EntityResourceService, IResourceService where TResource : class, IIdentifiable { @@ -33,7 +33,7 @@ public EntityResourceService( { } } - public class EntityResourceService : + public class EntityResourceService : IResourceService where TResource : class, IIdentifiable where TEntity : class, IIdentifiable @@ -51,8 +51,7 @@ public EntityResourceService( // no mapper provided, TResource & TEntity must be the same type if (typeof(TResource) != typeof(TEntity)) { - throw new InvalidOperationException("Resource and Entity types are NOT the same. " + - "Please provide a mapper."); + throw new InvalidOperationException("Resource and Entity types are NOT the same. Please provide a mapper."); } _jsonApiContext = jsonApiContext; @@ -74,11 +73,11 @@ public EntityResourceService( public virtual async Task CreateAsync(TResource resource) { - var entity = (typeof(TResource) == typeof(TEntity)) ? resource as TEntity : - _mapper.Map(resource); + var entity = MapIn(resource); + entity = await _entities.CreateAsync(entity); - return (typeof(TResource) == typeof(TEntity)) ? entity as TResource : - _mapper.Map(entity); + + return MapOut(entity); } public virtual async Task DeleteAsync(TId id) @@ -105,16 +104,12 @@ public virtual async Task> GetAsync() public virtual async Task GetAsync(TId id) { - TResource dto; if (ShouldIncludeRelationships()) - dto = await GetWithRelationshipsAsync(id); - else - { - TEntity entity = await _entities.GetAsync(id); - dto = (typeof(TResource) == typeof(TEntity)) ? entity as TResource : - _mapper.Map(entity); - } - return dto; + return await GetWithRelationshipsAsync(id); + + TEntity entity = await _entities.GetAsync(id); + + return MapOut(entity); } public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) @@ -145,15 +140,14 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh public virtual async Task UpdateAsync(TId id, TResource resource) { - var entity = (typeof(TResource) == typeof(TEntity)) ? resource as TEntity : - _mapper.Map(resource); + var entity = MapIn(resource); + entity = await _entities.UpdateAsync(id, entity); - return (typeof(TResource) == typeof(TEntity)) ? entity as TResource : - _mapper.Map(entity); + + return MapOut(entity); } - public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipName, - List relationships) + public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipName, List relationships) { var entity = await _entities.GetAndIncludeAsync(id, relationshipName); if (entity == null) @@ -165,6 +159,7 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa .GetContextEntity(typeof(TResource)) .Relationships .FirstOrDefault(r => r.Is(relationshipName)); + var relationshipType = relationship.Type; // update relationship type with internalname @@ -174,8 +169,10 @@ public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipNa throw new JsonApiException(404, $"Property {relationship.InternalRelationshipName} " + $"could not be found on entity."); } - relationship.Type = relationship.IsHasMany ? entityProperty.PropertyType.GetGenericArguments()[0] : - entityProperty.PropertyType; + + relationship.Type = relationship.IsHasMany + ? entityProperty.PropertyType.GetGenericArguments()[0] + : entityProperty.PropertyType; var relationshipIds = relationships.Select(r => r?.Id?.ToString()); @@ -200,10 +197,9 @@ protected virtual async Task> ApplyPageQueryAsync(IQuerya $"with {pageManager.PageSize} entities"); } - var pagedEntities = await _entities.PageAsync(entities, pageManager.PageSize, - pageManager.CurrentPage); - return (typeof(TResource) == typeof(TEntity)) ? pagedEntities as IEnumerable : - _mapper.Map>(pagedEntities); + var pagedEntities = await _entities.PageAsync(entities, pageManager.PageSize, pageManager.CurrentPage); + + return MapOut(pagedEntities); } protected virtual IQueryable ApplySortAndFilterQuery(IQueryable entities) @@ -223,8 +219,7 @@ protected virtual IQueryable ApplySortAndFilterQuery(IQueryable IncludeRelationships(IQueryable entities, - List relationships) + protected virtual IQueryable IncludeRelationships(IQueryable entities, List relationships) { _jsonApiContext.IncludedRelationships = relationships; @@ -237,22 +232,34 @@ protected virtual IQueryable IncludeRelationships(IQueryable e private async Task GetWithRelationshipsAsync(TId id) { var query = _entities.Get().Where(e => e.Id.Equals(id)); + _jsonApiContext.QuerySet.IncludedRelationships.ForEach(r => { query = _entities.Include(query, r); }); + var value = await _entities.FirstOrDefaultAsync(query); - return (typeof(TResource) == typeof(TEntity)) ? value as TResource : - _mapper.Map(value); + + return MapOut(value); } private bool ShouldIncludeRelationships() - => (_jsonApiContext.QuerySet?.IncludedRelationships != null && + => (_jsonApiContext.QuerySet?.IncludedRelationships != null && _jsonApiContext.QuerySet.IncludedRelationships.Count > 0); private TResource MapOut(TEntity entity) => (typeof(TResource) == typeof(TEntity)) ? entity as TResource : _mapper.Map(entity); + + private IEnumerable MapOut(IEnumerable entities) + => (typeof(TResource) == typeof(TEntity)) + ? entities as IEnumerable + : _mapper.Map>(entities); + + private TEntity MapIn(TResource resource) + => (typeof(TResource) == typeof(TEntity)) + ? resource as TEntity + : _mapper.Map(resource); } } From 48467f1f7cc76175f6b7833102a63cc3f21d97b0 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 22:02:36 -0700 Subject: [PATCH 4/6] fix(EntityResourceService): pass relationship name to repository --- .../Services/EntityResourceService.cs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index cdd658d27d..300c858c43 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -117,25 +117,24 @@ public virtual async Task GetRelationshipsAsync(TId id, string relations public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - // compound-property -> CompoundProperty - var navigationPropertyName = _jsonApiContext.ContextGraph.GetRelationshipName(relationshipName); - if (navigationPropertyName == null) - throw new JsonApiException(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); - - var entity = await _entities.GetAndIncludeAsync(id, navigationPropertyName); + var entity = await _entities.GetAndIncludeAsync(id, relationshipName); // TODO: it would be better if we could distinguish whether or not the relationship was not found, // vs the relationship not being set on the instance of T if (entity == null) { - throw new JsonApiException(404, $"Relationship '{navigationPropertyName}' not found."); + throw new JsonApiException(404, $"Relationship '{relationshipName}' not found."); } var resource = MapOut(entity); - var relationship = _jsonApiContext.ContextGraph.GetRelationship(resource, navigationPropertyName); + // compound-property -> CompoundProperty + var navigationPropertyName = _jsonApiContext.ContextGraph.GetRelationshipName(relationshipName); + if (navigationPropertyName == null) + throw new JsonApiException(422, $"Relationship '{relationshipName}' does not exist on resource '{typeof(TResource)}'."); - return relationship; + var relationshipValue = _jsonApiContext.ContextGraph.GetRelationship(resource, navigationPropertyName); + return relationshipValue; } public virtual async Task UpdateAsync(TId id, TResource resource) From 5a7d7daf1bfdd7506f70733c3a8f69c6a9f0d662 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 22:03:02 -0700 Subject: [PATCH 5/6] docs(IEntityReadRepository): add API documentation --- .../Data/DefaultEntityRepository.cs | 20 ++++++-- .../Data/IEntityReadRepository.cs | 49 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 8cd3200a4e..7de24d7360 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -48,6 +48,7 @@ public DefaultEntityRepository( _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; } + /// public virtual IQueryable Get() { if (_jsonApiContext.QuerySet?.Fields != null && _jsonApiContext.QuerySet.Fields.Count > 0) @@ -56,21 +57,25 @@ public virtual IQueryable Get() return _dbSet; } + /// public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) { return entities.Filter(_jsonApiContext, filterQuery); } + /// public virtual IQueryable Sort(IQueryable entities, List sortQueries) { return entities.Sort(sortQueries); } + /// public virtual async Task GetAsync(TId id) { return await Get().SingleOrDefaultAsync(e => e.Id.Equals(id)); } + /// public virtual async Task GetAndIncludeAsync(TId id, string relationshipName) { _logger.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})"); @@ -80,6 +85,7 @@ public virtual async Task GetAndIncludeAsync(TId id, string relationshi return result; } + /// public virtual async Task CreateAsync(TEntity entity) { AttachRelationships(); @@ -102,9 +108,9 @@ protected virtual void AttachRelationships() private void AttachHasManyPointers() { var relationships = _jsonApiContext.HasManyRelationshipPointers.Get(); - foreach(var relationship in relationships) + foreach (var relationship in relationships) { - foreach(var pointer in relationship.Value) + foreach (var pointer in relationship.Value) { _context.Entry(pointer).State = EntityState.Unchanged; } @@ -123,6 +129,7 @@ private void AttachHasOnePointers() _context.Entry(relationship.Value).State = EntityState.Unchanged; } + /// public virtual async Task UpdateAsync(TId id, TEntity entity) { var oldEntity = await GetAsync(id); @@ -141,12 +148,14 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) return oldEntity; } + /// public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) { var genericProcessor = _genericProcessorFactory.GetProcessor(typeof(GenericProcessor<>), relationship.Type); await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds); } + /// public virtual async Task DeleteAsync(TId id) { var entity = await GetAsync(id); @@ -161,11 +170,12 @@ public virtual async Task DeleteAsync(TId id) return true; } + /// public virtual IQueryable Include(IQueryable entities, string relationshipName) { var entity = _jsonApiContext.RequestEntity; var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == relationshipName); - if (relationship == null) + if (relationship == null) { throw new JsonApiException(400, $"Invalid relationship {relationshipName} on {entity.EntityName}", $"{entity.EntityName} does not have a relationship named {relationshipName}"); @@ -178,6 +188,7 @@ public virtual IQueryable Include(IQueryable entities, string return entities.Include(relationship.InternalRelationshipName); } + /// public virtual async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) { if (pageNumber >= 0) @@ -198,6 +209,7 @@ public virtual async Task> PageAsync(IQueryable en .ToListAsync(); } + /// public async Task CountAsync(IQueryable entities) { return (entities is IAsyncEnumerable) @@ -205,6 +217,7 @@ public async Task CountAsync(IQueryable entities) : entities.Count(); } + /// public async Task FirstOrDefaultAsync(IQueryable entities) { return (entities is IAsyncEnumerable) @@ -212,6 +225,7 @@ public async Task FirstOrDefaultAsync(IQueryable entities) : entities.FirstOrDefault(); } + /// public async Task> ToListAsync(IQueryable entities) { return (entities is IAsyncEnumerable) diff --git a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs index a86b7334a9..f861ce10e2 100644 --- a/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityReadRepository.cs @@ -6,32 +6,75 @@ namespace JsonApiDotNetCore.Data { - public interface IEntityReadRepository - : IEntityReadRepository - where TEntity : class, IIdentifiable + public interface IEntityReadRepository + : IEntityReadRepository + where TEntity : class, IIdentifiable { } public interface IEntityReadRepository where TEntity : class, IIdentifiable { + /// + /// The base GET query. This is a good place to apply rules that should affect all reads, + /// such as authorization of resources. + /// IQueryable Get(); + /// + /// Include a relationship in the query + /// + /// + /// + /// _todoItemsRepository.GetAndIncludeAsync(1, "achieved-date"); + /// + /// IQueryable Include(IQueryable entities, string relationshipName); + /// + /// Apply a filter to the provided queryable + /// IQueryable Filter(IQueryable entities, FilterQuery filterQuery); + /// + /// Apply a sort to the provided queryable + /// IQueryable Sort(IQueryable entities, List sortQueries); + /// + /// Paginate the provided queryable + /// Task> PageAsync(IQueryable entities, int pageSize, int pageNumber); + /// + /// Get the entity by id + /// Task GetAsync(TId id); + /// + /// Get the entity with the specified id and include the relationship. + /// + /// The entity id + /// The exposed relationship name + /// + /// + /// _todoItemsRepository.GetAndIncludeAsync(1, "achieved-date"); + /// + /// Task GetAndIncludeAsync(TId id, string relationshipName); + /// + /// Count the total number of records + /// Task CountAsync(IQueryable entities); + /// + /// Get the first element in the collection, return the default value if collection is empty + /// Task FirstOrDefaultAsync(IQueryable entities); + /// + /// Convert the collection to a materialized list + /// Task> ToListAsync(IQueryable entities); } } From 46f1e2d8841d2333354d3f98812b70ee3072a5dd Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Wed, 25 Jul 2018 22:23:39 -0700 Subject: [PATCH 6/6] test(#354): add EntityResourceService tests --- .../Services/EntityResourceService_Tests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 test/UnitTests/Services/EntityResourceService_Tests.cs diff --git a/test/UnitTests/Services/EntityResourceService_Tests.cs b/test/UnitTests/Services/EntityResourceService_Tests.cs new file mode 100644 index 0000000000..0f27f70924 --- /dev/null +++ b/test/UnitTests/Services/EntityResourceService_Tests.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace UnitTests.Services +{ + public class EntityResourceService_Tests + { + private readonly Mock _jsonApiContextMock = new Mock(); + private readonly Mock> _repositoryMock = new Mock>(); + private readonly ILoggerFactory _loggerFactory = new Mock().Object; + + public EntityResourceService_Tests() + { + _jsonApiContextMock + .Setup(m => m.ContextGraph) + .Returns( + new ContextGraphBuilder() + .AddResource("todo-items") + .Build() + ); + } + + [Fact] + public async Task GetRelationshipAsync_Passes_Public_ResourceName_To_Repository() + { + // arrange + const int id = 1; + const string relationshipName = "collection"; + + _repositoryMock.Setup(m => m.GetAndIncludeAsync(id, relationshipName)) + .ReturnsAsync(new TodoItem()); + + var repository = GetService(); + + // act + await repository.GetRelationshipAsync(id, relationshipName); + + // assert + _repositoryMock.Verify(m => m.GetAndIncludeAsync(id, relationshipName), Times.Once); + } + + [Fact] + public async Task GetRelationshipAsync_Returns_Relationship_Value() + { + // arrange + const int id = 1; + const string relationshipName = "collection"; + + var todoItem = new TodoItem + { + Collection = new TodoItemCollection { Id = Guid.NewGuid() } + }; + + _repositoryMock.Setup(m => m.GetAndIncludeAsync(id, relationshipName)) + .ReturnsAsync(todoItem); + + var repository = GetService(); + + // act + var result = await repository.GetRelationshipAsync(id, relationshipName); + + // assert + Assert.NotNull(result); + var collection = Assert.IsType(result); + Assert.Equal(todoItem.Collection.Id, collection.Id); + } + + private EntityResourceService GetService() => + new EntityResourceService(_jsonApiContextMock.Object, _repositoryMock.Object, _loggerFactory); + } +}