diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 7de24d7360..862079aeee 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -13,6 +13,7 @@ namespace JsonApiDotNetCore.Data { + /// public class DefaultEntityRepository : DefaultEntityRepository, IEntityRepository @@ -26,8 +27,13 @@ public DefaultEntityRepository( { } } + /// + /// Provides a default repository implementation and is responsible for + /// abstracting any EF Core APIs away from the service layer. + /// public class DefaultEntityRepository - : IEntityRepository + : IEntityRepository, + IEntityFrameworkRepository where TEntity : class, IIdentifiable { private readonly DbContext _context; @@ -48,7 +54,7 @@ public DefaultEntityRepository( _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; } - /// + /// public virtual IQueryable Get() { if (_jsonApiContext.QuerySet?.Fields != null && _jsonApiContext.QuerySet.Fields.Count > 0) @@ -57,41 +63,43 @@ 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})"); - var result = await Include(Get(), relationshipName).SingleOrDefaultAsync(e => e.Id.Equals(id)); + var includedSet = Include(Get(), relationshipName); + var result = await includedSet.SingleOrDefaultAsync(e => e.Id.Equals(id)); return result; } - /// + /// public virtual async Task CreateAsync(TEntity entity) { AttachRelationships(); _dbSet.Add(entity); await _context.SaveChangesAsync(); + return entity; } @@ -101,6 +109,28 @@ protected virtual void AttachRelationships() AttachHasOnePointers(); } + /// + public void DetachRelationshipPointers(TEntity entity) + { + foreach (var hasOneRelationship in _jsonApiContext.HasOneRelationshipPointers.Get()) + { + _context.Entry(hasOneRelationship.Value).State = EntityState.Detached; + } + + foreach (var hasManyRelationship in _jsonApiContext.HasManyRelationshipPointers.Get()) + { + foreach (var pointer in hasManyRelationship.Value) + { + _context.Entry(pointer).State = EntityState.Detached; + } + + // HACK: detaching has many relationships doesn't appear to be sufficient + // the navigation property actually needs to be nulled out, otherwise + // EF adds duplicate instances to the collection + hasManyRelationship.Key.SetValue(entity, null); + } + } + /// /// This is used to allow creation of HasMany relationships when the /// dependent side of the relationship already exists. @@ -129,7 +159,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); @@ -148,14 +178,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); @@ -170,7 +200,7 @@ public virtual async Task DeleteAsync(TId id) return true; } - /// + /// public virtual IQueryable Include(IQueryable entities, string relationshipName) { var entity = _jsonApiContext.RequestEntity; @@ -185,10 +215,11 @@ public virtual IQueryable Include(IQueryable entities, string { throw new JsonApiException(400, $"Including the relationship {relationshipName} on {entity.EntityName} is not allowed"); } + return entities.Include(relationship.InternalRelationshipName); } - /// + /// public virtual async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) { if (pageNumber >= 0) @@ -209,7 +240,7 @@ public virtual async Task> PageAsync(IQueryable en .ToListAsync(); } - /// + /// public async Task CountAsync(IQueryable entities) { return (entities is IAsyncEnumerable) @@ -217,7 +248,7 @@ public async Task CountAsync(IQueryable entities) : entities.Count(); } - /// + /// public async Task FirstOrDefaultAsync(IQueryable entities) { return (entities is IAsyncEnumerable) @@ -225,7 +256,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/IEntityRepository.cs b/src/JsonApiDotNetCore/Data/IEntityRepository.cs index e8bb68ef90..ac69f4fdac 100644 --- a/src/JsonApiDotNetCore/Data/IEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/IEntityRepository.cs @@ -8,8 +8,29 @@ public interface IEntityRepository { } public interface IEntityRepository - : IEntityReadRepository, + : IEntityReadRepository, IEntityWriteRepository where TEntity : class, IIdentifiable { } + + /// + /// A staging interface to avoid breaking changes that + /// specifically depend on EntityFramework. + /// + internal interface IEntityFrameworkRepository + { + /// + /// Ensures that any relationship pointers created during a POST or PATCH + /// request are detached from the DbContext. + /// This allows the relationships to be fully loaded from the database. + /// + /// + /// + /// The only known case when this should be called is when a POST request is + /// sent with an ?include query. + /// + /// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 + /// + void DetachRelationshipPointers(TEntity entity); + } } diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 4af725919b..c786cd5153 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@ - 2.5.0 + 2.5.1 $(NetStandardVersion) JsonApiDotNetCore JsonApiDotNetCore diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 300c858c43..1e8dc249a2 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -77,6 +77,17 @@ public virtual async Task CreateAsync(TResource resource) entity = await _entities.CreateAsync(entity); + // this ensures relationships get reloaded from the database if they have + // been requested + // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 + if (ShouldIncludeRelationships()) + { + if(_entities is IEntityFrameworkRepository efRepository) + efRepository.DetachRelationshipPointers(entity); + + return await GetWithRelationshipsAsync(entity.Id); + } + return MapOut(entity); } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index e65278db12..a34312ca2f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -285,6 +285,71 @@ public async Task Can_Create_And_Set_HasMany_Relationships() Assert.NotEmpty(contextCollection.TodoItems); } + [Fact] + public async Task Can_Create_With_HasMany_Relationship_And_Include_Result() + { + // arrange + var builder = new WebHostBuilder() + .UseStartup(); + var httpMethod = new HttpMethod("POST"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var context = _fixture.GetService(); + + var owner = new JsonApiDotNetCoreExample.Models.Person(); + var todoItem = new TodoItem(); + todoItem.Owner = owner; + todoItem.Description = "Description"; + context.People.Add(owner); + context.TodoItems.Add(todoItem); + await context.SaveChangesAsync(); + + var route = "/api/v1/todo-collections?include=todo-items"; + var request = new HttpRequestMessage(httpMethod, route); + var content = new + { + data = new + { + type = "todo-collections", + relationships = new Dictionary + { + { "owner", new { + data = new + { + type = "people", + id = owner.Id.ToString() + } + } }, + { "todo-items", new { + data = new dynamic[] + { + new { + type = "todo-items", + id = todoItem.Id.ToString() + } + } + } } + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // act + var response = await client.SendAsync(request); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var collectionResult = _fixture.GetService().Deserialize(body); + + Assert.NotNull(collectionResult); + Assert.NotEmpty(collectionResult.TodoItems); + Assert.Equal(todoItem.Description, collectionResult.TodoItems.Single().Description); + } + [Fact] public async Task Can_Create_And_Set_HasOne_Relationships() { @@ -342,6 +407,62 @@ public async Task Can_Create_And_Set_HasOne_Relationships() Assert.Equal(owner.Id, todoItemResult.OwnerId); } + [Fact] + public async Task Can_Create_With_HasOne_Relationship_And_Include_Result() + { + // arrange + var builder = new WebHostBuilder().UseStartup(); + + var httpMethod = new HttpMethod("POST"); + var server = new TestServer(builder); + var client = server.CreateClient(); + + var context = _fixture.GetService(); + + var todoItem = new TodoItem(); + var owner = new JsonApiDotNetCoreExample.Models.Person + { + FirstName = "Alice" + }; + context.People.Add(owner); + + await context.SaveChangesAsync(); + + var route = "/api/v1/todo-items?include=owner"; + var request = new HttpRequestMessage(httpMethod, route); + var content = new + { + data = new + { + type = "todo-items", + relationships = new Dictionary + { + { "owner", new { + data = new + { + type = "people", + id = owner.Id.ToString() + } + } } + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + + // act + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + // assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var todoItemResult = (TodoItem)_fixture.GetService().Deserialize(body); + Assert.NotNull(todoItemResult); + Assert.NotNull(todoItemResult.Owner); + Assert.Equal(owner.FirstName, todoItemResult.Owner.FirstName); + } + [Fact] public async Task Can_Create_And_Set_HasOne_Relationships_From_Independent_Side() {