Route many-to-many loading through an injectable IManyToManyLoaderFactory (#38363)#38411
Route many-to-many loading through an injectable IManyToManyLoaderFactory (#38363)#38411ajcvickers wants to merge 1 commit into
Conversation
…tory (dotnet#38363) 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<TEntity, TSourceEntity>() 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<IManyToManyLoaderFactory, ...>`. `IRuntimeSkipNavigation.GetManyToManyLoader` now takes the factory as a parameter; both `SkipNavigation` and `RuntimeSkipNavigation` lazily create and cache the loader through it. A new `Create<TEntity, TSourceEntity>()` 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.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR enables providers and compiled (Native AOT) runtime models to control how many-to-many collection loaders are created, rather than always using EF Core’s static factory.
Changes:
- Introduces
IManyToManyLoaderFactoryas a singleton service and wires it intoDbContextdependencies. - Updates skip-navigation loader creation to route through the injected factory and optionally a compiled-model delegate (
SetManyToManyLoaderFactory). - Adds a targeted test and updates scaffolding baselines/codegen to emit the compiled delegate for Native AOT.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/EFCore.Tests/ChangeTracking/ManyToManyLoaderReplacementTest.cs | Adds coverage verifying service replacement and compiled-delegate routing for many-to-many loaders. |
| test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.Sqlite.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalDerivedEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.InMemory.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalDerivedEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| test/EFCore.Cosmos.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs | Baseline update to include generated SetManyToManyLoaderFactory call. |
| src/EFCore/Metadata/RuntimeSkipNavigation.cs | Adds compiled-model delegate hook and updates runtime loader creation to use an injected factory. |
| src/EFCore/Metadata/Internal/SkipNavigation.cs | Switches runtime loader creation to use an injected factory and caches the result. |
| src/EFCore/Metadata/Internal/IRuntimeSkipNavigation.cs | Updates the internal API to accept an IManyToManyLoaderFactory. |
| src/EFCore/Internal/ManyToManyLoaderFactory.cs | Converts the factory into a DI service implementing IManyToManyLoaderFactory and preserves override behavior on reflection path. |
| src/EFCore/Internal/IManyToManyLoaderFactory.cs | Introduces a new internal interface for loader factory customization. |
| src/EFCore/Internal/IDbContextDependencies.cs | Exposes IManyToManyLoaderFactory via context dependencies. |
| src/EFCore/Internal/DbContextDependencies.cs | Plumbs IManyToManyLoaderFactory through dependency construction. |
| src/EFCore/Infrastructure/EntityFrameworkServicesBuilder.cs | Registers IManyToManyLoaderFactory as a core singleton service. |
| src/EFCore/DbContext.cs | Implements the new dependency property for IManyToManyLoaderFactory. |
| src/EFCore/ChangeTracking/CollectionEntry.cs | Uses the injected factory when loading many-to-many collections. |
| src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs | Emits SetManyToManyLoaderFactory for Native AOT runtime models. |
| /// The service lifetime is <see cref="ServiceLifetime.Singleton" />. This means a single instance | ||
| /// is used by many <see cref="DbContext" /> 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 <see cref="DbContext" />. |
There was a problem hiding this comment.
Thanks, but this is a non-issue here. EFCore.csproj declares project-wide global usings, including <Using Include="Microsoft.Extensions.DependencyInjection" /> (so ServiceLifetime is in scope) and <Using Include="Microsoft.EntityFrameworkCore" /> (so DbContext is in scope); DbContext also resolves via namespace nesting since this file is in Microsoft.EntityFrameworkCore.Internal. Building src/EFCore with documentation generation produces no CS1574/CS0246 for these crefs. Adding file-local usings or fully-qualifying the crefs would be inconsistent with the rest of the codebase, which relies on the same global usings. Leaving as-is.
| private IClrCollectionAccessor? _collectionAccessor; | ||
| private bool _collectionAccessorInitialized; | ||
| private ICollectionLoader? _manyToManyLoader; | ||
| // Holds an optional compiled-model delegate (set once during model build) that creates the loader; read via the EnsureInitialized lambda in GetManyToManyLoader. |
There was a problem hiding this comment.
Good point — reworded to describe the field's purpose (an optional compiled-model delegate that carries the concrete generic types for native AOT) without referencing the initialization helper or call site.
Fixes #38363.
Background
Many-to-many collection loaders for skip navigations were created by a static singleton,
ManyToManyLoaderFactory.Instance, hard-wired intoSkipNavigation/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
IManyToManyLoaderFactoryas a core service registered with aSingletonlifetime, implemented byManyToManyLoaderFactory. The factory is exposed onIDbContextDependenciesand consumed byCollectionEntrywhen building the target loader, replacing the staticInstance. Providers can swap the implementation viaReplaceService<IManyToManyLoaderFactory, ...>.IRuntimeSkipNavigation.GetManyToManyLoadernow takes the factory as a parameter; bothSkipNavigationandRuntimeSkipNavigationlazily create and cache the loader through it. A newCreate<TEntity, TSourceEntity>()overload provides a reflection-free creation path; the non-genericCreatestill usesMakeGenericMethodbut now forwards to the virtualCreate<,>(via the privateCreateManyToMany) so a provider override is honored on both paths.For native AOT,
CSharpRuntimeModelCodeGeneratoremits aRuntimeSkipNavigation.SetManyToManyLoaderFactory(...)call carrying a static delegate with the concrete generic type arguments. At runtimeGetManyToManyLoaderprefers that generated delegate (still invoking the injected factory) and otherwise falls back to the reflection path when dynamic code is supported, throwingNativeAotNoCompiledModelwhen it is not. Compiled-modelBigModelbaselines 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.