Skip to content

Fix multi-property record structs misclassified as strong-typed-id wrappers#4322

Merged
jeremydmiller merged 1 commit intomasterfrom
fix-money-value-object-misdetected-as-strong-typed-id
May 1, 2026
Merged

Fix multi-property record structs misclassified as strong-typed-id wrappers#4322
jeremydmiller merged 1 commit intomasterfrom
fix-money-value-object-misdetected-as-strong-typed-id

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Problem

When a document property's type is a multi-field record struct like Money(decimal Value, Guid CurrencyId), the LINQ resolver throws on nested member access:

'Marten.Exceptions.BadLinqExpressionException' ... 'Marten does not (yet) support member Money.Value'

Root cause

In ValueTypeIdGeneration.IsCandidate, the property filter only counts properties whose type is in DocumentMapping.ValidIdTypes (Guid/int/long/string). For a Money struct with Value: decimal and CurrencyId: Guid, it sees only the CurrencyId property — a single match. A static Money Zero(Guid) builder method then satisfies the 1-arg signature requirement, and the type is wrongly classified as a strong-typed-id wrapper.

Worse, the candidate check has a side effect: it calls

PostgresqlProvider.Instance.RegisterMapping(typeof(Money), "uuid", NpgsqlDbType.Uuid);

on the global Weasel singleton, poisoning the type map for the rest of the process. Subsequent LINQ resolution treats Money as a uuid scalar (SimpleCastMember) instead of a JSONB document (ChildDocument), so any x.MoneyProp.Amount > 0 style query throws.

Fix

Reject types whose canonical constructor takes more than one parameter before any further matching. A strong-typed-id wrapper takes exactly one argument by definition; a multi-property record struct's primary ctor (Money(decimal, Guid)) signals "data-carrying value object", not "id wrapper".

Single-arg strong-typed-ids — including those with additional convenience computed properties — continue to be detected normally. Verified: dotnet test src/ValueTypeTests passes 339/339.

Tests

Two regression tests in src/LinqTests/Bugs/:

  1. multi_property_record_struct_does_not_pollute_global_pg_type_mapping — asserts PostgresqlProvider.Instance.GetDatabaseType(typeof(Money), ...) returns "jsonb" (the document-storage default) rather than "uuid".
  2. linq_can_resolve_nested_member_access_on_multi_property_record_struct — end-to-end check that session.Query<Doc>().Where(x => x.Amount.Value > 0m && x.Amount.CurrencyId == Guid.Empty).ToCommand() no longer throws.

Each test uses its own unique record-struct type to avoid cross-test pollution of the process-wide PostgresqlProvider.Instance singleton.

Reported by

@mmidkiff, with full root-cause analysis pre-attached. Thanks!

Test plan

  • New regression tests pass on net9.0
  • dotnet test src/ValueTypeTests 339/339 — no regression on legit strong-typed-id detection
  • Full CI green

🤖 Generated with Claude Code

…appers

When a document property's type was a multi-field record struct like
`Money(decimal Value, Guid CurrencyId)`, the LINQ resolver would throw
`BadLinqExpressionException` on any nested member access:

    'Marten does not (yet) support member Money.Value'

Root cause was in ValueTypeIdGeneration.IsCandidate: the property filter
only counted properties whose type was in DocumentMapping.ValidIdTypes
(Guid/int/long/string), so for a Money struct with `Value: decimal` and
`CurrencyId: Guid` it saw only the CurrencyId property. With exactly one
"valid" property and a static `Money Zero(Guid)` builder method that
matched the 1-arg signature, the type was misdetected as a strong-typed-id
wrapper. Worse, IsCandidate then called

    PostgresqlProvider.Instance.RegisterMapping(typeof(Money), "uuid", NpgsqlDbType.Uuid)

on the global Weasel singleton, poisoning the type map for the rest of the
process: subsequent LINQ resolution treated `Money` as a uuid scalar
(SimpleCastMember) instead of a JSONB document (ChildDocument), so any
nested member access threw.

Fix: reject types whose canonical constructor takes more than one parameter
before any further matching. A strong-typed-id wrapper takes exactly one
argument by definition; a multi-property value object's primary ctor
exposes the data shape and signals "not a wrapper".

Two regression tests in LinqTests/Bugs cover (1) the global PG type-map
isolation invariant and (2) end-to-end LINQ resolution of nested member
access on a multi-property record struct. Each test uses its own unique
record-struct type to avoid cross-test pollution of the process-wide
PostgresqlProvider.Instance singleton.

Reported by mmidkiff with full root-cause analysis attached.
@jeremydmiller jeremydmiller merged commit ffcaa99 into master May 1, 2026
6 checks passed
@jeremydmiller jeremydmiller deleted the fix-money-value-object-misdetected-as-strong-typed-id branch May 1, 2026 14:28
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