Skip to content

Commit c529811

Browse files
author
Bart Koelman
committed
Optimized DELETE Relationship endpoints (remove from to-many relationship)
Before we'd fetch the full left resource including attributes, along with the full relationship (all related resources with all of their attributes). This commit changes that to only fetch the left ID and the subset of right IDs to be removed, then makes the EF Core change tracker believe the full relationship was loaded, so it can perform change detection and generate SQL for the diff. Replaced PlaceholderResourceCollector usage with resetting the change tracker on CreateResource/UpdateResource and failure in SaveChanges. This fixes broken JSON:API change tracking, where a side effect in database during save goes unnoticed. The refetch-after-save would execute the query, but did not refresh tracked entities. This is by design, see https://stackoverflow.com/questions/46205114/how-to-refresh-an-entity-framework-core-dbcontext. Resetting the full change tracker made this bug surface.
1 parent c3dbf43 commit c529811

28 files changed

+520
-206
lines changed

src/JsonApiDotNetCore/CollectionExtensions.cs

+11
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,16 @@ public static bool DictionaryEqual<TKey, TValue>(this IReadOnlyDictionary<TKey,
7070

7171
return true;
7272
}
73+
74+
public static void AddRange<T>(this ICollection<T> source, IEnumerable<T> itemsToAdd)
75+
{
76+
ArgumentGuard.NotNull(source, nameof(source));
77+
ArgumentGuard.NotNull(itemsToAdd, nameof(itemsToAdd));
78+
79+
foreach (T item in itemsToAdd)
80+
{
81+
source.Add(item);
82+
}
83+
}
7384
}
7485
}

src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs

+8-3
Original file line numberDiff line numberDiff line change
@@ -284,15 +284,20 @@ private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder
284284
{
285285
foreach (IEntityType entityType in dbContext.Model.GetEntityTypes())
286286
{
287-
#pragma warning disable EF1001 // Internal EF Core API usage.
288-
if (entityType is not EntityType { IsImplicitlyCreatedJoinEntityType: true })
289-
#pragma warning restore EF1001 // Internal EF Core API usage.
287+
if (!IsImplicitManyToManyJoinEntity(entityType))
290288
{
291289
builder.Add(entityType.ClrType);
292290
}
293291
}
294292
}
295293

294+
private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType)
295+
{
296+
#pragma warning disable EF1001 // Internal EF Core API usage.
297+
return entityType is EntityType { IsImplicitlyCreatedJoinEntityType: true };
298+
#pragma warning restore EF1001 // Internal EF Core API usage.
299+
}
300+
296301
public void Dispose()
297302
{
298303
_intermediateProvider.Dispose();

src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

+71-41
Original file line numberDiff line numberDiff line change
@@ -182,16 +182,14 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r
182182

183183
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Create resource");
184184

185-
using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext);
186-
187185
foreach (RelationshipAttribute relationship in _targetedFields.Relationships)
188186
{
189187
object rightValue = relationship.GetValue(resourceFromRequest);
190188

191189
object rightValueEvaluated = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightValue, WriteOperationKind.CreateResource,
192190
cancellationToken);
193191

194-
await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, collector, cancellationToken);
192+
await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, cancellationToken);
195193
}
196194

197195
foreach (AttrAttribute attribute in _targetedFields.Attributes)
@@ -207,6 +205,8 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r
207205
await SaveChangesAsync(cancellationToken);
208206

209207
await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken);
208+
209+
_dbContext.ResetChangeTracker();
210210
}
211211

212212
private async Task<object> VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightValue,
@@ -254,8 +254,6 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r
254254

255255
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Update resource");
256256

257-
using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext);
258-
259257
foreach (RelationshipAttribute relationship in _targetedFields.Relationships)
260258
{
261259
object rightValue = relationship.GetValue(resourceFromRequest);
@@ -265,7 +263,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r
265263

266264
AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightValueEvaluated);
267265

268-
await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, collector, cancellationToken);
266+
await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken);
269267
}
270268

