Skip to content

Build a missing aggregate through its constructor; let a supertype Create own the null-snapshot case (#1087) - #1090

Merged
jeremydmiller merged 2 commits into
mainfrom
fix/1087-required-members-missing-aggregate
Oct 28, 2026
Merged

jeremydmiller merged 2 commits into
mainfrom
fix/1087-required-members-missing-aggregate

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #1087

Problem

For an Apply-only event on an aggregate that does not exist yet, the source generator built the aggregate as new T { Required = default!, ... } whenever it had required members. That overwrote initializers like public required List<string> Emails { get; set; } = []; with null, so Apply threw. Marten 8 ran the constructor, so initializers ran. A catch-all Create(object) intended to take control of that case made every Apply-fallback arm unreachable instead (CS8120, an error inside generated code).

Fix

BuildAggregateConstructorExpression:

Aggregate Before Now
public parameterless ctor, no required new T() new T()
public ctor marked [SetsRequiredMembers] new T { X = default!, … } new T()
public ctor, required members new T { X = default!, … } (T)Activator.CreateInstance(typeof(T))!
no public parameterless ctor GetUninitializedObject unchanged

required is compile-time only, so Activator.CreateInstance runs the constructor and every initializer, as Marten 8 did; with a typeof literal it stays trim/AOT safe. Only paid on the first event for a missing aggregate.

Null-snapshot switch: Apply-fallback arms already matched by an earlier Create for a base type or interface are no longer emitted. Fixes CS8120 for Create(object) and for an interface Create(IDocEvent) + Apply(Renamed) where Renamed : IDocEvent. The Create wins, as in Marten 8, so Create(object _) => null ignores events for documents that don't exist.

Behaviour change: an Apply-only event on a missing aggregate now sees initializer values where it used to see null.

Not in scope (follow-up): Create(IEvent) recognition, and a constructor taking the event as a creation on the projection path.

Tests

  • RequiredMemberConstructionTests (generator): 4 tests, all failing on the old generator
  • Bug_1087_required_members_on_missing_aggregate (EventTests, real generated evolver): initializers survive, Create(object) => null yields no snapshot, Apply still runs on an existing aggregate; the file does not compile on the old generator
  • Generator tests 105/105; EventTests 1646/1646 on net9.0 and net10.0

🤖 Generated with Claude Code

jeremydmiller and others added 2 commits October 28, 2026 07:25
…eate own the null-snapshot case (#1087)

The null-snapshot branch built an aggregate with required members as
`new T { Required = default!, ... }`, which overwrote member initializers
such as `public required List<string> Emails { get; set; } = [];` with
null. Marten 8 ran the constructor, so the initializers ran.

- `required` is compile-time only: emit `(T)Activator.CreateInstance(typeof(T))!`
  so the constructor and initializers run (trim/AOT safe, typeof literal)
- emit `new T()` when the parameterless constructor is [SetsRequiredMembers]
- drop Apply-fallback arms already matched by an earlier Create for a base
  type or interface (e.g. a catch-all `Create(object)`), which were
  unreachable (CS8120) and broke the consumer build; the Create wins, as
  it did in Marten 8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ace of #1088's copied initializers

#1088 fixed the same regression by copying self-contained required-member
initializers into the object initializer. Activator.CreateInstance(typeof(T))
runs the constructor and every initializer, so it also covers initializers that
need a using or a member of the aggregate (Guid.NewGuid(), a constant) and
members set in the constructor body. The initializer-copy helpers are removed.

#1088's fixture is kept; its two emitted-text tests now assert construction
through the constructor, and EventTests checks at runtime that a Guid.NewGuid()
initializer, a constant and a constructor-body assignment all survive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jeremydmiller

Copy link
Copy Markdown
Member Author

Merged main, which brought in #1088's fix for the same regression. #1088 copies a required member's initializer into the object initializer when that initializer can be copied safely. This PR builds the aggregate with Activator.CreateInstance(typeof(T)), which runs the constructor and every initializer. That also covers members set with Guid.NewGuid(), with a constant of the aggregate or in the constructor body, so #1088's RequiredMemberValue / IsSelfContained helpers were no longer used and have been removed.

#1088's 13-member fixture is kept in RequiredMemberInitializerTests. Its two tests that checked the generated text now check that the aggregate is built through its constructor, and its nullable-enabled compile test passes unchanged. A new runtime test in EventTests (Bug_1087_required_members_on_missing_aggregate) checks that a Guid.NewGuid() initializer, a constant and a constructor-body assignment survive building a missing aggregate. Against #1088's emitter, that test fails with Token == Guid.Empty.

Thanks to Kenneth Skovhede for #1088 and for that fixture.

🤖 Generated with Claude Code

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.

Source generator: constructing a missing aggregate sets every required member to null; no way to supply the default instance

1 participant