Use with Dapper
dotnet add package AdCodicem.ValueObjects.Dapper
ValueObjectDapper.AddValueObjectHandlers(typeof(Iban).Assembly); // once, at start-up
That registers a type handler for every value object of the assembly. From then on a value object can be a query parameter and a column of a result, with no projection:
var account = await connection.QuerySingleOrDefaultAsync<BankAccount>(
"SELECT iban AS Iban, balance AS Balance FROM accounts WHERE iban = @iban",
new { iban });
Dapper keeps its handlers in a process-wide table, so the call belongs at start-up rather than per connection.
Pass several assemblies if the value objects live in more than one. Calling it again changes nothing: a value object
Dapper already has a handler for keeps it, including one the application registered itself. What is handled is read
from Dapper's own table, so after SqlMapper.ResetTypeHandlers() — between tests, say — calling it again registers
every handler anew.
Parameters
A value object goes out as its underlying value. One over string that says more than its type also declares the
column the Entity Framework Core conventions map it to:
- an entity identifier as fixed-length, non-Unicode text of its exact length, the
char(n)ofConfigureEntityIds; - a value object declaring
MaxLengthas Unicode text of that length,nvarchar(34)for an IBAN on SQL Server.
Left to itself, SqlClient sends a string as nvarchar of the value's own length, which also caches one plan per
length, and SQL Server converts a char or varchar column to compare it with one, which costs the index seek of a
query filtering on an identifier. Npgsql sends every string as text whatever it declares, and PostgreSQL compares a
character(n) column with text by converting the column, which costs its index the same way: cast the parameter in
the query, WHERE id = @id::bpchar or WHERE id = CAST(@id AS character(25)). Only an identifier is known to be
ASCII, so no other value object goes out as non-Unicode text, which would lose the characters the column's code page
lacks. A value longer than the declared length, which a row read without validation can hold, gets a parameter as
long as itself: both providers would otherwise cut it short, silently.
What a read trusts
A column the provider returns as the underlying type is read with CreateUnchecked, on the same reasoning as the
Entity Framework Core read path: this application validated the value when it wrote
it. Some providers return another type of the date and time family than the underlying one, and those are converted
first: SQL Server returns a DateTime for a date column and a TimeSpan for a time column, Npgsql a DateOnly
and a TimeOnly for them, and a UTC DateTime for a timestamptz. A DateTime that says nothing of its zone — a
SQL Server datetime2, a PostgreSQL timestamp — cannot become a DateTimeOffset, and is refused. So is any value
the handler cannot convert to the underlying type, another type or one out of its range: each throws a
DataException naming the type the provider returned and the value object it was read into.
A column holding text where the underlying type is not text, or the reverse, is the exception: the value object did
not write it. Text read into a value object whose underlying type is not string — a Guid or a number kept in a
text column — is parsed the way the value object parses text, so it is normalized and validated. A number or a
Guid read into a value object over string — the digits of a reference kept in a numeric column, a reference kept
in a uuid or uniqueidentifier column — is turned into text, then normalized and validated through TryCreate. A
Guid becomes text in its D form, lowercase, as Guid.ToString() writes it. Either way, a value the value object refuses throws a DataException
carrying the rule's message.
Generic value objects
Dapper looks a handler up by the exact type, ahead of any query, and the constructions of a generic value object are
known only to the application. AddValueObjectHandlers handles only the constructions something resolved before it
ran, which depends on the order of start-up; register each construction a query reads or writes, once, at start-up:
ValueObjectDapper.AddValueObjectHandler<Reference<PurchaseOrder>, string>();
It registers the same handler, for the value object and its nullable form, closed at compile time with no assembly scan, and keeps a handler the application registered itself. It works for a value object that is not generic too. The parameter of a construction declares its column as any other value object's does.
128-bit value objects
No ADO.NET provider takes an Int128 or a UInt128 as a parameter, or returns one, so AddValueObjectHandlers
registers no handler for a value object over either: the column it lands in, and the conversion to it, are yours to
choose, with a handler of your own, before or after the call:
SqlMapper.AddTypeHandler(new LedgerBalanceHandler());
internal sealed class LedgerBalanceHandler : SqlMapper.TypeHandler<LedgerBalance>
{
public override void SetValue(IDbDataParameter parameter, LedgerBalance value)
=> parameter.Value = (decimal)value.Value;
public override LedgerBalance Parse(object value) => LedgerBalance.Create((Int128)(decimal)value);
}
The choice is the one Entity Framework Core leaves you: a numeric column
compares as numbers do but carries no more than System.Decimal holds, and a text column holds the whole range but
compares as text does.
NULL
A NULL column reads as null into an optional value object, Iban?, whether it is the result of a single-column
query or a member of a mapped type:
var iban = await connection.QuerySingleAsync<Iban?>(
"SELECT a.iban FROM customers c LEFT JOIN accounts a ON a.customer_id = c.id WHERE c.id = @id",
new { id }); // null when the customer has no account
Read into the value object itself, what happens depends on where it lands, as it does for an int:
- In a single-column query —
QuerySingleAsync<Iban>— it throws aDataException. - In a member of a mapped type, or a parameter of the constructor Dapper maps it through, Dapper checks for
NULLbefore it calls the handler, and never calls it. The member is left as an uninitialized value object —IsDefaultistrue, and no rule ever ran — and nothing throws.
So a nullable column belongs in a nullable member: declare Iban? wherever the query can return NULL, an outer
join included.
An optional value object holding nothing goes out as a NULL parameter.