271269
foreach (AttrAttribute attribute in _targetedFields.Attributes)
@@ -278,18 +276,21 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r
278276
await SaveChangesAsync(cancellationToken);
279277

280278
await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken);
279+
280+
_dbContext.ResetChangeTracker();
281281
}
282282

283283
protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute relationship, TResource leftResource, object rightValue)
284284
{
285-
bool relationshipIsRequired = false;
286-
287-
if (relationship is not HasManyAttribute { IsManyToMany: true })
285+
if (relationship is HasManyAttribute { IsManyToMany: true })
288286
{
289-
INavigation navigation = TryGetNavigation(relationship);
290-
relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false;
287+
// Many-to-many relationships cannot be required.
288+
return;
291289
}
292290

291+
INavigation navigation = TryGetNavigation(relationship);
292+
bool relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false;
293+
293294
bool relationshipIsBeingCleared = relationship is HasManyAttribute hasManyRelationship
294295
? IsToManyRelationshipBeingCleared(hasManyRelationship, leftResource, rightValue)
295296
: rightValue == null;
@@ -326,31 +327,30 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke
326327
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource");
327328

328329
// This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker.
329-
// If so, we'll reuse the tracked resource instead of a placeholder resource.
330-
var emptyResource = _resourceFactory.CreateInstance<TResource>();
331-
emptyResource.Id = id;
330+
// If so, we'll reuse the tracked resource instead of this placeholder resource.
331+
var placeholderResource = _resourceFactory.CreateInstance<TResource>();
332+
placeholderResource.Id = id;
332333

333-
await _resourceDefinitionAccessor.OnWritingAsync(emptyResource, WriteOperationKind.DeleteResource, cancellationToken);
334+
await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken);
334335

335-
using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext);
336-
TResource resource = collector.CreateForId<TResource, TId>(id);
336+
var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource);
337337

338338
foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceContext<TResource>().Relationships)
339339
{
340340
// Loads the data of the relationship, if in EF Core it is configured in such a way that loading the related
341341
// entities into memory is required for successfully executing the selected deletion behavior.
342342
if (RequiresLoadOfRelationshipForDeletion(relationship))
343343
{
344-
NavigationEntry navigation = GetNavigationEntry(resource, relationship);
344+
NavigationEntry navigation = GetNavigationEntry(resourceTracked, relationship);
345345
await navigation.LoadAsync(cancellationToken);
346346
}
347347
}
348348

349-
_dbContext.Remove(resource);
349+
_dbContext.Remove(resourceTracked);
350350

351351
await SaveChangesAsync(cancellationToken);
352352

353-
await _resourceDefinitionAccessor.OnWriteSucceededAsync(resource, WriteOperationKind.DeleteResource, cancellationToken);
353+
await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken);
354354
}
355355

356356
private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship)
@@ -413,8 +413,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object ri
413413

414414
AssertIsNotClearingRequiredRelationship(relationship, leftResource, rightValueEvaluated);
415415

416-
using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext);
417-
await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, collector, cancellationToken);
416+
await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken);
418417

419418
await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken);
420419

@@ -442,16 +441,18 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet<IIdentif
442441

443442
if (rightResourceIds.Any())
444443
{
445-
using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext);
446-
TResource leftResource = collector.CreateForId<TResource, TId>(leftId);
444+
var leftPlaceholderResource = _resourceFactory.CreateInstance<TResource>();
445+
leftPlaceholderResource.Id = leftId;
447446

448-
await UpdateRelationshipAsync(relationship, leftResource, rightResourceIds, collector, cancellationToken);
447+
var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftPlaceholderResource);
449448

450-
await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.AddToRelationship, cancellationToken);
449+
await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIds, cancellationToken);
450+
451+
await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken);
451452

452453
await SaveChangesAsync(cancellationToken);
453454

454-
await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.AddToRelationship, cancellationToken);
455+
await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken);
455456
}
456457
}
457458

@@ -470,35 +471,62 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour
470471
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship");
471472

