Skip to content

Commit fd96762

Browse files
authored
Merge pull request #496 from wisepotato/feat/#494
Feat/#494
2 parents 2cc9c14 + c1db1d5 commit fd96762

File tree

8 files changed

+575
-13
lines changed

8 files changed

+575
-13
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,43 @@
11
using System;
2+
using System.Linq;
3+
using System.Threading.Tasks;
24
using JsonApiDotNetCore.Controllers;
5+
using JsonApiDotNetCore.Data;
36
using JsonApiDotNetCore.Services;
47
using JsonApiDotNetCoreExample.Models;
8+
using Microsoft.AspNetCore.Mvc;
9+
using Microsoft.EntityFrameworkCore;
510
using Microsoft.Extensions.Logging;
611

712
namespace JsonApiDotNetCoreExample.Controllers
813
{
914
public class TodoCollectionsController : JsonApiController<TodoItemCollection, Guid>
1015
{
16+
17+
readonly IDbContextResolver _dbResolver;
18+
1119
public TodoCollectionsController(
20+
IDbContextResolver contextResolver,
1221
IJsonApiContext jsonApiContext,
1322
IResourceService<TodoItemCollection, Guid> resourceService,
1423
ILoggerFactory loggerFactory)
1524
: base(jsonApiContext, resourceService, loggerFactory)
16-
{ }
25+
{
26+
_dbResolver = contextResolver;
27+
28+
}
29+
30+
[HttpPatch("{id}")]
31+
public override async Task<IActionResult> PatchAsync(Guid id, [FromBody] TodoItemCollection entity)
32+
{
33+
if (entity.Name == "PRE-ATTACH-TEST")
34+
{
35+
var targetTodoId = entity.TodoItems.First().Id;
36+
var todoItemContext = _dbResolver.GetDbSet<TodoItem>();
37+
await todoItemContext.Where(ti => ti.Id == targetTodoId).FirstOrDefaultAsync();
38+
}
39+
return await base.PatchAsync(id, entity);
40+
}
41+
1742
}
1843
}

src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs

+11
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
4040

4141
modelBuilder.Entity<ArticleTag>()
4242
.HasKey(bc => new { bc.ArticleId, bc.TagId });
43+
44+
45+
modelBuilder.Entity<TodoItem>()
46+
.HasOne(t => t.DependentTodoItem);
47+
48+
modelBuilder.Entity<TodoItem>()
49+
.HasMany(t => t.ChildrenTodoItems)
50+
.WithOne(t => t.ParentTodoItem)
51+
.HasForeignKey(t => t.ParentTodoItemId);
52+
53+
4354
}
4455

4556
public DbSet<TodoItem> TodoItems { get; set; }

src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using JsonApiDotNetCore.Models;
34

45
namespace JsonApiDotNetCoreExample.Models
@@ -30,7 +31,7 @@ public TodoItem()
3031
public DateTime? UpdatedDate { get; set; }
3132

3233

33-
34+
3435
public int? OwnerId { get; set; }
3536
public int? AssigneeId { get; set; }
3637
public Guid? CollectionId { get; set; }
@@ -43,5 +44,19 @@ public TodoItem()
4344

4445
[HasOne("collection")]
4546
public virtual TodoItemCollection Collection { get; set; }
47+
48+
public virtual int? DependentTodoItemId { get; set; }
49+
[HasOne("dependent-on-todo")]
50+
public virtual TodoItem DependentTodoItem { get; set; }
51+
52+
53+
54+
55+
// cyclical structure
56+
public virtual int? ParentTodoItemId {get; set;}
57+
[HasOne("parent-todo")]
58+
public virtual TodoItem ParentTodoItem { get; set; }
59+
[HasMany("children-todos")]
60+
public virtual List<TodoItem> ChildrenTodoItems { get; set; }
4661
}
4762
}

src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs

