Skip to content

Route many-to-many loading through an injectable IManyToManyLoaderFactory (#38363)#38411

Open
ajcvickers wants to merge 1 commit into
dotnet:mainfrom
ajcvickers:m2m-loader-di
Open

Route many-to-many loading through an injectable IManyToManyLoaderFactory (#38363)#38411
ajcvickers wants to merge 1 commit into
dotnet:mainfrom
ajcvickers:m2m-loader-di

Conversation

@ajcvickers

@ajcvickers ajcvickers commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #38363.

  • 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.

…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.
@ajcvickers ajcvickers requested a review from a team as a code owner June 11, 2026 10:00
Copilot AI review requested due to automatic review settings June 11, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 IManyToManyLoaderFactory as a singleton service and wires it into DbContext dependencies.
  • 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.

Comment on lines +13 to +16
/// 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" />.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow providers to replace the many-to-many (skip-navigation) loader

2 participants