diff --git a/src/LinqTests/Bugs/Bug_money_value_object_misdetected_as_strong_typed_id.cs b/src/LinqTests/Bugs/Bug_money_value_object_misdetected_as_strong_typed_id.cs new file mode 100644 index 0000000000..3d3996110f --- /dev/null +++ b/src/LinqTests/Bugs/Bug_money_value_object_misdetected_as_strong_typed_id.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using Marten; +using Marten.Testing.Harness; +using Shouldly; +using Weasel.Postgresql; +using Xunit; + +namespace LinqTests.Bugs; + +// Multi-property record structs (e.g. Money(decimal Amount, Guid CurrencyId)) used as +// document properties were being mis-classified as strong-typed-id wrappers by +// ValueTypeIdGeneration.IsCandidate. The candidate filter only counted properties whose +// type is in DocumentMapping.ValidIdTypes (Guid/int/long/string), so it saw only +// CurrencyId, matched a static `Money Zero(Guid)` builder, and as a side effect called +// PostgresqlProvider.Instance.RegisterMapping(typeof(Money), "uuid", ...) — globally +// poisoning Weasel's type map. LINQ resolution then treated Money as a scalar +// (SimpleCastMember) instead of a JSONB document (ChildDocument), so any nested member +// access like `x.UnappliedAmount.Amount` threw BadLinqExpressionException. +// +// Each test uses its own unique record-struct type to avoid cross-test pollution of +// the process-wide PostgresqlProvider.Instance singleton. +public class Bug_money_value_object_misdetected_as_strong_typed_id +{ + [Fact] + public void multi_property_record_struct_does_not_pollute_global_pg_type_mapping() + { + using var store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.Schema.For(); + }); + + var pgType = PostgresqlProvider.Instance.GetDatabaseType( + typeof(MoneyA), store.Options.Serializer().EnumStorage); + + pgType.ShouldBe("jsonb"); + } + + [Fact] + public void linq_can_resolve_nested_member_access_on_multi_property_record_struct() + { + using var store = DocumentStore.For(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.Schema.For(); + }); + + using var session = store.QuerySession(); + var queryable = session.Query() + .Where(x => x.Amount.Value > 0m && x.Amount.CurrencyId == Guid.Empty); + + Should.NotThrow(() => queryable.ToCommand()); + } +} + +public readonly record struct MoneyA(decimal Value, Guid CurrencyId) +{ + public static MoneyA Zero(Guid currencyId) => new(0m, currencyId); +} + +public class MoneyDocA +{ + public Guid Id { get; set; } + public MoneyA Amount { get; set; } +} + +public readonly record struct MoneyB(decimal Value, Guid CurrencyId) +{ + public static MoneyB Zero(Guid currencyId) => new(0m, currencyId); +} + +public class MoneyDocB +{ + public Guid Id { get; set; } + public MoneyB Amount { get; set; } +} diff --git a/src/Marten/Schema/Identity/ValueTypeIdGeneration.cs b/src/Marten/Schema/Identity/ValueTypeIdGeneration.cs index 3d837b506c..fc533d5982 100644 --- a/src/Marten/Schema/Identity/ValueTypeIdGeneration.cs +++ b/src/Marten/Schema/Identity/ValueTypeIdGeneration.cs @@ -154,6 +154,18 @@ public static bool IsCandidate(Type idType, [NotNullWhen(true)]out ValueTypeIdGe return false; } + // Reject multi-property value objects (e.g. `Money(decimal Amount, Guid CurrencyId)`) + // before any further matching. Such types' canonical constructors take more than one + // argument; a strong-typed-id wrapper takes at most one. Without this guard the + // ValidIdTypes filter below would silently drop the non-id-typed property, see only + // the Guid one, match a static `Zero(Guid)` builder, and — as a side effect — register + // the entire value object as a `uuid` column on the global PostgresqlProvider singleton, + // breaking LINQ resolution for any document that uses it as a property. + if (idType.GetConstructors().Any(c => c.GetParameters().Length > 1)) + { + return false; + } + var properties = idType.GetProperties() .Where(x => DocumentMapping.ValidIdTypes.Contains(x.PropertyType)) .ToArray();