Skip to content

Commit

Permalink
WIP: Investigating fix for #1118
Browse files Browse the repository at this point in the history
  • Loading branch information
bkoelman committed Oct 26, 2022
1 parent ea3ab72 commit f52e1d3
Show file tree
Hide file tree
Showing 8 changed files with 340 additions and 0 deletions.
9 changes: 9 additions & 0 deletions src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Reflection;
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using Microsoft.EntityFrameworkCore;
Expand All @@ -8,6 +9,8 @@ namespace JsonApiDotNetCore.Repositories;
[PublicAPI]
public static class DbContextExtensions
{
private static readonly MethodInfo DbContextSetMethod = typeof(DbContext).GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!;

/// <summary>
/// If not already tracked, attaches the specified resource to the change tracker in <see cref="EntityState.Unchanged" /> state.
/// </summary>
Expand Down Expand Up @@ -57,4 +60,10 @@ public static void ResetChangeTracker(this DbContext dbContext)

dbContext.ChangeTracker.Clear();
}

internal static IQueryable Set(this DbContext context, Type entityType)
{
MethodInfo setMethod = DbContextSetMethod.MakeGenericMethod(entityType);
return (IQueryable)setMethod.Invoke(context, null)!;
}
}
115 changes: 115 additions & 0 deletions src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,9 @@ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, C

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

EnsureIncomingNavigationsAreTracked(resourceTracked);

/*
foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceType<TResource>().Relationships)
{
// Loads the data of the relationship, if in Entity Framework Core it is configured in such a way that loading
Expand All @@ -335,6 +338,7 @@ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, C
await navigation.LoadAsync(cancellationToken);
}
}
*/

_dbContext.Remove(resourceTracked);

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

private void EnsureIncomingNavigationsAreTracked(TResource resourceTracked)
{
IEntityType[] entityTypes = _dbContext.Model.GetEntityTypes().ToArray();
IEntityType thisEntityType = entityTypes.Single(entityType => entityType.ClrType == typeof(TResource));

HashSet<INavigation> navigationsToLoad = new();

foreach (INavigation navigation in entityTypes.SelectMany(entityType => entityType.GetNavigations()))
{
bool isIncomingNavigation =
navigation.IsOnDependent ? navigation.TargetEntityType == thisEntityType : navigation.DeclaringEntityType == thisEntityType;

if (isIncomingNavigation && navigation.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull)
{
navigationsToLoad.Add(navigation);
}
}

// {Navigation: Customer.FirstOrder (Order) ToPrincipal Order}
// var query = from _dbContext.Set<Customer>().Where(customer => customer.FirstOrder == resourceTracked) // .Select(customer => customer.Id)

// {Navigation: Customer.LastOrder (Order) ToPrincipal Order}
// var query = from _dbContext.Set<Customer>().Where(customer => customer.LastOrder == resourceTracked) // .Select(customer => customer.Id)

// {Navigation: Order.Parent (Order) ToPrincipal Order}
// var query = from _dbContext.Set<Order>().Where(order => order.Parent == resourceTracked) // .Select(order => order.Id)

// {Navigation: ShoppingBasket.CurrentOrder (Order) ToPrincipal Order}
// var query = from _dbContext.Set<ShoppingBasket>().Where(shoppingBasket => shoppingBasket.CurrentOrder == resourceTracked) // .Select(shoppingBasket => shoppingBasket.Id)

var nameFactory = new LambdaParameterNameFactory();
var scopeFactory = new LambdaScopeFactory(nameFactory);

foreach (INavigation navigation in navigationsToLoad)
{
if (!navigation.IsOnDependent && navigation.Inverse != null)
{
// TODO: Handle the case where there is no inverse.
continue;
}

IQueryable source = _dbContext.Set(navigation.DeclaringEntityType.ClrType);

using LambdaScope scope = scopeFactory.CreateScope(source.ElementType);

Expression expression;

if (navigation.IsCollection)
{
/*
{Navigation: WorkItem.Subscribers (ISet<UserAccount>) Collection ToDependent UserAccount}
var subscribers = dbContext.WorkItems
.Where(workItem => workItem == existingWorkItem)
.Include(workItem => workItem.Subscribers)
.Select(workItem => workItem.Subscribers);
*/

Expression left = scope.Accessor;
Expression right = Expression.Constant(resourceTracked, resourceTracked.GetType());

Expression whereBody = Expression.Equal(left, right);
LambdaExpression wherePredicate = Expression.Lambda(whereBody, scope.Parameter);
Expression whereExpression = WhereExtensionMethodCall(source.Expression, scope, wherePredicate);

// TODO: Use typed overload
Expression includeExpression = IncludeExtensionMethodCall(whereExpression, scope, navigation.Name);

MemberExpression selectorBody = Expression.MakeMemberAccess(scope.Accessor, navigation.PropertyInfo);
LambdaExpression selectorLambda = Expression.Lambda(selectorBody, scope.Parameter);

expression = SelectExtensionMethodCall(includeExpression, source.ElementType, navigation.PropertyInfo.PropertyType, selectorLambda);
}
else
{
MemberExpression left = Expression.MakeMemberAccess(scope.Parameter, navigation.PropertyInfo);
ConstantExpression right = Expression.Constant(resourceTracked, resourceTracked.GetType());

Expression body = Expression.Equal(left, right);
LambdaExpression selectorLambda = Expression.Lambda(body, scope.Parameter);
expression = WhereExtensionMethodCall(source.Expression, scope, selectorLambda);
}

IQueryable queryable = source.Provider.CreateQuery(expression);

// Executes the query and loads the returned entities in the change tracker.
// We can likely optimize this by only fetching ~IDs~ (primary/foreign keys) and creating placeholder resources for them.
// The reason we can't fetch by ID is because there's no interception possible (see CompositeKeyTests); there's no access
// to QueryExpressionRewriter, and even if there was, we need to handle unexpected relationships so can't rely on our query abstractions.
object[] results = queryable.Cast<object>().ToArray();
}
}

