Fix multi-property record structs misclassified as strong-typed-id wrappers#4322
Merged
jeremydmiller merged 1 commit intomasterfrom May 1, 2026
Merged
Conversation
…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.
This was referenced May 3, 2026
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.
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:Root cause
In
ValueTypeIdGeneration.IsCandidate, the property filter only counts properties whose type is inDocumentMapping.ValidIdTypes(Guid/int/long/string). For aMoneystruct withValue: decimalandCurrencyId: Guid, it sees only theCurrencyIdproperty — a single match. A staticMoney 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
on the global Weasel singleton, poisoning the type map for the rest of the process. Subsequent LINQ resolution treats
Moneyas a uuid scalar (SimpleCastMember) instead of a JSONB document (ChildDocument), so anyx.MoneyProp.Amount > 0style 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/ValueTypeTestspasses 339/339.Tests
Two regression tests in
src/LinqTests/Bugs/:multi_property_record_struct_does_not_pollute_global_pg_type_mapping— assertsPostgresqlProvider.Instance.GetDatabaseType(typeof(Money), ...)returns"jsonb"(the document-storage default) rather than"uuid".linq_can_resolve_nested_member_access_on_multi_property_record_struct— end-to-end check thatsession.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.Instancesingleton.Reported by
@mmidkiff, with full root-cause analysis pre-attached. Thanks!
Test plan
dotnet test src/ValueTypeTests339/339 — no regression on legit strong-typed-id detection🤖 Generated with Claude Code