Skip to content

Commit 5943ccb

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 cd9310f commit 5943ccb

28 files changed

+510
-207
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

+70-42
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;
@@ -325,32 +326,29 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke
325326

326327
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource");
327328

328-
// 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;
329+
var placeholderResource = _resourceFactory.CreateInstance<TResource>();
330+
placeholderResource.Id = id;
332331

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

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

338336
foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceContext<TResource>().Relationships)
339337
{
340338
// Loads the data of the relationship, if in EF Core it is configured in such a way that loading the related
341339
// entities into memory is required for successfully executing the selected deletion behavior.
342340
if (RequiresLoadOfRelationshipForDeletion(relationship))
343341
{
344-
NavigationEntry navigation = GetNavigationEntry(resource, relationship);
342+
NavigationEntry navigation = GetNavigationEntry(resourceTracked, relationship);
345343
await navigation.LoadAsync(cancellationToken);
346344
}
347345
}
348346

349-
_dbContext.Remove(resource);
347+
_dbContext.Remove(resourceTracked);
350348

351349
await SaveChangesAsync(cancellationToken);
352350

353-
await _resourceDefinitionAccessor.OnWriteSucceededAsync(resource, WriteOperationKind.DeleteResource, cancellationToken);
351+
await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken);
354352
}
355353

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

414412
AssertIsNotClearingRequiredRelationship(relationship, leftResource, rightValueEvaluated);
415413

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

419416
await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken);
420417

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

443440
if (rightResourceIds.Any())
444441
{
445-
using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext);
446-
TResource leftResource = collector.CreateForId<TResource, TId>(leftId);
442+
var leftPlaceholderResource = _resourceFactory.CreateInstance<TResource>();
443+
leftPlaceholderResource.Id = leftId;
447444

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

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

452451
await SaveChangesAsync(cancellationToken);
453452

454-
await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.AddToRelationship, cancellationToken);
453+
await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken);
455454
}
456455
}
457456

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

472471
var relationship = (HasManyAttribute)_targetedFields.Relationships.Single();
472+
HashSet<IIdentifiable> rightResourceIdsToRemove = rightResourceIds.ToHashSet(IdentifiableComparer.Instance);
473473

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

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

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

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

485-
AssertIsNotClearingRequiredRelationship(relationship, leftResource, rightResourceIdsToStore);
502+
HashSet<IIdentifiable> rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance);
503+
rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove);
486504

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

490-
await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken);
507+
await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken);
508+
509+
await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);
491510

492511
await SaveChangesAsync(cancellationToken);
493512

494-
await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken);
513+
await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken);
495514
}
496515
}
497516

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

503529
if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign))
504530
{
@@ -511,15 +537,15 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship,
511537
relationship.SetValue(leftResource, trackedValueToAssign);
512538
}
513539

514-
private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType, PlaceholderResourceCollector collector)
540+
private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType)
515541
{
516542
if (rightValue == null)
517543
{
518544
return null;
519545
}
520546

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

524550
return rightValue is IEnumerable
525551
? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType)
@@ -551,6 +577,8 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
551577
await _dbContext.Database.CurrentTransaction.RollbackAsync(cancellationToken);
552578
}
553579

580+
_dbContext.ResetChangeTracker();
581+
554582
throw new DataStoreUpdateException(exception);
555583
}
556584
}

src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs

-85
This file was deleted.

0 commit comments

Comments
 (0)