472473
var relationship = (HasManyAttribute)_targetedFields.Relationships.Single();
474+
HashSet<IIdentifiable> rightResourceIdsToRemove = rightResourceIds.ToHashSet(IdentifiableComparer.Instance);
473475

474-
await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIds, cancellationToken);
476+
await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIdsToRemove, cancellationToken);
475477

476-
if (rightResourceIds.Any())
478+
if (rightResourceIdsToRemove.Any())
477479
{
480+
var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResource);
481+
482+
// Make EF Core believe any additional resources added from ResourceDefinition already exist in database.
483+
IIdentifiable[] extraResourceIdsToRemove = rightResourceIdsToRemove.Where(rightId => !rightResourceIds.Contains(rightId)).ToArray();
484+
478485
object rightValueStored = relationship.GetValue(leftResource);
479486

480-
HashSet<IIdentifiable> rightResourceIdsToStore =
481-
_collectionConverter.ExtractResources(rightValueStored).ToHashSet(IdentifiableComparer.Instance);
487+
// @formatter:wrap_chained_method_calls chop_always
488+
// @formatter:keep_existing_linebreaks true
482489

483-
rightResourceIdsToStore.ExceptWith(rightResourceIds);
490+
IIdentifiable[] rightResourceIdsStored = _collectionConverter
491+
.ExtractResources(rightValueStored)
492+
.Concat(extraResourceIdsToRemove)
493+
.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource))
494+
.ToArray();
495+
496+
// @formatter:keep_existing_linebreaks restore
497+
// @formatter:wrap_chained_method_calls restore
498+
499+
rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType);
500+
relationship.SetValue(leftResource, rightValueStored);
501+
502+
MarkRelationshipAsLoaded(leftResource, relationship);
484503

485-
AssertIsNotClearingRequiredRelationship(relationship, leftResource, rightResourceIdsToStore);
504+
HashSet<IIdentifiable> rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance);
505+
rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove);
486506

487-
using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext);
488-
await UpdateRelationshipAsync(relationship, leftResource, rightResourceIdsToStore, collector, cancellationToken);
507+
AssertIsNotClearingRequiredRelationship(relationship, leftResourceTracked, rightResourceIdsToStore);
489508

490-
await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken);
509+
await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken);
510+
511+
await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);
491512

492513
await SaveChangesAsync(cancellationToken);
493514

494-
await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken);
515+
await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);
495516
}
496517
}
497518

519+
private void MarkRelationshipAsLoaded(TResource leftResource, RelationshipAttribute relationship)
520+
{
521+
EntityEntry<TResource> leftEntry = _dbContext.Entry(leftResource);
522+
CollectionEntry rightCollectionEntry = leftEntry.Collection(relationship.Property.Name);
523+
rightCollectionEntry.IsLoaded = true;
524+
}
525+
498526
protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign,
499-
PlaceholderResourceCollector collector, CancellationToken cancellationToken)
527+
CancellationToken cancellationToken)
500528
{
501-
object trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType, collector);
529+
object trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType);
502530

503531
if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign))
504532
{
@@ -511,15 +539,15 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
511539
relationship.SetValue(leftResource, trackedValueToAssign);
512540
}
513541

514-
private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType, PlaceholderResourceCollector collector)
542+
private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType)
515543
{
516544
if (rightValue == null)
517545
{
518546
return null;
519547
}
520548

521549
ICollection<IIdentifiable> rightResources = _collectionConverter.ExtractResources(rightValue);
522-
IIdentifiable[] rightResourcesTracked = rightResources.Select(collector.CaptureExisting).ToArray();
550+
IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray();
523551

524552
return rightValue is IEnumerable
525553
? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType)
@@ -551,6 +579,8 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
551579
await _dbContext.Database.CurrentTransaction.RollbackAsync(cancellationToken);
552580
}
553581

582+
_dbContext.ResetChangeTracker();
583+
554584
throw new DataStoreUpdateException(exception);
555585
}
556586
}

src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs

-85
This file was deleted.

0 commit comments

Comments
 (0)