+50-7
Original file line numberDiff line numberDiff line change
@@ -238,13 +238,15 @@ private void AttachHasMany(TEntity entity, HasManyAttribute relationship, IList
238238
var relatedList = (IList)entity.GetType().GetProperty(relationship.EntityPropertyName)?.GetValue(entity);
239239
foreach (var related in relatedList)
240240
{
241-
_context.Entry(related).State = EntityState.Unchanged;
241+
if (_context.EntityIsTracked(related as IIdentifiable) == false)
242+
_context.Entry(related).State = EntityState.Unchanged;
242243
}
243244
}
244245
else
245246
{
246247
foreach (var pointer in pointers)
247248
{
249+
if (_context.EntityIsTracked(pointer as IIdentifiable) == false)
248250
_context.Entry(pointer).State = EntityState.Unchanged;
249251
}
250252
}
@@ -261,7 +263,8 @@ private void AttachHasManyThrough(TEntity entity, HasManyThroughAttribute hasMan
261263

262264
foreach (var pointer in pointers)
263265
{
264-
_context.Entry(pointer).State = EntityState.Unchanged;
266+
if (_context.EntityIsTracked(pointer as IIdentifiable) == false)
267+
_context.Entry(pointer).State = EntityState.Unchanged;
265268
var throughInstance = Activator.CreateInstance(hasManyThrough.ThroughType);
266269

267270
hasManyThrough.LeftProperty.SetValue(throughInstance, entity);
@@ -311,21 +314,61 @@ public virtual async Task<TEntity> UpdateAsync(TId id, TEntity entity)
311314

312315
if (_jsonApiContext.RelationshipsToUpdate.Any())
313316
{
317+
/// For one-to-many and many-to-many, the PATCH must perform a
318+
/// complete replace. When assigning new relationship values,
319+
/// it will only be like this if the to-be-replaced entities are loaded
320+
foreach (var relationship in _jsonApiContext.RelationshipsToUpdate)
321+
{
322+
if (relationship.Key is HasManyThroughAttribute throughAttribute)
323+
{
324+
await _context.Entry(oldEntity).Collection(throughAttribute.InternalThroughName).LoadAsync();
325+
}
326+
}
327+
328+
/// @HACK @TODO: It is inconsistent that for many-to-many, the new relationship value
329+
/// is assigned in AttachRelationships() helper fn below, but not for
330+
/// one-to-many and one-to-one (we need to do that manually as done below).
331+
/// Simultaneously, for a proper working "complete replacement", in the case of many-to-many
332+
/// we need to LoadAsync() BEFORE calling AttachRelationships(), but for one-to-many we
333+
/// need to do it AFTER AttachRelationships or we we'll get entity tracking errors
334+
/// This really needs a refactor.
314335
AttachRelationships(oldEntity);
336+
315337
foreach (var relationship in _jsonApiContext.RelationshipsToUpdate)
316338
{
317-
/// If we are updating to-many relations from PATCH, we need to include the relation first,
318-
/// else it will not peform a complete replacement, as required by the specs.
319-
/// Also, we currently do not support the same for many-to-many
320-
if (relationship.Key is HasManyAttribute && !(relationship.Key is HasManyThroughAttribute))
339+
if ((relationship.Key.TypeId as Type).IsAssignableFrom(typeof(HasOneAttribute)))
340+
{
341+
relationship.Key.SetValue(oldEntity, relationship.Value);
342+
}
343+
if ((relationship.Key.TypeId as Type).IsAssignableFrom(typeof(HasManyAttribute)))
344+
{
321345
await _context.Entry(oldEntity).Collection(relationship.Key.InternalRelationshipName).LoadAsync();
322-
relationship.Key.SetValue(oldEntity, relationship.Value); // article.tags = nieuwe lijst
346+
var value = PreventReattachment((IEnumerable<object>)relationship.Value);
347+
relationship.Key.SetValue(oldEntity, value);
348+
}
323349
}
324350
}
325351
await _context.SaveChangesAsync();
326352
return oldEntity;
327353
}
328354

355+
/// <summary>
356+
/// We need to make sure we're not re-attaching entities when assigning
357+
/// new relationship values. Entities may have been loaded in the change
358+
/// tracker anywhere in the application beyond the control of
359+
/// JsonApiDotNetCore.
360+
/// </summary>
361+
/// <returns>The interpolated related entity collection</returns>
362+
/// <param name="relatedEntities">Related entities.</param>
363+
object PreventReattachment(IEnumerable<object> relatedEntities)
364+
{
365+
var relatedType = TypeHelper.GetTypeOfList(relatedEntities.GetType());
366+
var replaced = relatedEntities.Cast<IIdentifiable>().Select(entity => _context.GetTrackedEntity(entity) ?? entity);
367+
return TypeHelper.ConvertCollection(replaced, relatedType);
368+
369+
}
370+
371+
329372
/// <inheritdoc />
330373
public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable<string> relationshipIds)
331374
{

src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs

+14-4
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,30 @@ public static IQueryable<object> Set(this DbContext context, Type t)
2828
/// Determines whether or not EF is already tracking an entity of the same Type and Id
2929
/// </summary>
3030
public static bool EntityIsTracked(this DbContext context, IIdentifiable entity)
31+
{
32+
return GetTrackedEntity(context, entity) != null;
33+
}
34+
35+
/// <summary>
36+
/// Determines whether or not EF is already tracking an entity of the same Type and Id
37+
/// and returns that entity.
38+
/// </summary>
39+
public static IIdentifiable GetTrackedEntity(this DbContext context, IIdentifiable entity)
3140
{
3241
if (entity == null)
3342
throw new ArgumentNullException(nameof(entity));
34-
43+
3544
var trackedEntries = context.ChangeTracker
3645
.Entries()
37-
.FirstOrDefault(entry =>
38-
entry.Entity.GetType() == entity.GetType()
46+
.FirstOrDefault(entry =>
47+
entry.Entity.GetType() == entity.GetType()
3948
&& ((IIdentifiable)entry.Entity).StringId == entity.StringId
4049
);
4150

42-
return trackedEntries != null;
51+
return (IIdentifiable)trackedEntries?.Entity;
4352
}
4453

54+
4555
/// <summary>
4656
/// Gets the current transaction or creates a new one.
4757
/// If a transaction already exists, commit, rollback and dispose

src/JsonApiDotNetCore/Internal/TypeHelper.cs

+9
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ public static T ConvertType<T>(object value)
6565
return (T)ConvertType(value, typeof(T));
6666
}
6767

68+
public static Type GetTypeOfList(Type type)
69+
{
70+
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>))
71+
{
72+
return type.GetGenericArguments()[0];
73+
}
74+
return null;
75+
}
76+
6877
/// <summary>
6978
/// Convert collection of query string params to Collection of concrete Type
7079
/// </summary>

