Skip to content

Commit f52e1d3

Browse files
committed
WIP: Investigating fix for #1118
1 parent ea3ab72 commit f52e1d3

File tree

8 files changed

+340
-0
lines changed

8 files changed

+340
-0
lines changed

src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs

+9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Reflection;
12
using JetBrains.Annotations;
23
using JsonApiDotNetCore.Resources;
34
using Microsoft.EntityFrameworkCore;
@@ -8,6 +9,8 @@ namespace JsonApiDotNetCore.Repositories;
89
[PublicAPI]
910
public static class DbContextExtensions
1011
{
12+
private static readonly MethodInfo DbContextSetMethod = typeof(DbContext).GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!;
13+
1114
/// <summary>
1215
/// If not already tracked, attaches the specified resource to the change tracker in <see cref="EntityState.Unchanged" /> state.
1316
/// </summary>
@@ -57,4 +60,10 @@ public static void ResetChangeTracker(this DbContext dbContext)
5760

5861
dbContext.ChangeTracker.Clear();
5962
}
63+
64+
internal static IQueryable Set(this DbContext context, Type entityType)
65+
{
66+
MethodInfo setMethod = DbContextSetMethod.MakeGenericMethod(entityType);
67+
return (IQueryable)setMethod.Invoke(context, null)!;
68+
}
6069
}

src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

+115
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, C
325325

326326
var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource);
327327

328+
EnsureIncomingNavigationsAreTracked(resourceTracked);
329+
330+
/*
328331
foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceType<TResource>().Relationships)
329332
{
330333
// Loads the data of the relationship, if in Entity Framework Core it is configured in such a way that loading
@@ -335,6 +338,7 @@ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, C
335338
await navigation.LoadAsync(cancellationToken);
336339
}
337340
}
341+
*/
338342

339343
_dbContext.Remove(resourceTracked);
340344

@@ -343,6 +347,117 @@ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, C
343347
await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken);
344348
}
345349

