fix(generator): dispatch DI projections through partial-class override (marten#4787)#481
Merged
Merged
Conversation
…e (marten#4787)
The 2.14.1 GetUninitializedObject fallback in BuildProjectionInstanceExpression
fixed CS7036 for DI-only-ctor projections at compile time but introduced a
runtime regression: every conventional Apply/Create/ShouldDelete fired against
an uninitialized shadow instance, so any deref of a constructor-injected field
NRE'd. The reporter's null-conditional masked the NRE as silent zero events.
The runtime dispatch chain in JasperFxAggregationProjectionBase already prefers
an isOverridden(Evolve/EvolveAsync/DetermineAction*) check ahead of
tryUseAssemblyRegisteredEvolver, and the post-override path (evolveDefaultAsync)
calls EvolveAsync on `this` — which IS the DI-built instance with all injected
fields populated. So when the projection has at least one instance-on-projection
convention method AND no public parameterless ctor AND is partial, route to a
new emission path that emits a [GeneratedCode]-attributed override (Evolve /
EvolveAsync / DetermineActionAsync — picked by HasShouldDelete / HasAnyAsync
exactly like the existing file-scoped Evolver) directly into the user's partial
class. Body uses bare convention-method calls so dispatch binds to `this`. The
[assembly: GeneratedEvolver(...)] attribute and the file-scoped Evolver class
are NOT emitted for this case — the runtime selects the override before ever
walking the assembly-registered evolver list.
Plumbing changes (additive, defaulted false everywhere else):
- New helpers: HasPublicParameterlessCtor, InstanceCallPrefix, three
EmitXAsOverride entry points, EmitPartialProjectionWithDIOverride.
- dispatchOnThis flag threaded through EmitCreateCallSync / EmitCreateCall /
EmitApplyCallExpression / EmitApplyCallStatement / EmitShouldDeleteCall and
the four Null/NonNull/{Sync,Async} case generators. When true, instance calls
emit bare method names (binding to `this`); when false (default), they emit
the existing `_projection.` prefix for the file-scoped Evolver path.
- Stateless / parameterless-ctor / aggregate-only / static-only projections
keep the file-scoped Evolver verbatim — no perf or shape change for the
cases that don't need DI.
Test surface:
- di_activated_projection_without_parameterless_ctor_compiles → renamed to
di_activated_projection_without_parameterless_ctor_dispatches_through_partial_override.
Assertion flipped: source must NOT contain GetUninitializedObject /
`file sealed class` / [assembly: GeneratedEvolver] / `_projection.Create` /
`_projection.Apply` for a DI projection; MUST contain a
`partial class DiActivated` with a [GeneratedCode] `override Evolve(...)`
on it. Compile check (no CS7036) preserved.
- Verified against marten#4787 Bug_4787_di_projection_with_instance_apply
(9 cases — Apply/Create/ShouldDelete × Singleton/Scoped/Transient): all
pass; before this fix all 9 failed with NRE through the shadow instance.
- ContainerScopedProjectionTests (71), EventSourcingTests (1411), DaemonTests
(198) all green on net10 against a locally-packed
2.17.0-bug4787local build of this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the runtime regression introduced by #470 / 2.14.1's GetUninitializedObject shadow-instance fallback. Reported in marten#4787.
Root cause
The 2.14.1 fallback in EvolverCodeEmitter.BuildProjectionInstanceExpression (EvolverCodeEmitter.cs:152-163) closed CS7036 for DI-only-ctor projections at compile time, but the file-scoped Evolver it produced built its private shadow projection via RuntimeHelpers.GetUninitializedObject — bypassing the constructor entirely. Every conventional Apply / Create / ShouldDelete then dispatched through that shadow, so any deref of a constructor-injected field NRE'd. The reporter's null-conditional masked it as silent zero events.
Fix shape
When the projection has at least one instance-on-projection convention method AND no public parameterless ctor AND is partial, emit a [GeneratedCode]-attributed override (Evolve / EvolveAsync / DetermineActionAsync — picked by HasShouldDelete / HasAnyAsync exactly like the existing file-scoped Evolver) directly into the user's partial class. Body uses bare convention-method calls so dispatch binds to
this— the DI-built instance with all injected fields populated.For this path the [assembly: GeneratedEvolver(...)] attribute and the file sealed class Evolver are NOT emitted — the runtime's isOverridden(...) precedence (JasperFxAggregationProjectionBase.cs:82-114) picks up the partial-class override before tryUseAssemblyRegisteredEvolver ever runs, so the file-scoped path is unreachable for this case.
Stateless / parameterless-ctor / aggregate-only / static-only projections keep the file-scoped Evolver verbatim — no perf or shape change for the cases that don't need DI.
Plumbing
this); when false (default), they emit the existing_projection.prefix for the file-scoped Evolver path — so the existing emission is unchanged for everything except DI projections.Test
Marten consumption
Follow-up Marten PR will bump JasperFx.Events to the version this ships in, promote the repro to the regression suite, and close marten#4787.