test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs

+122
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,128 @@ public async Task Can_Update_Many_To_Many()
255255
Assert.Equal(tag.Id, persistedArticleTag.TagId);
256256
}
257257

258+
[Fact]
259+
public async Task Can_Update_Many_To_Many_With_Complete_Replacement()
260+
{
261+
// arrange
262+
var context = _fixture.GetService<AppDbContext>();
263+
var firstTag = _tagFaker.Generate();
264+
var article = _articleFaker.Generate();
265+
var articleTag = new ArticleTag
266+
{
267+
Article = article,
268+
Tag = firstTag
269+
};
270+
context.ArticleTags.Add(articleTag);
271+
var secondTag = _tagFaker.Generate();
272+
context.Tags.Add(secondTag);
273+
await context.SaveChangesAsync();
274+
275+
var route = $"/api/v1/articles/{article.Id}";
276+
var request = new HttpRequestMessage(new HttpMethod("PATCH"), route);
277+
var content = new
278+
{
279+
data = new
280+
{
281+
type = "articles",
282+
id = article.StringId,
283+
relationships = new Dictionary<string, dynamic>
284+
{
285+
{ "tags", new {
286+
data = new [] { new
287+
{
288+
type = "tags",
289+
id = secondTag.StringId
290+
} }
291+
} }
292+
}
293+
}
294+
};
295+
296+
request.Content = new StringContent(JsonConvert.SerializeObject(content));
297+
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json");
298+
299+
// act
300+
var response = await _fixture.Client.SendAsync(request);
301+
302+
// assert
303+
var body = await response.Content.ReadAsStringAsync();
304+
Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}");
305+
306+
var articleResponse = _fixture.GetService<IJsonApiDeSerializer>().Deserialize<Article>(body);
307+
Assert.NotNull(articleResponse);
308+
309+
_fixture.ReloadDbContext();
310+
var persistedArticle = await _fixture.Context.Articles
311+
.Include("ArticleTags.Tag")
312+
.SingleOrDefaultAsync(a => a.Id == article.Id);
313+
var tag = persistedArticle.ArticleTags.Select(at => at.Tag).Single();
314+
Assert.Equal(secondTag.Id, tag.Id);
315+
}
316+
317+
[Fact]
318+
public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap()
319+
{
320+
// arrange
321+
var context = _fixture.GetService<AppDbContext>();
322+
var firstTag = _tagFaker.Generate();
323+
var article = _articleFaker.Generate();
324+
var articleTag = new ArticleTag
325+
{
326+
Article = article,
327+
Tag = firstTag
328+
};
329+
context.ArticleTags.Add(articleTag);
330+
var secondTag = _tagFaker.Generate();
331+
context.Tags.Add(secondTag);
332+
await context.SaveChangesAsync();
333+
334+
var route = $"/api/v1/articles/{article.Id}";
335+
var request = new HttpRequestMessage(new HttpMethod("PATCH"), route);
336+
var content = new
337+
{
338+
data = new
339+
{
340+
type = "articles",
341+
id = article.StringId,
342+
relationships = new Dictionary<string, dynamic>
343+
{
344+
{ "tags", new {
345+
data = new [] { new
346+
{
347+
type = "tags",
348+
id = firstTag.StringId
349+
}, new
350+
{
351+
type = "tags",
352+
id = secondTag.StringId
353+
} }
354+
} }
355+
}
356+
}
357+
};
358+
359+
request.Content = new StringContent(JsonConvert.SerializeObject(content));
360+
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json");
361+
362+
// act
363+
var response = await _fixture.Client.SendAsync(request);
364+
365+
// assert
366+
var body = await response.Content.ReadAsStringAsync();
367+
Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with payload: {body}");
368+
369+
var articleResponse = _fixture.GetService<IJsonApiDeSerializer>().Deserialize<Article>(body);
370+
Assert.NotNull(articleResponse);
371+
372+
_fixture.ReloadDbContext();
373+
var persistedArticle = await _fixture.Context.Articles
374+
.Include(a => a.ArticleTags)
375+
.SingleOrDefaultAsync( a => a.Id == article.Id);
376+
var tags = persistedArticle.ArticleTags.Select(at => at.Tag).ToList();
377+
Assert.Equal(2, tags.Count);
378+
}
379+
258380
[Fact]
259381
public async Task Can_Update_Many_To_Many_Through_Relationship_Link()
260382
{

0 commit comments

Comments
 (0)