350+
private void EnsureIncomingNavigationsAreTracked(TResource resourceTracked)
351+
{
352+
IEntityType[] entityTypes = _dbContext.Model.GetEntityTypes().ToArray();
353+
IEntityType thisEntityType = entityTypes.Single(entityType => entityType.ClrType == typeof(TResource));
354+
355+
HashSet<INavigation> navigationsToLoad = new();
356+
357+
foreach (INavigation navigation in entityTypes.SelectMany(entityType => entityType.GetNavigations()))
358+
{
359+
bool isIncomingNavigation =
360+
navigation.IsOnDependent ? navigation.TargetEntityType == thisEntityType : navigation.DeclaringEntityType == thisEntityType;
361+
362+
if (isIncomingNavigation && navigation.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull)
363+
{
364+
navigationsToLoad.Add(navigation);
365+
}
366+
}
367+
368+
// {Navigation: Customer.FirstOrder (Order) ToPrincipal Order}
369+
// var query = from _dbContext.Set<Customer>().Where(customer => customer.FirstOrder == resourceTracked) // .Select(customer => customer.Id)
370+
371+
// {Navigation: Customer.LastOrder (Order) ToPrincipal Order}
372+
// var query = from _dbContext.Set<Customer>().Where(customer => customer.LastOrder == resourceTracked) // .Select(customer => customer.Id)
373+
374+
// {Navigation: Order.Parent (Order) ToPrincipal Order}
375+
// var query = from _dbContext.Set<Order>().Where(order => order.Parent == resourceTracked) // .Select(order => order.Id)
376+
377+
// {Navigation: ShoppingBasket.CurrentOrder (Order) ToPrincipal Order}
378+
// var query = from _dbContext.Set<ShoppingBasket>().Where(shoppingBasket => shoppingBasket.CurrentOrder == resourceTracked) // .Select(shoppingBasket => shoppingBasket.Id)
379+
380+
var nameFactory = new LambdaParameterNameFactory();
381+
var scopeFactory = new LambdaScopeFactory(nameFactory);
382+
383+
foreach (INavigation navigation in navigationsToLoad)
384+
{
385+
if (!navigation.IsOnDependent && navigation.Inverse != null)
386+
{
387+
// TODO: Handle the case where there is no inverse.
388+
continue;
389+
}
390+
391+
IQueryable source = _dbContext.Set(navigation.DeclaringEntityType.ClrType);
392+
393+
using LambdaScope scope = scopeFactory.CreateScope(source.ElementType);
394+
395+
Expression expression;
396+
397+
if (navigation.IsCollection)
398+
{
399+
/*
400+
{Navigation: WorkItem.Subscribers (ISet<UserAccount>) Collection ToDependent UserAccount}
401+
402+
var subscribers = dbContext.WorkItems
403+
.Where(workItem => workItem == existingWorkItem)
404+
.Include(workItem => workItem.Subscribers)
405+
.Select(workItem => workItem.Subscribers);
406+
*/
407+
408+
Expression left = scope.Accessor;
409+
Expression right = Expression.Constant(resourceTracked, resourceTracked.GetType());
410+
411+
Expression whereBody = Expression.Equal(left, right);
412+
LambdaExpression wherePredicate = Expression.Lambda(whereBody, scope.Parameter);
413+
Expression whereExpression = WhereExtensionMethodCall(source.Expression, scope, wherePredicate);
414+
415+
// TODO: Use typed overload
416+
Expression includeExpression = IncludeExtensionMethodCall(whereExpression, scope, navigation.Name);
417+
418+
MemberExpression selectorBody = Expression.MakeMemberAccess(scope.Accessor, navigation.PropertyInfo);
419+
LambdaExpression selectorLambda = Expression.Lambda(selectorBody, scope.Parameter);
420+
421+
expression = SelectExtensionMethodCall(includeExpression, source.ElementType, navigation.PropertyInfo.PropertyType, selectorLambda);
422+
}
423+
else
424+
{
425+
MemberExpression left = Expression.MakeMemberAccess(scope.Parameter, navigation.PropertyInfo);
426+
ConstantExpression right = Expression.Constant(resourceTracked, resourceTracked.GetType());
427+
428+
Expression body = Expression.Equal(left, right);
429+
LambdaExpression selectorLambda = Expression.Lambda(body, scope.Parameter);
430+
expression = WhereExtensionMethodCall(source.Expression, scope, selectorLambda);
431+
}
432+
433+
IQueryable queryable = source.Provider.CreateQuery(expression);
434+
435+
// Executes the query and loads the returned entities in the change tracker.
436+
// We can likely optimize this by only fetching ~IDs~ (primary/foreign keys) and creating placeholder resources for them.
437+
// The reason we can't fetch by ID is because there's no interception possible (see CompositeKeyTests); there's no access
438+
// to QueryExpressionRewriter, and even if there was, we need to handle unexpected relationships so can't rely on our query abstractions.
439+
object[] results = queryable.Cast<object>().ToArray();
440+
}
441+
}
442+
443+
private Expression WhereExtensionMethodCall(Expression source, LambdaScope lambdaScope, LambdaExpression predicate)
444+
{
445+
return Expression.Call(typeof(Queryable), "Where", lambdaScope.Parameter.Type.AsArray(), source, predicate);
446+
}
447+
448+
private Expression IncludeExtensionMethodCall(Expression source, LambdaScope lambdaScope, string navigationPropertyPath)
449+
{
450+
Expression navigationExpression = Expression.Constant(navigationPropertyPath);
451+
452+
return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", lambdaScope.Parameter.Type.AsArray(), source, navigationExpression);
453+
}
454+
455+
private Expression SelectExtensionMethodCall(Expression source, Type elementType, Type bodyType, Expression selectorBody)
456+
{
457+
Type[] typeArguments = ArrayFactory.Create(elementType, bodyType);
458+
return Expression.Call(typeof(Queryable), "Select", typeArguments, source, selectorBody);
459+
}
460+
346461
private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship)
347462
{
348463
EntityEntry<TResource> entityEntry = _dbContext.Entry(resource);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Experiments")]
9+
public sealed class Customer : Identifiable<long>
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
14+
[HasOne]
15+
public Order? FirstOrder { get; set; }
16+
17+
[HasOne]
18+
public Order? LastOrder { get; set; }
19+
20+
[HasMany]
21+
public ISet<Order> Orders { get; set; } = new HashSet<Order>();
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using JetBrains.Annotations;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
// @formatter:wrap_chained_method_calls chop_always
5+
6+
namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;
7+
8+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
9+
public sealed class ExperimentsDbContext : DbContext
10+
{
11+
public DbSet<Customer> Customers => Set<Customer>();
12+
public DbSet<Order> Orders => Set<Order>();
13+
public DbSet<ShoppingBasket> ShoppingBaskets => Set<ShoppingBasket>();
14+
15+
public ExperimentsDbContext(DbContextOptions<ExperimentsDbContext> options)
16+
: base(options)
17+
{
18+
}
19+
20+
protected override void OnModelCreating(ModelBuilder builder)
21+
{
22+
// https://stackoverflow.com/questions/54326165/ef-core-why-clientsetnull-is-default-ondelete-behavior-for-optional-relations
23+
// https://learn.microsoft.com/en-us/ef/core/saving/cascade-delete
24+
25+
builder.Entity<Customer>()
26+
.HasMany(customer => customer.Orders)
27+
.WithOne(order => order.Customer);
28+
29+
builder.Entity<Customer>()
30+
.HasOne(customer => customer.FirstOrder)
31+
.WithOne()
32+
.HasForeignKey<Customer>("FirstOrderId")
33+
.OnDelete(DeleteBehavior.ClientSetNull);
34+
//.OnDelete(DeleteBehavior.SetNull);
35+
36+
builder.Entity<Customer>()
37+
.HasOne(customer => customer.LastOrder)
38+
.WithOne()
39+
.HasForeignKey<Customer>("LastOrderId")
40+
.OnDelete(DeleteBehavior.ClientSetNull);
41+
//.OnDelete(DeleteBehavior.SetNull);
42+
43+
builder.Entity<Order>()
44+
.HasOne(order => order.Parent)
45+
.WithOne()
46+
.HasForeignKey<Order>("ParentOrderId")
47+
.OnDelete(DeleteBehavior.ClientSetNull);
48+
//.OnDelete(DeleteBehavior.SetNull);
49+
50+
builder.Entity<ShoppingBasket>()
51+
.HasOne(shoppingBasket => shoppingBasket.CurrentOrder)
52+
.WithOne()
53+
.HasForeignKey<ShoppingBasket>("CurrentOrderId")
54+
.OnDelete(DeleteBehavior.ClientSetNull);
55+
//.OnDelete(DeleteBehavior.SetNull);
56+
}
57+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Bogus;
2+
using TestBuildingBlocks;
3+
4+
// @formatter:wrap_chained_method_calls chop_always
5+
// @formatter:keep_existing_linebreaks true
6+
7+
namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;
8+
9+
internal sealed class ExperimentsFakers : FakerContainer
10+
{
11+
private readonly Lazy<Faker<Customer>> _lazyCustomerFaker = new(() =>
12+
new Faker<Customer>()
13+
.UseSeed(GetFakerSeed())
14+
.RuleFor(customer => customer.Name, faker => faker.Person.FullName));
15+
16+
private readonly Lazy<Faker<Order>> _lazyOrderFaker = new(() =>
17+
new Faker<Order>()
18+
.UseSeed(GetFakerSeed())
19+
.RuleFor(order => order.Amount, faker => faker.Finance.Amount()));
20+
21+
private readonly Lazy<Faker<ShoppingBasket>> _lazyShoppingBasketFaker = new(() =>
22+
new Faker<ShoppingBasket>()
23+
.UseSeed(GetFakerSeed())
24+
.RuleFor(shoppingBasket => shoppingBasket.ProductCount, faker => faker.Random.Int(0, 5)));
25+
26+
public Faker<Customer> Customer => _lazyCustomerFaker.Value;
27+
public Faker<Order> Order => _lazyOrderFaker.Value;
28+
public Faker<ShoppingBasket> ShoppingBasket => _lazyShoppingBasketFaker.Value;
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Net;
2+
using FluentAssertions;
3+
using FluentAssertions.Common;
4+
using FluentAssertions.Extensions;
5+
using JsonApiDotNetCore.Configuration;
6+
using JsonApiDotNetCoreTests.IntegrationTests.SoftDeletion;
7+
using Microsoft.AspNetCore.Authentication;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using TestBuildingBlocks;
10+
using Xunit;
11+
12+
namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;
13+
14+
public sealed class ExperimentsTests : IClassFixture<IntegrationTestContext<TestableStartup<ExperimentsDbContext>, ExperimentsDbContext>>
15+
{
16+
private readonly IntegrationTestContext<TestableStartup<ExperimentsDbContext>, ExperimentsDbContext> _testContext;
17+
private readonly ExperimentsFakers _fakers = new();
18+
19+
public ExperimentsTests(IntegrationTestContext<TestableStartup<ExperimentsDbContext>, ExperimentsDbContext> testContext)
20+
{
21+
_testContext = testContext;
22+
23+
testContext.UseController<CustomersController>();
24+
testContext.UseController<OrdersController>();
25+
testContext.UseController<ShoppingBasketsController>();
26+
27+
testContext.ConfigureServicesAfterStartup(services =>
28+
{
29+
services.AddSingleton<ISystemClock>(new FrozenSystemClock
30+
{
31+
UtcNow = 1.January(2005).ToDateTimeOffset()
32+
});
33+
34+
services.AddResourceService<SoftDeletionAwareResourceService<Company, int>>();
35+
services.AddResourceService<SoftDeletionAwareResourceService<Department, int>>();
36+
});
37+
}
38+
39+
[Fact]
40+
public async Task Can_delete_resource()
41+
{
42+
// Arrange
43+
Order existingOrder = _fakers.Order.Generate();
44+
existingOrder.Customer = _fakers.Customer.Generate();
45+
existingOrder.Parent = _fakers.Order.Generate();
46+
existingOrder.Parent.Customer = existingOrder.Customer;
47+
48+
List<ShoppingBasket> existingBaskets = _fakers.ShoppingBasket.Generate(3);
49+
existingBaskets[0].CurrentOrder = existingOrder;
50+
existingBaskets[1].CurrentOrder = existingOrder;
51+
52+
await _testContext.RunOnDatabaseAsync(async dbContext =>
53+
{
54+
dbContext.Orders.Add(existingOrder);
55+
await dbContext.SaveChangesAsync();
56+
57+
existingOrder.Customer.FirstOrder = existingOrder;
58+
existingOrder.Customer.LastOrder = existingOrder;
59+
dbContext.ShoppingBaskets.AddRange(existingBaskets);
60+
await dbContext.SaveChangesAsync();
61+
});
62+
63+
string route = $"/orders/{existingOrder.StringId}";
64+
65+
// Act
66+
(HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync<string>(route);
67+
68+
// Assert
69+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent);
70+
71+
responseDocument.Should().BeEmpty();
72+
}
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Experiments")]
9+
public sealed class Order : Identifiable<long>
10+
{
11+
[Attr]
12+
public decimal Amount { get; set; }
13+
14+
[HasOne]
15+
public Customer Customer { get; set; } = null!;
16+
17+
[HasOne]
18+
public Order? Parent { get; set; }
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Experiments")]
9+
public sealed class ShoppingBasket : Identifiable<long>
10+
{
11+
[Attr]
12+
public int ProductCount { get; set; }
13+
14+
[HasOne]
15+
public Order? CurrentOrder { get; set; }
16+
}

0 commit comments

Comments
 (0)