Skip to content

fix(#343): reload relationships from database if included during POST #373

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 11, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 47 additions & 16 deletions src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace JsonApiDotNetCore.Data
{
/// <inheritdoc />
public class DefaultEntityRepository<TEntity>
: DefaultEntityRepository<TEntity, int>,
IEntityRepository<TEntity>
Expand All @@ -26,8 +27,13 @@ public DefaultEntityRepository(
{ }
}

/// <summary>
/// Provides a default repository implementation and is responsible for
/// abstracting any EF Core APIs away from the service layer.
/// </summary>
public class DefaultEntityRepository<TEntity, TId>
: IEntityRepository<TEntity, TId>
: IEntityRepository<TEntity, TId>,
IEntityFrameworkRepository<TEntity>
where TEntity : class, IIdentifiable<TId>
{
private readonly DbContext _context;
Expand All @@ -48,7 +54,7 @@ public DefaultEntityRepository(
_genericProcessorFactory = _jsonApiContext.GenericProcessorFactory;
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual IQueryable<TEntity> Get()
{
if (_jsonApiContext.QuerySet?.Fields != null && _jsonApiContext.QuerySet.Fields.Count > 0)
Expand All @@ -57,41 +63,43 @@ public virtual IQueryable<TEntity> Get()
return _dbSet;
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual IQueryable<TEntity> Filter(IQueryable<TEntity> entities, FilterQuery filterQuery)
{
return entities.Filter(_jsonApiContext, filterQuery);
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual IQueryable<TEntity> Sort(IQueryable<TEntity> entities, List<SortQuery> sortQueries)
{
return entities.Sort(sortQueries);
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual async Task<TEntity> GetAsync(TId id)
{
return await Get().SingleOrDefaultAsync(e => e.Id.Equals(id));
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual async Task<TEntity> 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;
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual async Task<TEntity> CreateAsync(TEntity entity)
{
AttachRelationships();
_dbSet.Add(entity);

await _context.SaveChangesAsync();

return entity;
}

Expand All @@ -101,6 +109,28 @@ protected virtual void AttachRelationships()
AttachHasOnePointers();
}

/// <inheritdoc />
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);
}
}

/// <summary>
/// This is used to allow creation of HasMany relationships when the
/// dependent side of the relationship already exists.
Expand Down Expand Up @@ -129,7 +159,7 @@ private void AttachHasOnePointers()
_context.Entry(relationship.Value).State = EntityState.Unchanged;
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual async Task<TEntity> UpdateAsync(TId id, TEntity entity)
{
var oldEntity = await GetAsync(id);
Expand All @@ -148,14 +178,14 @@ public virtual async Task<TEntity> UpdateAsync(TId id, TEntity entity)
return oldEntity;
}

/// </ inheritdoc>
/// <inheritdoc />
public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable<string> relationshipIds)
{
var genericProcessor = _genericProcessorFactory.GetProcessor<IGenericProcessor>(typeof(GenericProcessor<>), relationship.Type);
await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds);
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual async Task<bool> DeleteAsync(TId id)
{
var entity = await GetAsync(id);
Expand All @@ -170,7 +200,7 @@ public virtual async Task<bool> DeleteAsync(TId id)
return true;
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual IQueryable<TEntity> Include(IQueryable<TEntity> entities, string relationshipName)
{
var entity = _jsonApiContext.RequestEntity;
Expand All @@ -185,10 +215,11 @@ public virtual IQueryable<TEntity> Include(IQueryable<TEntity> entities, string
{
throw new JsonApiException(400, $"Including the relationship {relationshipName} on {entity.EntityName} is not allowed");
}

return entities.Include(relationship.InternalRelationshipName);
}

/// </ inheritdoc>
/// <inheritdoc />
public virtual async Task<IEnumerable<TEntity>> PageAsync(IQueryable<TEntity> entities, int pageSize, int pageNumber)
{
if (pageNumber >= 0)
Expand All @@ -209,23 +240,23 @@ public virtual async Task<IEnumerable<TEntity>> PageAsync(IQueryable<TEntity> en
.ToListAsync();
}

/// </ inheritdoc>
/// <inheritdoc />
public async Task<int> CountAsync(IQueryable<TEntity> entities)
{
return (entities is IAsyncEnumerable<TEntity>)
? await entities.CountAsync()
: entities.Count();
}

/// </ inheritdoc>
/// <inheritdoc />
public async Task<TEntity> FirstOrDefaultAsync(IQueryable<TEntity> entities)
{
return (entities is IAsyncEnumerable<TEntity>)
? await entities.FirstOrDefaultAsync()
: entities.FirstOrDefault();
}

/// </ inheritdoc>
/// <inheritdoc />
public async Task<IReadOnlyList<TEntity>> ToListAsync(IQueryable<TEntity> entities)
{
return (entities is IAsyncEnumerable<TEntity>)
Expand Down
23 changes: 22 additions & 1 deletion src/JsonApiDotNetCore/Data/IEntityRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,29 @@ public interface IEntityRepository<TEntity>
{ }

public interface IEntityRepository<TEntity, in TId>
: IEntityReadRepository<TEntity, TId>,
: IEntityReadRepository<TEntity, TId>,
IEntityWriteRepository<TEntity, TId>
where TEntity : class, IIdentifiable<TId>
{ }

/// <summary>
/// A staging interface to avoid breaking changes that
/// specifically depend on EntityFramework.
/// </summary>
internal interface IEntityFrameworkRepository<TEntity>
{
/// <summary>
/// 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.
///
/// </summary>
/// <remarks>
/// 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
/// </remarks>
void DetachRelationshipPointers(TEntity entity);
}
}
2 changes: 1 addition & 1 deletion src/JsonApiDotNetCore/JsonApiDotNetCore.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionPrefix>2.5.0</VersionPrefix>
<VersionPrefix>2.5.1</VersionPrefix>
<TargetFrameworks>$(NetStandardVersion)</TargetFrameworks>
<AssemblyName>JsonApiDotNetCore</AssemblyName>
<PackageId>JsonApiDotNetCore</PackageId>
Expand Down
11 changes: 11 additions & 0 deletions src/JsonApiDotNetCore/Services/EntityResourceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ public virtual async Task<TResource> 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<TEntity> efRepository)
efRepository.DetachRelationshipPointers(entity);

return await GetWithRelationshipsAsync(entity.Id);
}

return MapOut(entity);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Startup>();
var httpMethod = new HttpMethod("POST");
var server = new TestServer(builder);
var client = server.CreateClient();

var context = _fixture.GetService<AppDbContext>();

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<string, dynamic>
{
{ "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<IJsonApiDeSerializer>().Deserialize<TodoItemCollection>(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()
{
Expand Down Expand Up @@ -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<Startup>();

var httpMethod = new HttpMethod("POST");
var server = new TestServer(builder);
var client = server.CreateClient();

var context = _fixture.GetService<AppDbContext>();

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<string, dynamic>
{
{ "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<IJsonApiDeSerializer>().Deserialize<TodoItem>(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()
{
Expand Down