Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<MoneyDocA>();
});

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<MoneyDocB>();
});

using var session = store.QuerySession();
var queryable = session.Query<MoneyDocB>()
.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; }
}
12 changes: 12 additions & 0 deletions src/Marten/Schema/Identity/ValueTypeIdGeneration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading