Skip to content

Commit c33c455

Browse files
authored
Merge pull request #373 from json-api-dotnet/fix/#343
fix(#343): reload relationships from database if included during POST
2 parents 262d341 + 09f3846 commit c33c455

File tree

5 files changed

+202
-18
lines changed

5 files changed

+202
-18
lines changed

src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace JsonApiDotNetCore.Data
1515
{
16+
/// <inheritdoc />
1617
public class DefaultEntityRepository<TEntity>
1718
: DefaultEntityRepository<TEntity, int>,
1819
IEntityRepository<TEntity>
@@ -26,8 +27,13 @@ public DefaultEntityRepository(
2627
{ }
2728
}
2829

30+
/// <summary>
31+
/// Provides a default repository implementation and is responsible for
32+
/// abstracting any EF Core APIs away from the service layer.
33+
/// </summary>
2934
public class DefaultEntityRepository<TEntity, TId>
30-
: IEntityRepository<TEntity, TId>
35+
: IEntityRepository<TEntity, TId>,
36+
IEntityFrameworkRepository<TEntity>
3137
where TEntity : class, IIdentifiable<TId>
3238
{
3339
private readonly DbContext _context;
@@ -48,7 +54,7 @@ public DefaultEntityRepository(
4854
_genericProcessorFactory = _jsonApiContext.GenericProcessorFactory;
4955
}
5056

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

60-
/// </ inheritdoc>
66+
/// <inheritdoc />
6167
public virtual IQueryable<TEntity> Filter(IQueryable<TEntity> entities, FilterQuery filterQuery)
6268
{
6369
return entities.Filter(_jsonApiContext, filterQuery);
6470
}
6571

66-
/// </ inheritdoc>
72+
/// <inheritdoc />
6773
public virtual IQueryable<TEntity> Sort(IQueryable<TEntity> entities, List<SortQuery> sortQueries)
6874
{
6975
return entities.Sort(sortQueries);
7076
}
7177

72-
/// </ inheritdoc>
78+
/// <inheritdoc />
7379
public virtual async Task<TEntity> GetAsync(TId id)
7480
{
7581
return await Get().SingleOrDefaultAsync(e => e.Id.Equals(id));
7682
}
7783

78-
/// </ inheritdoc>
84+
/// <inheritdoc />
7985
public virtual async Task<TEntity> GetAndIncludeAsync(TId id, string relationshipName)
8086
{
8187
_logger.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})");
8288

83-
var result = await Include(Get(), relationshipName).SingleOrDefaultAsync(e => e.Id.Equals(id));
89+
var includedSet = Include(Get(), relationshipName);
90+
var result = await includedSet.SingleOrDefaultAsync(e => e.Id.Equals(id));
8491

8592
return result;
8693
}
8794

88-
/// </ inheritdoc>
95+
/// <inheritdoc />
8996
public virtual async Task<TEntity> CreateAsync(TEntity entity)
9097
{
9198
AttachRelationships();
9299
_dbSet.Add(entity);
93100

94101
await _context.SaveChangesAsync();
102+
95103
return entity;
96104
}
97105

@@ -101,6 +109,28 @@ protected virtual void AttachRelationships()
101109
AttachHasOnePointers();
102110
}
103111

112+
/// <inheritdoc />
113+
public void DetachRelationshipPointers(TEntity entity)
114+
{
115+
foreach (var hasOneRelationship in _jsonApiContext.HasOneRelationshipPointers.Get())
116+
{
117+
_context.Entry(hasOneRelationship.Value).State = EntityState.Detached;
118+
}
119+
120+
foreach (var hasManyRelationship in _jsonApiContext.HasManyRelationshipPointers.Get())
121+
{
122+
foreach (var pointer in hasManyRelationship.Value)
123+
{
124+
_context.Entry(pointer).State = EntityState.Detached;
125+
}
126+
127+
// HACK: detaching has many relationships doesn't appear to be sufficient
128+
// the navigation property actually needs to be nulled out, otherwise
129+
// EF adds duplicate instances to the collection
130+
hasManyRelationship.Key.SetValue(entity, null);
131+
}
132+
}
133+
104134
/// <summary>
105135
/// This is used to allow creation of HasMany relationships when the
106136
/// dependent side of the relationship already exists.
@@ -129,7 +159,7 @@ private void AttachHasOnePointers()
129159
_context.Entry(relationship.Value).State = EntityState.Unchanged;
130160
}
131161

132-
/// </ inheritdoc>
162+
/// <inheritdoc />
133163
public virtual async Task<TEntity> UpdateAsync(TId id, TEntity entity)
134164
{
135165
var oldEntity = await GetAsync(id);
@@ -148,14 +178,14 @@ public virtual async Task<TEntity> UpdateAsync(TId id, TEntity entity)
148178
return oldEntity;
149179
}
150180

151-
/// </ inheritdoc>
181+
/// <inheritdoc />
152182
public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable<string> relationshipIds)
153183
{
154184
var genericProcessor = _genericProcessorFactory.GetProcessor<IGenericProcessor>(typeof(GenericProcessor<>), relationship.Type);
155185
await genericProcessor.UpdateRelationshipsAsync(parent, relationship, relationshipIds);
156186
}
157187

158-
/// </ inheritdoc>
188+
/// <inheritdoc />
159189
public virtual async Task<bool> DeleteAsync(TId id)
160190
{
161191
var entity = await GetAsync(id);
@@ -170,7 +200,7 @@ public virtual async Task<bool> DeleteAsync(TId id)
170200
return true;
171201
}
172202

173-
/// </ inheritdoc>
203+
/// <inheritdoc />
174204
public virtual IQueryable<TEntity> Include(IQueryable<TEntity> entities, string relationshipName)
175205
{
176206
var entity = _jsonApiContext.RequestEntity;
@@ -185,10 +215,11 @@ public virtual IQueryable<TEntity> Include(IQueryable<TEntity> entities, string
185215
{
186216
throw new JsonApiException(400, $"Including the relationship {relationshipName} on {entity.EntityName} is not allowed");
187217
}
218+
188219
return entities.Include(relationship.InternalRelationshipName);
189220
}
190221

191-
/// </ inheritdoc>
222+
/// <inheritdoc />
192223
public virtual async Task<IEnumerable<TEntity>> PageAsync(IQueryable<TEntity> entities, int pageSize, int pageNumber)
193224
{
194225
if (pageNumber >= 0)
@@ -209,23 +240,23 @@ public virtual async Task<IEnumerable<TEntity>> PageAsync(IQueryable<TEntity> en
209240
.ToListAsync();
210241
}
211242

212-
/// </ inheritdoc>
243+
/// <inheritdoc />
213244
public async Task<int> CountAsync(IQueryable<TEntity> entities)
214245
{
215246
return (entities is IAsyncEnumerable<TEntity>)
216247
? await entities.CountAsync()
217248
: entities.Count();
218249
}
219250

220-
/// </ inheritdoc>
251+
/// <inheritdoc />
221252
public async Task<TEntity> FirstOrDefaultAsync(IQueryable<TEntity> entities)
222253
{
223254
return (entities is IAsyncEnumerable<TEntity>)
224255
? await entities.FirstOrDefaultAsync()
225256
: entities.FirstOrDefault();
226257
}
227258

228-
/// </ inheritdoc>
259+
/// <inheritdoc />
229260
public async Task<IReadOnlyList<TEntity>> ToListAsync(IQueryable<TEntity> entities)
230261
{
231262
return (entities is IAsyncEnumerable<TEntity>)

src/JsonApiDotNetCore/Data/IEntityRepository.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,29 @@ public interface IEntityRepository<TEntity>
88
{ }
99

1010
public interface IEntityRepository<TEntity, in TId>
11-
: IEntityReadRepository<TEntity, TId>,
11+
: IEntityReadRepository<TEntity, TId>,
1212
IEntityWriteRepository<TEntity, TId>
1313
where TEntity : class, IIdentifiable<TId>
1414
{ }
15+
16+
/// <summary>
17+
/// A staging interface to avoid breaking changes that
18+
/// specifically depend on EntityFramework.
19+
/// </summary>
20+
internal interface IEntityFrameworkRepository<TEntity>
21+
{
22+
/// <summary>
23+
/// Ensures that any relationship pointers created during a POST or PATCH
24+
/// request are detached from the DbContext.
25+
/// This allows the relationships to be fully loaded from the database.
26+
///
27+
/// </summary>
28+
/// <remarks>
29+
/// The only known case when this should be called is when a POST request is
30+
/// sent with an ?include query.
31+
///
32+
/// See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343
33+
/// </remarks>
34+
void DetachRelationshipPointers(TEntity entity);
35+
}
1536
}

src/JsonApiDotNetCore/JsonApiDotNetCore.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
3-
<VersionPrefix>2.5.0</VersionPrefix>
3+
<VersionPrefix>2.5.1</VersionPrefix>
44
<TargetFrameworks>$(NetStandardVersion)</TargetFrameworks>
55
<AssemblyName>JsonApiDotNetCore</AssemblyName>
66
<PackageId>JsonApiDotNetCore</PackageId>

src/JsonApiDotNetCore/Services/EntityResourceService.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ public virtual async Task<TResource> CreateAsync(TResource resource)
7777

7878
entity = await _entities.CreateAsync(entity);
7979

80+
// this ensures relationships get reloaded from the database if they have
81+
// been requested
82+
// https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343
83+
if (ShouldIncludeRelationships())
84+
{
85+
if(_entities is IEntityFrameworkRepository<TEntity> efRepository)
86+
efRepository.DetachRelationshipPointers(entity);
87+
88+
return await GetWithRelationshipsAsync(entity.Id);
89+
}
90+
8091
return MapOut(entity);
8192
}
8293

test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,71 @@ public async Task Can_Create_And_Set_HasMany_Relationships()
285285
Assert.NotEmpty(contextCollection.TodoItems);
286286
}
287287

288+
[Fact]
289+
public async Task Can_Create_With_HasMany_Relationship_And_Include_Result()
290+
{
291+
// arrange
292+
var builder = new WebHostBuilder()
293+
.UseStartup<Startup>();
294+
var httpMethod = new HttpMethod("POST");
295+
var server = new TestServer(builder);
296+
var client = server.CreateClient();
297+
298+
var context = _fixture.GetService<AppDbContext>();
299+
300+
var owner = new JsonApiDotNetCoreExample.Models.Person();
301+
var todoItem = new TodoItem();
302+
todoItem.Owner = owner;
303+
todoItem.Description = "Description";
304+
context.People.Add(owner);
305+
context.TodoItems.Add(todoItem);
306+
await context.SaveChangesAsync();
307+
308+
var route = "/api/v1/todo-collections?include=todo-items";
309+
var request = new HttpRequestMessage(httpMethod, route);
310+
var content = new
311+
{
312+
data = new
313+
{
314+
type = "todo-collections",
315+
relationships = new Dictionary<string, dynamic>
316+
{
317+
{ "owner", new {
318+
data = new
319+
{
320+
type = "people",
321+
id = owner.Id.ToString()
322+
}
323+
} },
324+
{ "todo-items", new {
325+
data = new dynamic[]
326+
{
327+
new {
328+
type = "todo-items",
329+
id = todoItem.Id.ToString()
330+
}
331+
}
332+
} }
333+
}
334+
}
335+
};
336+
337+
request.Content = new StringContent(JsonConvert.SerializeObject(content));
338+
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json");
339+
340+
// act
341+
var response = await client.SendAsync(request);
342+
343+
// assert
344+
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
345+
var body = await response.Content.ReadAsStringAsync();
346+
var collectionResult = _fixture.GetService<IJsonApiDeSerializer>().Deserialize<TodoItemCollection>(body);
347+
348+
Assert.NotNull(collectionResult);
349+
Assert.NotEmpty(collectionResult.TodoItems);
350+
Assert.Equal(todoItem.Description, collectionResult.TodoItems.Single().Description);
351+
}
352+
288353
[Fact]
289354
public async Task Can_Create_And_Set_HasOne_Relationships()
290355
{
@@ -342,6 +407,62 @@ public async Task Can_Create_And_Set_HasOne_Relationships()
342407
Assert.Equal(owner.Id, todoItemResult.OwnerId);
343408
}
344409

410+
[Fact]
411+
public async Task Can_Create_With_HasOne_Relationship_And_Include_Result()
412+
{
413+
// arrange
414+
var builder = new WebHostBuilder().UseStartup<Startup>();
415+
416+
var httpMethod = new HttpMethod("POST");
417+
var server = new TestServer(builder);
418+
var client = server.CreateClient();
419+
420+
var context = _fixture.GetService<AppDbContext>();
421+
422+
var todoItem = new TodoItem();
423+
var owner = new JsonApiDotNetCoreExample.Models.Person
424+
{
425+
FirstName = "Alice"
426+
};
427+
context.People.Add(owner);
428+
429+
await context.SaveChangesAsync();
430+
431+
var route = "/api/v1/todo-items?include=owner";
432+
var request = new HttpRequestMessage(httpMethod, route);
433+
var content = new
434+
{
435+
data = new
436+
{
437+
type = "todo-items",
438+
relationships = new Dictionary<string, dynamic>
439+
{
440+
{ "owner", new {
441+
data = new
442+
{
443+
type = "people",
444+
id = owner.Id.ToString()
445+
}
446+
} }
447+
}
448+
}
449+
};
450+
451+
request.Content = new StringContent(JsonConvert.SerializeObject(content));
452+
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json");
453+
454+
// act
455+
var response = await client.SendAsync(request);
456+
var body = await response.Content.ReadAsStringAsync();
457+
458+
// assert
459+
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
460+
var todoItemResult = (TodoItem)_fixture.GetService<IJsonApiDeSerializer>().Deserialize<TodoItem>(body);
461+
Assert.NotNull(todoItemResult);
462+
Assert.NotNull(todoItemResult.Owner);
463+
Assert.Equal(owner.FirstName, todoItemResult.Owner.FirstName);
464+
}
465+
345466
[Fact]
346467
public async Task Can_Create_And_Set_HasOne_Relationships_From_Independent_Side()
347468
{

0 commit comments

Comments
 (0)