private Expression WhereExtensionMethodCall(Expression source, LambdaScope lambdaScope, LambdaExpression predicate)
{
return Expression.Call(typeof(Queryable), "Where", lambdaScope.Parameter.Type.AsArray(), source, predicate);
}

private Expression IncludeExtensionMethodCall(Expression source, LambdaScope lambdaScope, string navigationPropertyPath)
{
Expression navigationExpression = Expression.Constant(navigationPropertyPath);

return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", lambdaScope.Parameter.Type.AsArray(), source, navigationExpression);
}

private Expression SelectExtensionMethodCall(Expression source, Type elementType, Type bodyType, Expression selectorBody)
{
Type[] typeArguments = ArrayFactory.Create(elementType, bodyType);
return Expression.Call(typeof(Queryable), "Select", typeArguments, source, selectorBody);
}

private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship)
{
EntityEntry<TResource> entityEntry = _dbContext.Entry(resource);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Experiments")]
public sealed class Customer : Identifiable<long>
{
[Attr]
public string Name { get; set; } = null!;

[HasOne]
public Order? FirstOrder { get; set; }

[HasOne]
public Order? LastOrder { get; set; }

[HasMany]
public ISet<Order> Orders { get; set; } = new HashSet<Order>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;

// @formatter:wrap_chained_method_calls chop_always

namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class ExperimentsDbContext : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<ShoppingBasket> ShoppingBaskets => Set<ShoppingBasket>();

public ExperimentsDbContext(DbContextOptions<ExperimentsDbContext> options)
: base(options)
{
}

protected override void OnModelCreating(ModelBuilder builder)
{
// https://stackoverflow.com/questions/54326165/ef-core-why-clientsetnull-is-default-ondelete-behavior-for-optional-relations
// https://learn.microsoft.com/en-us/ef/core/saving/cascade-delete

builder.Entity<Customer>()
.HasMany(customer => customer.Orders)
.WithOne(order => order.Customer);

builder.Entity<Customer>()
.HasOne(customer => customer.FirstOrder)
.WithOne()
.HasForeignKey<Customer>("FirstOrderId")
.OnDelete(DeleteBehavior.ClientSetNull);
//.OnDelete(DeleteBehavior.SetNull);

builder.Entity<Customer>()
.HasOne(customer => customer.LastOrder)
.WithOne()
.HasForeignKey<Customer>("LastOrderId")
.OnDelete(DeleteBehavior.ClientSetNull);
//.OnDelete(DeleteBehavior.SetNull);

builder.Entity<Order>()
.HasOne(order => order.Parent)
.WithOne()
.HasForeignKey<Order>("ParentOrderId")
.OnDelete(DeleteBehavior.ClientSetNull);
//.OnDelete(DeleteBehavior.SetNull);

builder.Entity<ShoppingBasket>()
.HasOne(shoppingBasket => shoppingBasket.CurrentOrder)
.WithOne()
.HasForeignKey<ShoppingBasket>("CurrentOrderId")
.OnDelete(DeleteBehavior.ClientSetNull);
//.OnDelete(DeleteBehavior.SetNull);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Bogus;
using TestBuildingBlocks;

// @formatter:wrap_chained_method_calls chop_always
// @formatter:keep_existing_linebreaks true

namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;

internal sealed class ExperimentsFakers : FakerContainer
{
private readonly Lazy<Faker<Customer>> _lazyCustomerFaker = new(() =>
new Faker<Customer>()
.UseSeed(GetFakerSeed())
.RuleFor(customer => customer.Name, faker => faker.Person.FullName));

private readonly Lazy<Faker<Order>> _lazyOrderFaker = new(() =>
new Faker<Order>()
.UseSeed(GetFakerSeed())
.RuleFor(order => order.Amount, faker => faker.Finance.Amount()));

private readonly Lazy<Faker<ShoppingBasket>> _lazyShoppingBasketFaker = new(() =>
new Faker<ShoppingBasket>()
.UseSeed(GetFakerSeed())
.RuleFor(shoppingBasket => shoppingBasket.ProductCount, faker => faker.Random.Int(0, 5)));

public Faker<Customer> Customer => _lazyCustomerFaker.Value;
public Faker<Order> Order => _lazyOrderFaker.Value;
public Faker<ShoppingBasket> ShoppingBasket => _lazyShoppingBasketFaker.Value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Net;
using FluentAssertions;
using FluentAssertions.Common;
using FluentAssertions.Extensions;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCoreTests.IntegrationTests.SoftDeletion;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using TestBuildingBlocks;
using Xunit;

namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;

public sealed class ExperimentsTests : IClassFixture<IntegrationTestContext<TestableStartup<ExperimentsDbContext>, ExperimentsDbContext>>
{
private readonly IntegrationTestContext<TestableStartup<ExperimentsDbContext>, ExperimentsDbContext> _testContext;
private readonly ExperimentsFakers _fakers = new();

public ExperimentsTests(IntegrationTestContext<TestableStartup<ExperimentsDbContext>, ExperimentsDbContext> testContext)
{
_testContext = testContext;

testContext.UseController<CustomersController>();
testContext.UseController<OrdersController>();
testContext.UseController<ShoppingBasketsController>();

testContext.ConfigureServicesAfterStartup(services =>
{
services.AddSingleton<ISystemClock>(new FrozenSystemClock
{
UtcNow = 1.January(2005).ToDateTimeOffset()
});

services.AddResourceService<SoftDeletionAwareResourceService<Company, int>>();
services.AddResourceService<SoftDeletionAwareResourceService<Department, int>>();
});
}

[Fact]
public async Task Can_delete_resource()
{
// Arrange
Order existingOrder = _fakers.Order.Generate();
existingOrder.Customer = _fakers.Customer.Generate();
existingOrder.Parent = _fakers.Order.Generate();
existingOrder.Parent.Customer = existingOrder.Customer;

List<ShoppingBasket> existingBaskets = _fakers.ShoppingBasket.Generate(3);
existingBaskets[0].CurrentOrder = existingOrder;
existingBaskets[1].CurrentOrder = existingOrder;

await _testContext.RunOnDatabaseAsync(async dbContext =>
{
dbContext.Orders.Add(existingOrder);
await dbContext.SaveChangesAsync();

existingOrder.Customer.FirstOrder = existingOrder;
existingOrder.Customer.LastOrder = existingOrder;
dbContext.ShoppingBaskets.AddRange(existingBaskets);
await dbContext.SaveChangesAsync();
});

string route = $"/orders/{existingOrder.StringId}";

// Act
(HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync<string>(route);

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent);

responseDocument.Should().BeEmpty();
}
}
19 changes: 19 additions & 0 deletions test/JsonApiDotNetCoreTests/IntegrationTests/Experiments/Order.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Experiments")]
public sealed class Order : Identifiable<long>
{
[Attr]
public decimal Amount { get; set; }

[HasOne]
public Customer Customer { get; set; } = null!;

[HasOne]
public Order? Parent { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Experiments;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Experiments")]
public sealed class ShoppingBasket : Identifiable<long>
{
[Attr]
public int ProductCount { get; set; }

[HasOne]
public Order? CurrentOrder { get; set; }
}

0 comments on commit f52e1d3

Please sign in to comment.