Skip to content

Polecat 5.13.0

Choose a tag to compare

@jeremydmiller jeremydmiller released this 14 Aug 23:45
ec4e5a4

One theme this release: strong typed identifiers. Marten's ValueTypeTests project was ported across in full, and four of the ported cases failed on arrival against real defects in Polecat's existing support. All four are fixed, and value types now work on any document member rather than only on the identity.

📖 Document Identity → Strongly Typed IDs — rewritten for this release, with worked examples for both generator libraries, the value-types-on-other-members rules, and an explicit list of what is not supported yet.

Value types are no longer limited to the identity

Previously a strong typed value could only be a document's Id. Anywhere else it was an unmapped CLR type, and any LINQ predicate touching it died with No mapping exists from object type .... That ruled out the whole "reference another aggregate by its typed id" pattern:

[ValueObject<Guid>]
public readonly partial struct TeacherId;

public class ClassRoom
{
    public Guid Id { get; set; }
    public TeacherId Teacher { get; set; }   // now queryable
}

var rooms = await session.Query<ClassRoom>()
    .Where(x => x.Teacher == teacherId)
    .OrderBy(x => x.Teacher)
    .ToListAsync();

var teacherIds = await session.Query<ClassRoom>()
    .Select(x => x.Teacher)
    .ToListAsync();

This works because both Vogen and StronglyTypedId emit a System.Text.Json converter that writes the inner value, so the member lands in the document JSON as a bare scalar. Polecat types the SQL from the inner type and unwraps the CLR parameter. Where, OrderBy/OrderByDescending, IsOneOf, Select, Count and paging all follow.

Marten reaches the same place through Duplicate(x => x.Member), which projects the value into its own column. Polecat has no duplicated columns — it queries the JSON path directly — so there is nothing to configure.

Defects fixed

Select(x => x.Id) on a wrapper threw Object must implement IConvertible. Scalar projection ran the raw column value through Convert.ChangeType, which a wrapper struct satisfies no interface for. It is now rebuilt from its inner value through whichever constructor or static factory the type exposes.

A wrapper with a nullable sibling factory bound the wrong builder (marten#4288). JasperFx's ValueTypeInfo.ForType takes the first public static method accepting one inner-typed argument as the type's builder, without checking what it returns. A type pairing static X From(string) with static X? FromNullable(string?) could therefore bind the sibling, and every wrapper built from it failed with Expression of type 'Nullable<X>' cannot be used for return type 'X'. Polecat now prefers the builder whose return type is the value type itself and re-registers the corrected info into JasperFx's shared cache, so the event store's stream-id resolution agrees rather than holding a second, wrong answer.

Flat table projection columns over a value type failed at execution (marten#4290). MapToSqlType had no case for a wrapper, so the column fell through to nvarchar(max) and the MERGE then died with No mapping exists from object type ... the first time the projection ran. The column is now typed from the inner value and the parameter setter unwraps it, so map.Map(x => x.Account) on a [ValueObject<Guid>] gets a uniqueidentifier column.

A computed index over a value type was dead weight. Index(x => x.Teacher) created its persisted computed column from the wrapper type — varchar(250) — while the LINQ predicate was typed from the inner value. The two never lined up, so SQL Server could not match the predicate to the column and the index was never seekable. Both sides now resolve the same way.

One resolver, and it caches its misses

The rule for "is this type a value wrapper" lived only inside DocumentMapping. It now lives in one place shared by document mapping, the LINQ member factory, the scalar handlers, the flat table projection and index DDL — which is what lets those five agree, since three of the four defects above were two of them disagreeing.

It caches negative results deliberately. ValueTypeInfo.ForType signals "not a wrapper" by throwing and only caches successes, so without a negative cache every LINQ member over an ordinary decimal or DateTimeOffset would raise and swallow an exception on a hot path.

Guarding against over-eager detection

Widening detection to non-identity members risks the opposite failure — treating an ordinary nested object as a scalar, which breaks nested member access. Two rules hold the line, both pinned by ported Marten regressions:

  • A multi-property record struct Money(decimal Amount, Guid CurrencyId) is not an identifier and stays a nested JSON object, so x.Amount.CurrencyId keeps resolving as a nested path. That is Marten's Bug_money_value_object_misdetected_as_strong_typed_id.
  • A reference-type value object — Vogen's [ValueObject<int>] public partial record Age — is recognized, but only when built through a static factory rather than a public constructor. An ordinary record Address(string City) resolves through its constructor and must keep being treated as a nested entity.

Vogen and StronglyTypedId are both under test now

Vogen is referenced by the test suite for the first time, matching the two libraries Marten covers. It exercises the private-constructor + static-From "builder" shape that a hand-written record struct never reaches. StronglyTypedId was already referenced but only by the projection tests; it gains document-side suites for Guid, int, long and string.

⚠️ Vogen identities must be declared nullableInvoiceId? Id, as they are in every Vogen document in Marten's own tests. Vogen forbids an uninitialized value object, so reading .Value off a default instance throws; on a non-nullable Vogen id that throw happens inside identity assignment, before Polecat can see the id was unset. StronglyTypedId permits default and works either way.

There is no naming requirement on the wrapper type or its inner property. Marten's docs state the type name must be suffixed with Id; that rule does not exist in Polecat, and there is a test named WeirdNamed that proves it.

Known gaps, stated plainly

These are Marten features Polecat does not have, so the corresponding Marten tests were not ported. They are listed in the docs too:

  • No LoadAsync<T>(object id) overload taking the wrapper itself — LoadAsync, Delete and CheckExistsAsync take the inner value (order.Id.Value)
  • No Include() LINQ operator
  • No compiled queries
  • F# single-case discriminated unions as identities

Test coverage

163 new tests across 14 files, and the full suite is at 2118. Two Marten areas turned out already covered here: archived partitioning with a strong-typed DCB tag type, and the event-side FetchForWriting overloads.