Skip to content

[#4454 Phase 1D] Reflection-driven CompiledQueryHandlerDescriptor fallback#4456

Merged
jeremydmiller merged 2 commits into
masterfrom
fix/4454-phase-1cd-source-gen-fec-fallback
May 18, 2026
Merged

[#4454 Phase 1D] Reflection-driven CompiledQueryHandlerDescriptor fallback#4456
jeremydmiller merged 2 commits into
masterfrom
fix/4454-phase-1cd-source-gen-fec-fallback

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Summary

Phase 1D of #4454. When CompiledQueryHandlerRegistry.TryGet(queryType) misses — the consumer's assembly is missing the [JasperFxAssembly] marker that Marten.SourceGenerator needs, or the query type was registered at runtime via reflection — we now build the descriptor reflectively from the freshly walked CompiledQueryPlan instead of falling through to CompiledQuerySourceBuilder / JasperFx.RuntimeCompiler. The constructed descriptor is registered with CompiledQueryHandlerRegistry so subsequent session.Query(compiledQuery) invocations hit the fast registry path — descriptor build cost is paid once per query type, never per row.

After this PR, the Roslyn-emit codegen path (CompiledQuerySourceBuilder, CompiledQueryCodeFile, StatelessCompiledQuery / ClonedCompiledQuery / ComplexCompiledQuery base classes) is runtime-unreachable for the live query dispatch path. Phase 1E deletes the dead code; only BuildFiles()'s codegen surface for the dotnet marten codegen ... CLI remains, and Phase 5 retires that.

(Phase 1C — "finish the source generator" — was already covered by the in-tree CompiledQuerySourceGenerator's iteration-2/3 emit. The generator already discovers ICompiledQuery<TDoc, TOut> implementations in [JasperFxAssembly]-marked assemblies and emits a [ModuleInitializer] per type that registers a CompiledQueryHandlerDescriptor. This PR closes the runtime side of 1C+1D — the fallback when the generator hasn't run.)

What's in RuntimeCompiledQueryDescriptorFactory

Mirrors the source generator's emit:

  • BindParameter — a per-queryType dictionary of per-member binders. Each binder knows the NpgsqlDbType + read strategy for one parameter member (scalar / array / enum / byte[]). EnumStorage branches inline on the enumAsString flag the descriptor caller passes, so a single descriptor serves stores with mixed enum-storage configs.
  • AttachIncludeReaders — dispatches by the include member's declared type (Action<T>, IList<T> / List<T>, IDictionary<TId, T> / Dictionary<TId, T>) onto Include.ReaderToAction<T> / ReaderToList<T> / ReaderToDictionary<T, TId> via MakeGenericMethod. One MakeGenericMethod call per include member per session.Query(...) — cheap relative to the SQL round-trip that follows.
  • ReadStatistics — direct MemberInfo.GetValue read against the StatisticsMember captured on the plan, or null when the query has no statistics member.

DocType is resolved from the query type's ICompiledQuery<TDoc, TOut> generic arguments via reflection (covers ICompiledListQuery and any future shape extensions because they all extend ICompiledQuery<,>).

The factory carries [RequiresUnreferencedCode] + [RequiresDynamicCode] so AOT publishers see the right warning at the boundary. #4454 Phase 5's AOT-compliance pass decides whether this fallback survives long-term or gets replaced behind those annotations with a source-gen-only contract.

Call-site collapse

DocumentStore.CompiledQueryCollection's registry-miss path collapses to:

if (!CompiledQueryHandlerRegistry.TryGet(query.GetType(), out var descriptor))
{
    descriptor = RuntimeCompiledQueryDescriptorFactory.Build(plan);
    CompiledQueryHandlerRegistry.Register(query.GetType(), descriptor);
}
// ... hand to SourceGeneratedCompiledQuerySource<TOut> ...

22-line block replacing the prior CompiledQueryCodeFile + Roslyn-emit fallthrough.

Verification

dotnet test src/CompiledQueryTests/CompiledQueryTests.csproj
→ 9/9 pass

dotnet test src/LinqTests/LinqTests.csproj
→ 1257/1258 pass (one unrelated skip carried over from master)

Follow-up phases

  • 1E — delete the now-unreachable codegen path (CompiledQuerySourceBuilder, CompiledQueryCodeFile, the three codegen-emitted handler base classes, the GenerateCode method on ICompiledQueryAwareFilter).
  • 2 — flip event-store closed-shape to the default + delete the extra closed-shape GH Action profiles.
  • 3 — port ancillary stores to the closed-shape provider.
  • 4 — daemon / projection codegen audit.
  • 5 — remove JasperFx.RuntimeCompiler PackageReference + retire dotnet marten codegen ... CLI verbs. That's the "Boom" PR.

🤖 Generated with Claude Code

…lback

When CompiledQueryHandlerRegistry.TryGet(queryType) misses — i.e. the
consumer's assembly is missing the [JasperFxAssembly] marker that the
source generator needs, or the query type was registered at runtime via
reflection — we now build the descriptor reflectively from the freshly
walked CompiledQueryPlan instead of falling through to
CompiledQuerySourceBuilder / JasperFx.RuntimeCompiler. The constructed
descriptor is registered with CompiledQueryHandlerRegistry so subsequent
session.Query(compiledQuery) invocations hit the fast registry path —
build cost is paid once per query type, never per row.

The new RuntimeCompiledQueryDescriptorFactory mirrors what
CompiledQuerySourceGenerator emits:

* BindParameter — a per-(queryType) dictionary of per-member binders.
  Each binder knows the NpgsqlDbType + the read strategy for one parameter
  member (scalar / array / enum / byte[]). EnumStorage branches inline on
  the enumAsString flag the descriptor caller passes, so the same
  descriptor serves stores with mixed enum-storage configs.

* AttachIncludeReaders — dispatches by the include member's declared type
  (Action<T>, IList<T> / List<T>, IDictionary<TId, T> / Dictionary<TId, T>)
  onto Include.ReaderToAction<T> / ReaderToList<T> / ReaderToDictionary<T,
  TId> via MakeGenericMethod. One MakeGenericMethod call per include
  member per session.Query(...) — cheap relative to the SQL round-trip
  that follows.

* ReadStatistics — direct MemberInfo.GetValue read against the
  StatisticsMember captured on the plan, or null when the query has no
  statistics member.

DocType is resolved from the query type's ICompiledQuery<TDoc, TOut>
generic arguments via reflection (covers ICompiledListQuery / future
shape extensions because they all extend ICompiledQuery<,>).

DocumentStore.CompiledQueryCollection's registry-miss path collapses to
"build descriptor, register, hand to SourceGeneratedCompiledQuerySource"
— a 22-line block replacing the prior CompiledQueryCodeFile + Roslyn
emit fallthrough. The codegen path (CompiledQuerySourceBuilder,
CompiledQueryCodeFile, StatelessCompiledQuery / ClonedCompiledQuery /
ComplexCompiledQuery base classes) is now runtime-unreachable — Phase 1E
deletes the dead code.

The factory carries [RequiresUnreferencedCode] +
[RequiresDynamicCode] so AOT publishers see the right warning at the
boundary; #4454 Phase 5's AOT-compliance pass decides whether this
fallback survives long-term or gets replaced behind those annotations
with a source-gen-only contract.

Verification:

    dotnet test src/CompiledQueryTests/CompiledQueryTests.csproj
    → 9/9 pass

    dotnet test src/LinqTests/LinqTests.csproj
    → 1257/1258 pass (one unrelated skip carried over from master)
The runtime include-reader dispatcher passed the full GetGenericArguments()
array to typeof(IList<>).MakeGenericType regardless of arity, which threw
"The number of generic arguments provided doesn't equal the arity of the
generic type definition" for compiled-query includes shaped as
Dictionary<TId, TDoc> (2 args going to a 1-arg generic).

Split the dispatch by args.Length first so each MakeGenericType call sees
the right shape. Covers LinqTests.Bugs.Bug_4043_include_plus_contains
.CompiledQuery_WithIncludeOnly_Works and
LinqTests.Includes.end_to_end_query_with_compiled_include_Tests.compiled_include_to_dictionary.
@jeremydmiller jeremydmiller merged commit d305db3 into master May 18, 2026
8 checks passed
@jeremydmiller jeremydmiller deleted the fix/4454-phase-1cd-source-gen-fec-fallback branch May 18, 2026 01:22
jeremydmiller added a commit that referenced this pull request May 18, 2026
Phase 1D (#4456) made the JasperFx.RuntimeCompiler-driven path runtime-
unreachable for live compiled-query dispatch — every session.Query(...)
now goes through CompiledQueryHandlerRegistry (source-gen-emitted
descriptor) or RuntimeCompiledQueryDescriptorFactory (reflection
fallback) and lands in SourceGeneratedCompiledQuerySource<TOut>. The
Roslyn-emit classes and the ICodeFile contract for compiled queries
were carrying their weight as a "just in case" PoC bridge; the bridge
is gone. Delete it.

Files removed:
 - CompiledQueryCodeFile.cs (ICodeFile producer for the codegen CLI)
 - CompiledQuerySourceBuilder.cs (Roslyn-emit assembly builder)
 - CompiledQuerySource.cs (codegen base for emitted sources)
 - CompiledSourceType.cs (the Stateless/Cloneable/Complex enum)
 - StatelessCompiledQuery.cs / ClonedCompiledQuery.cs / ComplexCompiledQuery.cs
   (the three codegen-emitted handler base classes — superseded by
   SourceGeneratedStatelessHandler / SourceGeneratedClonedHandler /
   SourceGeneratedComplexHandler)
 - WriteSerializedJsonParameterFrame.cs (codegen Frame for the
   containment / JsonPath JSON-payload writers; runtime equivalent is
   CompiledQueryDictionaryBuilder)

Member-level deletions:
 - ICompiledQueryAwareFilter.GenerateCode — the source-gen + FEC paths
   consume BuildSetter() now; no codegen consumer remains. All seven
   filter implementations drop their GenerateCode overrides:
     · StringContainsFilter, StringStartsWithFilter,
       StringEndsWithFilter, StringEqualsIgnoreCaseFilter
     · ContainmentWhereFilter, ChildCollectionJsonPathCountFilter,
       DictionaryContainsKeyFilter
 - ParameterUsage.GenerateCode / generateSimpleCode / generateEnumCode
   / npgsqlArrayDbTypeCodeFor — the runtime descriptor factory owns
   the same dispatch in RuntimeCompiledQueryDescriptorFactory.
 - CompiledQueryPlan.GenerateCode — orchestrated the codegen emit
   over the plan's commands; no caller.
 - DocumentStore.CompiledQueryCollection's ICodeFileCollection.BuildFiles()
   compiled-query branch — Phase 5 will retire the `dotnet marten
   codegen ...` CLI surface and the ICodeFileCollection contract along
   with it. For now BuildFiles returns Array.Empty so non-compiled-query
   ICodeFile consumers keep working.

The DictionaryValueUsage class survives the WriteSerializedJsonParameterFrame
deletion — moved to its own file because the runtime
CompiledQueryDictionaryBuilder + filter Apply paths still need it.
The codegen-only IDictionaryPart tree (DictionaryDeclaration,
ArrayContainer, ArrayDeclaration, ArrayScalarValue, DictionaryValue)
had no other consumers and is gone.

Verification:

    dotnet test src/CompiledQueryTests/CompiledQueryTests.csproj
    → 9/9 pass

    dotnet test src/LinqTests/LinqTests.csproj --filter "FullyQualifiedName~Compiled"
    → 75/75 pass

901 lines of dead code removed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant