From 9f71c240939a84bfddafad9ebe9c62d3cb90c5e2 Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Thu, 11 Jun 2026 10:52:34 +0100 Subject: [PATCH] Route many-to-many loading through an injectable IManyToManyLoaderFactory (#38363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TL;DR - Adds IManyToManyLoaderFactory as a replaceable singleton service so providers can override how skip-navigation (many-to-many) collection loaders are created. - Removes the static ManyToManyLoaderFactory.Instance; loaders are now resolved from DI via IDbContextDependencies. - Loaders are cached per skip-navigation on the (singleton) model, so the factory and the loaders it returns must be thread-safe and capture no scoped state. - Native AOT / compiled models emit a SetManyToManyLoaderFactory delegate carrying the concrete generic types, still routed through the runtime factory. - Adds Create() for a reflection-free creation path; the reflection path now forwards to it so overrides are honored either way. ## Background Many-to-many collection loaders for skip navigations were created by a static singleton, `ManyToManyLoaderFactory.Instance`, hard-wired into `SkipNavigation`/`RuntimeSkipNavigation`. Because creation was static there was no way for a provider to substitute its own loader, and the reflection-based generic dispatch (`MakeGenericMethod`) was not overridable and is unavailable to fully reflection-free native AOT. ## Change Introduce `IManyToManyLoaderFactory` as a core service registered with a `Singleton` lifetime, implemented by `ManyToManyLoaderFactory`. The factory is exposed on `IDbContextDependencies` and consumed by `CollectionEntry` when building the target loader, replacing the static `Instance`. Providers can swap the implementation via `ReplaceService`. `IRuntimeSkipNavigation.GetManyToManyLoader` now takes the factory as a parameter; both `SkipNavigation` and `RuntimeSkipNavigation` lazily create and cache the loader through it. A new `Create()` overload provides a reflection-free creation path; the non-generic `Create` still uses `MakeGenericMethod` but now forwards to the virtual `Create<,>` (via the private `CreateManyToMany`) so a provider override is honored on both paths. For native AOT, `CSharpRuntimeModelCodeGenerator` emits a `RuntimeSkipNavigation.SetManyToManyLoaderFactory(...)` call carrying a static delegate with the concrete generic type arguments. At runtime `GetManyToManyLoader` prefers that generated delegate (still invoking the injected factory) and otherwise falls back to the reflection path when dynamic code is supported, throwing `NativeAotNoCompiledModel` when it is not. Compiled-model `BigModel` baselines are regenerated accordingly. ## Tests `ManyToManyLoaderReplacementTest`: - `Provider_can_replace_many_to_many_loader_factory` — a context that replaces the service routes collection loading through the custom factory. - `Generated_loader_factory_delegate_routes_through_the_runtime_factory` — the compiled-model delegate path still defers loader creation to the injected factory. --- .../CSharpRuntimeModelCodeGenerator.cs | 17 +++ src/EFCore/ChangeTracking/CollectionEntry.cs | 3 +- src/EFCore/DbContext.cs | 10 ++ .../EntityFrameworkServicesBuilder.cs | 2 + src/EFCore/Internal/DbContextDependencies.cs | 10 ++ src/EFCore/Internal/IDbContextDependencies.cs | 8 ++ .../Internal/IManyToManyLoaderFactory.cs | 37 ++++++ .../Internal/ManyToManyLoaderFactory.cs | 39 +++---- .../Internal/IRuntimeSkipNavigation.cs | 2 +- .../Metadata/Internal/SkipNavigation.cs | 32 ++--- src/EFCore/Metadata/RuntimeSkipNavigation.cs | 29 ++++- .../BigModel/PrincipalBaseEntityType.cs | 3 + .../BigModel/PrincipalDerivedEntityType.cs | 3 + .../BigModel/PrincipalBaseEntityType.cs | 3 + .../BigModel/PrincipalDerivedEntityType.cs | 3 + .../BigModel/PrincipalBaseEntityType.cs | 3 + .../BigModel/PrincipalDerivedEntityType.cs | 3 + .../PrincipalBaseEntityType.cs | 3 + .../PrincipalDerivedEntityType.cs | 3 + .../BigModel/PrincipalBaseEntityType.cs | 3 + .../BigModel/PrincipalDerivedEntityType.cs | 3 + .../PrincipalBaseEntityType.cs | 3 + .../PrincipalDerivedEntityType.cs | 3 + .../ChangeTracking/ManyToManyLoaderTest.cs | 110 ++++++++++++++++++ 24 files changed, 282 insertions(+), 53 deletions(-) create mode 100644 src/EFCore/Internal/IManyToManyLoaderFactory.cs create mode 100644 test/EFCore.Tests/ChangeTracking/ManyToManyLoaderTest.cs diff --git a/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs index 432db1e9007..d06a982a176 100644 --- a/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs +++ b/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs @@ -2826,6 +2826,23 @@ private void CreateSkipNavigation( SetNavigationBaseProperties(navigation, memberAccessReplacements, parameters); + if (parameters.ForNativeAot) + { + AddNamespace(navigation.TargetEntityType.ClrType, parameters.Namespaces); + AddNamespace(navigation.DeclaringEntityType.ClrType, parameters.Namespaces); + mainBuilder + .Append(navigationVariable) + .AppendLine(".SetManyToManyLoaderFactory(") + .IncrementIndent() + .Append("static (factory, navigation) => factory.Create<") + .Append(_code.Reference(navigation.TargetEntityType.ClrType)) + .Append(", ") + .Append(_code.Reference(navigation.DeclaringEntityType.ClrType)) + .AppendLine(">(navigation));") + .DecrementIndent() + .AppendLine(); + } + CreateAnnotations(navigation, _annotationCodeGenerator.Generate, parameters); mainBuilder diff --git a/src/EFCore/ChangeTracking/CollectionEntry.cs b/src/EFCore/ChangeTracking/CollectionEntry.cs index 859ff54a344..ead8a89190d 100644 --- a/src/EFCore/ChangeTracking/CollectionEntry.cs +++ b/src/EFCore/ChangeTracking/CollectionEntry.cs @@ -337,7 +337,8 @@ private void EnsureInitialized() [field: AllowNull, MaybeNull] private ICollectionLoader TargetLoader => field ??= Metadata is IRuntimeSkipNavigation skipNavigation - ? skipNavigation.GetManyToManyLoader() + ? skipNavigation.GetManyToManyLoader( + InternalEntry.Context.GetDependencies().ManyToManyLoaderFactory) : new EntityFinderCollectionLoaderAdapter( InternalEntry.StateManager.CreateEntityFinder(Metadata.TargetEntityType), (INavigation)Metadata); diff --git a/src/EFCore/DbContext.cs b/src/EFCore/DbContext.cs index e7a86ec66fa..d7f5f7405f3 100644 --- a/src/EFCore/DbContext.cs +++ b/src/EFCore/DbContext.cs @@ -211,6 +211,16 @@ IDbSetSource IDbContextDependencies.SetSource IEntityFinderFactory IDbContextDependencies.EntityFinderFactory => DbContextDependencies.EntityFinderFactory; + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + [EntityFrameworkInternal] + IManyToManyLoaderFactory IDbContextDependencies.ManyToManyLoaderFactory + => DbContextDependencies.ManyToManyLoaderFactory; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Infrastructure/EntityFrameworkServicesBuilder.cs b/src/EFCore/Infrastructure/EntityFrameworkServicesBuilder.cs index 7bb16caffa9..6804603d072 100644 --- a/src/EFCore/Infrastructure/EntityFrameworkServicesBuilder.cs +++ b/src/EFCore/Infrastructure/EntityFrameworkServicesBuilder.cs @@ -59,6 +59,7 @@ public static readonly IDictionary CoreServices { typeof(IDbSetInitializer), new ServiceCharacteristics(ServiceLifetime.Singleton) }, { typeof(IDbSetSource), new ServiceCharacteristics(ServiceLifetime.Singleton) }, { typeof(IEntityFinderSource), new ServiceCharacteristics(ServiceLifetime.Singleton) }, + { typeof(IManyToManyLoaderFactory), new ServiceCharacteristics(ServiceLifetime.Singleton) }, { typeof(IStructuralTypeMaterializerSource), new ServiceCharacteristics(ServiceLifetime.Singleton) }, { typeof(ITypeMappingSource), new ServiceCharacteristics(ServiceLifetime.Singleton) }, { typeof(IModelCustomizer), new ServiceCharacteristics(ServiceLifetime.Singleton) }, @@ -239,6 +240,7 @@ public virtual EntityFrameworkServicesBuilder TryAddCoreServices() TryAdd(); TryAdd(); TryAdd(); + TryAdd(); TryAdd(); TryAdd(); TryAdd(); diff --git a/src/EFCore/Internal/DbContextDependencies.cs b/src/EFCore/Internal/DbContextDependencies.cs index e3cf9768c6e..c5af0d4aaba 100644 --- a/src/EFCore/Internal/DbContextDependencies.cs +++ b/src/EFCore/Internal/DbContextDependencies.cs @@ -35,6 +35,7 @@ public DbContextDependencies( IChangeDetector changeDetector, IDbSetSource setSource, IEntityFinderSource entityFinderSource, + IManyToManyLoaderFactory manyToManyLoaderFactory, IEntityGraphAttacher entityGraphAttacher, IAsyncQueryProvider queryProvider, IStateManager stateManager, @@ -51,6 +52,7 @@ public DbContextDependencies( UpdateLogger = updateLogger; InfrastructureLogger = infrastructureLogger; EntityFinderFactory = new EntityFinderFactory(entityFinderSource, stateManager, setSource, currentContext.Context); + ManyToManyLoaderFactory = manyToManyLoaderFactory; } /// @@ -69,6 +71,14 @@ public DbContextDependencies( /// public IEntityFinderFactory EntityFinderFactory { get; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public IManyToManyLoaderFactory ManyToManyLoaderFactory { get; } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Internal/IDbContextDependencies.cs b/src/EFCore/Internal/IDbContextDependencies.cs index aaf0787f834..48a550764a5 100644 --- a/src/EFCore/Internal/IDbContextDependencies.cs +++ b/src/EFCore/Internal/IDbContextDependencies.cs @@ -35,6 +35,14 @@ public interface IDbContextDependencies /// IEntityFinderFactory EntityFinderFactory { get; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + IManyToManyLoaderFactory ManyToManyLoaderFactory { get; } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore/Internal/IManyToManyLoaderFactory.cs b/src/EFCore/Internal/IManyToManyLoaderFactory.cs new file mode 100644 index 00000000000..5586a42cd3a --- /dev/null +++ b/src/EFCore/Internal/IManyToManyLoaderFactory.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +/// +/// The service lifetime is . This means a single instance +/// is used by many instances and the loaders it creates may be cached on the +/// (singleton) model, so implementations and the loaders they return must be thread-safe and must not +/// capture any scoped service or . +/// +public interface IManyToManyLoaderFactory +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + ICollectionLoader Create(ISkipNavigation skipNavigation); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + ICollectionLoader Create(ISkipNavigation skipNavigation) + where TEntity : class + where TSourceEntity : class; +} diff --git a/src/EFCore/Internal/ManyToManyLoaderFactory.cs b/src/EFCore/Internal/ManyToManyLoaderFactory.cs index e5a772b8fb0..5b01f448bdb 100644 --- a/src/EFCore/Internal/ManyToManyLoaderFactory.cs +++ b/src/EFCore/Internal/ManyToManyLoaderFactory.cs @@ -11,38 +11,31 @@ namespace Microsoft.EntityFrameworkCore.Internal; /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// -public class ManyToManyLoaderFactory +public class ManyToManyLoaderFactory : IManyToManyLoaderFactory { private static readonly MethodInfo GenericCreate = typeof(ManyToManyLoaderFactory).GetTypeInfo().GetDeclaredMethod(nameof(CreateManyToMany))!; - private ManyToManyLoaderFactory() - { - } - - /// - /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to - /// the same compatibility standards as public APIs. It may be changed or removed without notice in - /// any release. You should only use it directly in your code with extreme caution and knowing that - /// doing so can result in application failures when updating to a new Entity Framework Core release. - /// - public static readonly ManyToManyLoaderFactory Instance = new(); - - /// - /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to - /// the same compatibility standards as public APIs. It may be changed or removed without notice in - /// any release. You should only use it directly in your code with extreme caution and knowing that - /// doing so can result in application failures when updating to a new Entity Framework Core release. - /// + /// public virtual ICollectionLoader Create(ISkipNavigation skipNavigation) => (ICollectionLoader)GenericCreate.MakeGenericMethod( skipNavigation.TargetEntityType.ClrType, skipNavigation.DeclaringEntityType.ClrType) - .Invoke(null, [skipNavigation])!; + .Invoke(this, [skipNavigation])!; + + /// + public virtual ICollectionLoader Create(ISkipNavigation skipNavigation) + where TEntity : class + where TSourceEntity : class + => new ManyToManyLoader(skipNavigation); + // Invoked via reflection by the non-generic Create above (GenericCreate). It deliberately + // forwards to the virtual Create<,> so that a provider overriding Create<,> is still honored + // on the reflection path. Do not inline this into MakeGenericMethod against Create<,> directly: + // Create is overloaded, so GetDeclaredMethod(nameof(Create)) would be ambiguous. [UsedImplicitly] - private static ICollectionLoader CreateManyToMany(ISkipNavigation skipNavigation) + private ICollectionLoader CreateManyToMany(ISkipNavigation skipNavigation) where TEntity : class - where TTargetEntity : class - => new ManyToManyLoader(skipNavigation); + where TSourceEntity : class + => Create(skipNavigation); } diff --git a/src/EFCore/Metadata/Internal/IRuntimeSkipNavigation.cs b/src/EFCore/Metadata/Internal/IRuntimeSkipNavigation.cs index b6d6ddecb8f..c6d56c3fb88 100644 --- a/src/EFCore/Metadata/Internal/IRuntimeSkipNavigation.cs +++ b/src/EFCore/Metadata/Internal/IRuntimeSkipNavigation.cs @@ -19,5 +19,5 @@ public interface IRuntimeSkipNavigation : ISkipNavigation, IRuntimeNavigationBas /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - ICollectionLoader GetManyToManyLoader(); + ICollectionLoader GetManyToManyLoader(IManyToManyLoaderFactory factory); } diff --git a/src/EFCore/Metadata/Internal/SkipNavigation.cs b/src/EFCore/Metadata/Internal/SkipNavigation.cs index c5fb4cf508d..e8ce3ed26ec 100644 --- a/src/EFCore/Metadata/Internal/SkipNavigation.cs +++ b/src/EFCore/Metadata/Internal/SkipNavigation.cs @@ -20,6 +20,7 @@ public class SkipNavigation : PropertyBase, IMutableSkipNavigation, IConventionS // Warning: Never access these fields directly as access needs to be thread-safe private bool _collectionAccessorInitialized; + private ICollectionLoader? _manyToManyLoader; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -367,21 +368,6 @@ public virtual IClrCollectionAccessor? CollectionAccessor return ClrCollectionAccessorFactory.Instance.Create(navigation); }); - /// - /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to - /// the same compatibility standards as public APIs. It may be changed or removed without notice in - /// any release. You should only use it directly in your code with extreme caution and knowing that - /// doing so can result in application failures when updating to a new Entity Framework Core release. - /// - [field: AllowNull, MaybeNull] - public virtual ICollectionLoader ManyToManyLoader - => NonCapturingLazyInitializer.EnsureInitialized( - ref field, this, static navigation => - { - navigation.EnsureReadOnly(); - return ManyToManyLoaderFactory.Instance.Create(navigation); - }); - /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -480,12 +466,12 @@ IReadOnlySkipNavigation IReadOnlySkipNavigation.Inverse IClrCollectionAccessor? IPropertyBase.GetCollectionAccessor() => CollectionAccessor; - /// - /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to - /// the same compatibility standards as public APIs. It may be changed or removed without notice in - /// any release. You should only use it directly in your code with extreme caution and knowing that - /// doing so can result in application failures when updating to a new Entity Framework Core release. - /// - ICollectionLoader IRuntimeSkipNavigation.GetManyToManyLoader() - => ManyToManyLoader; + /// + ICollectionLoader IRuntimeSkipNavigation.GetManyToManyLoader(IManyToManyLoaderFactory factory) + => NonCapturingLazyInitializer.EnsureInitialized( + ref _manyToManyLoader, this, factory, static (navigation, factory) => + { + navigation.EnsureReadOnly(); + return factory.Create(navigation); + }); } diff --git a/src/EFCore/Metadata/RuntimeSkipNavigation.cs b/src/EFCore/Metadata/RuntimeSkipNavigation.cs index 623d223101e..46c21ef98a1 100644 --- a/src/EFCore/Metadata/RuntimeSkipNavigation.cs +++ b/src/EFCore/Metadata/RuntimeSkipNavigation.cs @@ -24,6 +24,9 @@ public class RuntimeSkipNavigation : RuntimePropertyBase, IRuntimeSkipNavigation private IClrCollectionAccessor? _collectionAccessor; private bool _collectionAccessorInitialized; private ICollectionLoader? _manyToManyLoader; + // An optional compiled-model delegate that creates the loader, letting a compiled model carry the + // concrete generic types for native AOT. Null unless set during model build. + private Func? _manyToManyLoaderDelegatedFactory; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -137,6 +140,17 @@ public virtual void SetCollectionAccessor( _collectionAccessorInitialized = true; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + [EntityFrameworkInternal] + public virtual void SetManyToManyLoaderFactory( + Func factory) + => _manyToManyLoaderDelegatedFactory = factory; + /// /// Returns a string that represents the current object. /// @@ -204,10 +218,15 @@ bool IReadOnlySkipNavigation.IsOnDependent : null); /// - ICollectionLoader IRuntimeSkipNavigation.GetManyToManyLoader() + ICollectionLoader IRuntimeSkipNavigation.GetManyToManyLoader(IManyToManyLoaderFactory factory) => NonCapturingLazyInitializer.EnsureInitialized( - ref _manyToManyLoader, this, static navigation => - RuntimeFeature.IsDynamicCodeSupported - ? ManyToManyLoaderFactory.Instance.Create(navigation) - : throw new InvalidOperationException(CoreStrings.NativeAotNoCompiledModel)); + ref _manyToManyLoader, this, factory, static (navigation, factory) => + { + var generated = navigation._manyToManyLoaderDelegatedFactory; + return generated != null + ? generated(factory, navigation) + : RuntimeFeature.IsDynamicCodeSupported + ? factory.Create(navigation) + : throw new InvalidOperationException(CoreStrings.NativeAotNoCompiledModel); + }); } diff --git a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs index 64ee284a8eb..06643bad1a0 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs @@ -919,6 +919,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalBase entity, ICollection collection) => PrincipalBaseUnsafeAccessors.Deriveds(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalBase entity, Action> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>, CompiledModelTestBase.PrincipalBase>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs index e9bbceea33c..edcac7de4b9 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs @@ -87,6 +87,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalDerived> entity, ICollection collection) => PrincipalDerivedUnsafeAccessors>.Principals(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalDerived> entity, Action>, ICollection> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet>, ICollection, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs index 49a781f6b6b..f150d6b05a4 100644 --- a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs +++ b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs @@ -994,6 +994,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalBase entity, ICollection collection) => PrincipalBaseUnsafeAccessors.Deriveds(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalBase entity, Action> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>, CompiledModelTestBase.PrincipalBase>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs index 346e58d8d13..61f3216d5d0 100644 --- a/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs +++ b/test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs @@ -87,6 +87,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalDerived> entity, ICollection collection) => PrincipalDerivedUnsafeAccessors>.Principals(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalDerived> entity, Action>, ICollection> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet>, ICollection, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs index 3a9bcc28112..503437810e6 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs @@ -1061,6 +1061,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalBase entity, ICollection collection) => PrincipalBaseUnsafeAccessors.Deriveds(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalBase entity, Action> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>, CompiledModelTestBase.PrincipalBase>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs index 304a4a7eb6f..808763fd89e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs @@ -100,6 +100,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalDerived> entity, ICollection collection) => PrincipalDerivedUnsafeAccessors>.Principals(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalDerived> entity, Action>, ICollection> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet>, ICollection, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs index a3ce4bf92bd..310e6a7fad7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs @@ -1089,6 +1089,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalBase entity, ICollection collection) => PrincipalBaseUnsafeAccessors.Deriveds(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalBase entity, Action> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>, CompiledModelTestBase.PrincipalBase>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs index e0baebdc179..95e17ad8832 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs @@ -87,6 +87,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalDerived> entity, ICollection collection) => PrincipalDerivedUnsafeAccessors>.Principals(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalDerived> entity, Action>, ICollection> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet>, ICollection, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs index 2712f57ef83..ef000e05e30 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs @@ -981,6 +981,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalBase entity, ICollection collection) => PrincipalBaseUnsafeAccessors.Deriveds(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalBase entity, Action> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>, CompiledModelTestBase.PrincipalBase>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs index dc0e87cd11d..eb57b66fde2 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs @@ -101,6 +101,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalDerived> entity, ICollection collection) => PrincipalDerivedUnsafeAccessors>.Principals(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalDerived> entity, Action>, ICollection> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet>, ICollection, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs index 7b13cb1134c..0b1d43bb873 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs @@ -992,6 +992,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalBase entity, ICollection collection) => PrincipalBaseUnsafeAccessors.Deriveds(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalBase entity, Action> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>, CompiledModelTestBase.PrincipalBase>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs index 30b544eaaf9..3bb0028fffb 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs @@ -88,6 +88,9 @@ public static RuntimeSkipNavigation CreateSkipNavigation1(RuntimeEntityType decl (CompiledModelTestBase.PrincipalDerived> entity, ICollection collection) => PrincipalDerivedUnsafeAccessors>.Principals(entity) = ((ICollection)collection), ICollection (CompiledModelTestBase.PrincipalDerived> entity, Action>, ICollection> setter) => ClrCollectionAccessorFactory.CreateAndSetHashSet>, ICollection, CompiledModelTestBase.PrincipalBase>(entity, setter), ICollection () => ((ICollection)(((ICollection)(new HashSet(ReferenceEqualityComparer.Instance)))))); + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create>>(navigation)); + return skipNavigation; } diff --git a/test/EFCore.Tests/ChangeTracking/ManyToManyLoaderTest.cs b/test/EFCore.Tests/ChangeTracking/ManyToManyLoaderTest.cs new file mode 100644 index 00000000000..f557f85a9c1 --- /dev/null +++ b/test/EFCore.Tests/ChangeTracking/ManyToManyLoaderTest.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.EntityFrameworkCore.ChangeTracking; + +public class ManyToManyLoaderTest +{ + [Fact] + public void Provider_can_replace_many_to_many_loader_factory() + { + using var context = new ReplacedFactoryContext(); + + var left = new Left(); + var right = new Right(); + left.Rights.Add(right); + context.AddRange(left, right); + context.SaveChanges(); + context.ChangeTracker.Clear(); + + var tracked = context.Set().Single(); + + // Accessing the collection loader must route through the replaced factory. + Assert.Throws( + () => context.Entry(tracked).Collection(e => e.Rights).Load()); + } + + [Fact] + public void Generated_loader_factory_delegate_routes_through_the_runtime_factory() + { + using var context = new DelegatePathContext(); + + var skipNavigation = (RuntimeSkipNavigation)context.Model + .FindEntityType(typeof(Left))! + .FindSkipNavigation(nameof(Left.Rights))!; + + // Simulate what the compiled-model generator emits for native AOT: a static delegate + // that carries the concrete generic types but defers creation to the runtime factory. + skipNavigation.SetManyToManyLoaderFactory( + static (factory, navigation) => factory.Create(navigation)); + + var loader = ((IRuntimeSkipNavigation)skipNavigation) + .GetManyToManyLoader(new CustomManyToManyLoaderFactory()); + + // The replaced factory governs creation even via the generated delegate. + Assert.IsType(loader); + } + + private class ReplacedFactoryContext : DbContext + { + protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseInMemoryDatabase(nameof(ReplacedFactoryContext)) + .ReplaceService(); + + protected internal override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().HasMany(e => e.Rights).WithMany(e => e.Lefts); + } + + private class DelegatePathContext : DbContext + { + protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseInMemoryDatabase(nameof(DelegatePathContext)); + + protected internal override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().HasMany(e => e.Rights).WithMany(e => e.Lefts); + } + + private class CustomManyToManyLoaderFactory : IManyToManyLoaderFactory + { + public ICollectionLoader Create(ISkipNavigation skipNavigation) + => new CustomLoader(); + + public ICollectionLoader Create(ISkipNavigation skipNavigation) + where TEntity : class + where TSourceEntity : class + => new CustomLoader(); + } + + private class CustomLoader : ICollectionLoader + { + public void Load(InternalEntityEntry entry, LoadOptions options) + => throw new CustomLoaderSentinelException(); + + public Task LoadAsync(InternalEntityEntry entry, LoadOptions options, CancellationToken cancellationToken = default) + => throw new CustomLoaderSentinelException(); + + public IQueryable Query(InternalEntityEntry entry) + => throw new CustomLoaderSentinelException(); + } + + private sealed class CustomLoaderSentinelException : Exception { } + + private class Left + { + public int Id { get; set; } + public List Rights { get; } = new(); + } + + private class Right + { + public int Id { get; set; } + public List Lefts { get; } = new(); + } +}