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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ section is the GitHub release's notes.
### Added

- Generators: a plain value object that declares `IAdditionOperators`, `ISubtractionOperators`, `IMultiplyOperators`, `IDivisionOperators`, `IModulusOperators`, `IUnaryNegationOperators` or `IUnaryPlusOperators` gets their operators, `Money + Money` or `Money * 3m`, each the underlying type's own, overflow and `checked` context included; nothing without the interface, nothing on a validated value object, and an operator you wrote is kept ([Add, subtract and scale](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/value-objects.md#add-subtract-and-scale)).
- EF Core: those operators run in a query as the underlying type's arithmetic on SQLite, PostgreSQL and SQL Server, `o.Price * 2m > limit` and `o.Tax / o.Price` included ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)).

### Changed

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 0124. An operator in a query is the wrapped type's arithmetic

Date: 2026-10-30
Status: accepted

Context: [#186](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/186), under the 2.4.0 epic
[#177](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/177); [EF Core](../ef-core.md); refines
[decision 122](0122-arithmetic-operators-are-opt-in-and-a-hand-written-one-is-a-seam.md), whose EF Core bullet it
replaces, on [decision 123](0123-a-hand-written-operator-is-refused-in-a-query.md)'s mark.

EF translates an operator node as the database's arithmetic over its operands, and never calls the operator's
method. Between two value objects into one, that is what a generated operator does where TypeKit's converter
stores the column: `o.Price + o.Tax` is `ef_add("p"."Price", "p"."Tax")` on SQLite. Where the application's own
converter stores it, as `"M:" + value`, it added the texts: `Price + Tax > 4` became
`"p"."Price" + "p"."Tax" > 'M:4'` and kept no row where memory keeps two, and `-Price < -4` kept all three. With
another operand or result EF applied the value object's converter to the other side: `o.Price * 2m > limit` threw
"Invalid cast from 'System.Decimal' to 'ProbeMoney'", `o.Tax / o.Price` as a `decimal` "No coercion operator is
defined", and unary `+` was not translated (measured with EF Core 10.0.12 on SQLite).

**Every generated operator, marked `[TranslatedAsWrappedArithmetic]`, is rewritten to the wrapped type's
arithmetic over what the value objects wrap, before EF translates the query.**

- **The rewrite.** The interceptor and the filter convention of decision 123 rewrite a marked operator to the
wrapped type's operator over `ValueObjectConverter<,>.ProviderValue` of each value-object operand, the other
operand as it is. `ProviderValue` translates only where TypeKit's converter stores the column
([decision 56](0056-value-translated-only-through-typekit-converter.md)), so an operator over a column the
application converts itself is EF's "could not be translated"; as a query's last `Select`, EF computes it in
memory. A result of the value object's type is `ValueObjectConverter<,>.Materialize` of the wrapped type's, which
the method translator re-types as the value object, with its mapping for the same store type, so it compares with
and is read as one. Both methods are reached through `GeneratedValueObjectAttribute.Accept`, never by name, and
are public, so a compiled model writes a rewritten filter out. Unary `+` of a number is its operand.
- **A `Nullable` operand is checked, as the lifted operator is.** The rewrite of an operator with an optional
operand is null unless every optional operand is there, and an operator over such a rewrite takes its check over,
so `o.Optional * 2m + o.Tax` checks `Optional` once: `CASE WHEN "p"."Optional" IS NOT NULL THEN
ef_add(ef_multiply("p"."Optional", '2.0'), "p"."Tax") END` on SQLite. Unwrapped by conversions alone, which EF drops
from the SQL, the null reached the arithmetic where EF computes the last `Select` in memory: over a column the
application's own converter stores, `Select(o => o.Amount * 2m)` threw "Nullable object must have a value" on a
null row (an outside review of #190).
- **Re-typed, not cast.** The database's arithmetic, and `.Value` of it, is re-typed as a column is: cast to the
column's store type, SQL Server's default `decimal(18,2)`, `(o.Price / 32m).Value` over 5 read 0.16 where memory
reads 0.15625, and a filter on it lost the row (measured on Azure SQL Edge). SQLite's decimal arithmetic is
functions, `ef_add`, `ef_multiply`, `ef_divide`, `ef_mod` and `ef_negate`, re-typed by those names. So is what
holds such a value without computing one, branch by branch: a `CASE` (`?:`, and the null check above), a
`COALESCE` (`??`), `MAX` and `MIN`, and a subquery of one value. Cast whole, `(o.Optional / 32m ?? o.Price /
32m).Value` read 0.09 where memory reads 0.09375, a filter on it lost its row, and arithmetic over a `?:` and a
navigation's largest quotient rounded the same (an outside review of #190, measured on Azure SQL Edge). A branch of
any other shape, such as a parameter, is converted alone, and any other function's result is converted. Each
branch's own converter is checked, since EF gives a `CASE` the first branch's mapping.
- **A wrapped type without an expression of its own.** `byte`, `short`, `sbyte`, `ushort` and `char` have no
arithmetic in an expression tree, where C# computes it as an `int`'s. Their operators are refused naming the value
object, between two value objects too, which no release translated (#184 is 2.4's).

`ArithmeticQueryTests` holds it as a differential oracle: 45 queries (filters, projections, orderings and an
aggregate; a column, a nullable column, a JSON property, a navigation, a captured factor, a decimal column and an
optional one as the other operand; `checked`, unary `+` and `-`; past the column's scale, through `?:`, `??`,
`ValueOrNull()` and a navigation's `Max`) against the same queries over the objects
in memory, on SQLite, PostgreSQL 17 and SQL Server. `ArithmeticQueryFilterTests` holds the filters, named and not,
and `ForeignConvertedArithmeticTests` a converter of the application's own, optional operands computed in memory
included.
3 changes: 2 additions & 1 deletion design/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,6 @@ per area ([design](../plan.md)) link to the records they rest on.
| [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted |
| [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted |
| [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); a closed set's base class counts as a base record does, its `sealed` `ToString()` the seam and one not sealed refused (#205); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197, #205) | 2026-10-30 | accepted |
| [0122](0122-arithmetic-operators-are-opt-in-and-a-hand-written-one-is-a-seam.md) | A plain value object gets the arithmetic operators (`+`, `-`, `*`, `/`, `%`, unary `-` and `+`, and their `checked` forms) of the operator interfaces it declares, where the wrapped type implements the same interface with the value object replaced by itself; each calls the wrapped type's through `GeneratedArithmetic`, a constrained generic call with its context stated; never by default and never on a validated value object, which keeps CS0535; a hand-written operator for the same operands, or an explicit implementation of the interface, its own or a base record's, is a seam, kept with its `checked` form, so 2.3 code builds unchanged; refines decision 28 | 2026-10-30 | accepted; refined by [0123](0123-a-hand-written-operator-is-refused-in-a-query.md) |
| [0122](0122-arithmetic-operators-are-opt-in-and-a-hand-written-one-is-a-seam.md) | A plain value object gets the arithmetic operators (`+`, `-`, `*`, `/`, `%`, unary `-` and `+`, and their `checked` forms) of the operator interfaces it declares, where the wrapped type implements the same interface with the value object replaced by itself; each calls the wrapped type's through `GeneratedArithmetic`, a constrained generic call with its context stated; never by default and never on a validated value object, which keeps CS0535; a hand-written operator for the same operands, or an explicit implementation of the interface, its own or a base record's, is a seam, kept with its `checked` form, so 2.3 code builds unchanged; refines decision 28 | 2026-10-30 | accepted; refined by [0123](0123-a-hand-written-operator-is-refused-in-a-query.md) and [0124](0124-an-operator-in-a-query-is-the-wrapped-type-s-arithmetic.md) |
| [0123](0123-a-hand-written-operator-is-refused-in-a-query.md) | An arithmetic, bitwise or shift operator a value object declares by hand is refused in an EF Core query, naming the value object and `.Value`, by an interceptor and, for query filters, when the model is built; the generated ones carry `[TranslatedAsWrappedArithmetic]`, which is what tells them apart; a conversion operator is left to EF; refines decision 122 | 2026-10-30 | accepted |
| [0124](0124-an-operator-in-a-query-is-the-wrapped-type-s-arithmetic.md) | In an EF Core query, every generated arithmetic operator is rewritten to the wrapped type's operator over `ProviderValue`, which translates only through TypeKit's converter, its value-object result `Materialize`d and re-typed, never cast; `byte`, `short` and `char` operators are refused; refines decision 122, on decision 123's mark | 2026-10-30 | accepted |
19 changes: 14 additions & 5 deletions design/ef-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,21 @@
model is built (and so at `dbcontext optimize` and `migrations add`). A comparison in a filter that
reads neither the entity nor a query is left alone: EF evaluates it in memory, by `CompareTo`
(measured: `Floor > From(2)` on the context kept the rows memory keeps).
- **A hand-written operator is refused in a query (decided 2026-10-30, [decision 123](adr/0123-a-hand-written-operator-is-refused-in-a-query.md)).**
- **An operator is the wrapped type's arithmetic, or refused (decided 2026-10-30, [decision 123](adr/0123-a-hand-written-operator-is-refused-in-a-query.md) and [decision 124](adr/0124-an-operator-in-a-query-is-the-wrapped-type-s-arithmetic.md)).**
EF translates an operator node as the database's arithmetic over its operands and never calls the
method, so a hand-written `+` that rounds ran as the plain sum of the columns. The arithmetic aspect
marks every operator it generates `[TranslatedAsWrappedArithmetic]`; an interceptor, and a finalizing
convention for query filters, refuses any other arithmetic, bitwise or shift operator a value object
declares, `&&` and `||` over its `&` and `|` included, naming the value object (#187). A conversion operator is left to EF.
method. For a generated operator between two value objects that is right where TypeKit's converter
stores the column, and added the texts where the application's own stores `"M:" + value`; with another
operand or result it applied the value object's converter to the other side and threw (#186). An
interceptor, and a finalizing convention for query filters, rewrites every generated operator marked
`[TranslatedAsWrappedArithmetic]` to the wrapped type's operator over `ProviderValue` of each
value-object operand, its result wrapped by `ValueObjectConverter<,>.Materialize`, which the method
translator re-types as the value object with its mapping for the same store type; unary `+` of a number
is the operand. Any other arithmetic, bitwise or shift operator a value object declares, `&&` and `||` over its
`&` and `|` included, is hand-written, and refused naming the value object (#187), in a filter when the model is built. The database's
arithmetic, and `.Value` of it, is re-typed, not cast, through a `?:`, a `??`, `MAX`, `MIN` and a subquery of
one value too: a cast to the column's store type, SQL Server's default `decimal(18,2)`, read
`(o.Price / 32m).Value` over 5 as 0.16 where memory reads 0.15625. An optional operand is checked, so EF's
computing the last `Select` in memory reads null where the operator does.
- **A null passes no comparison in a query (decided 2026-10-15, [decision 98](adr/0098-a-null-passes-no-comparison-in-a-query.md)).**
A record class's generated operators sort null first, so `null < x` is true in memory, and SQL's
`"N" < @x` leaves the null row out, as for the underlying type's nullable column. Not refused: a
Expand Down
9 changes: 4 additions & 5 deletions docs/value-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,10 @@ object or another type, as long as the underlying type has that operator itself.
base record or an interface of yours, and TypeKit keeps it, with its checked form. It generates only the operators you haven't written.
- **The checked context.** `checked(a + b)` uses the checked operator, as it would for an `int`. A value
object over an `int` throws `OverflowException` there, and wraps around outside it.
- **In an EF Core query.** An operator between two value objects runs in the database, as it would for
the underlying type: `o.Price + o.Tax > limit` keeps the rows memory keeps. One with another operand or
result, such as `o.Price * 2m`, throws there for now. Write `o.Price.Value * 2m` instead. An operator
you wrote yourself can't run there, so a query that uses it throws, naming the value object
([EF Core, Queries](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)).
- **In an EF Core query.** Each operator runs in the database as the underlying type's arithmetic:
`o.Price + o.Tax > limit`, `o.Price * 2m > limit` and `o.Tax / o.Price < 0.3m` keep the rows memory
keeps. An operator you wrote yourself can't run there, so a query that uses it throws, naming the value
object ([EF Core, Queries](../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)).

## Enums, dates and other types

Expand Down
41 changes: 41 additions & 0 deletions src/CodoMetis.TypeKit.EntityFrameworkCore/ConverterMethods.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Collections.Concurrent;
using System.Linq.Expressions;
using System.Reflection;
using CodoMetis.TypeKit.CompilerServices;
using CodoMetis.TypeKit.ValueObjects;

namespace CodoMetis.TypeKit.EntityFrameworkCore;

/// <summary>
/// <see cref="ValueObjectConverter{TValueObject,T}.ProviderValue"/> and
/// <see cref="ValueObjectConverter{TValueObject,T}.Materialize"/> of one value object, which a query is rewritten to
/// call where it unwraps one or wraps the wrapped type's result in one.
/// </summary>
/// <remarks>
/// Reached through <see cref="GeneratedValueObjectAttribute.Accept{TResult}"/> with the value object as a type
/// argument, from the converter's expressions, whose methods the compiler names: nothing is looked up by name and
/// nothing is made with <c>MakeGenericType</c>, which the trimmer and Native AOT may break (IL2075, IL3050). Both
/// are public because a compiled model writes a rewritten query filter out as C# in the application's assembly.
/// </remarks>
internal static class ConverterMethods
{
private static readonly ConcurrentDictionary<Type, (MethodInfo ProviderValue, MethodInfo Materialize)> Methods = new();

public static MethodInfo ProviderValue(GeneratedValueObjectAttribute valueObject) => Of(valueObject).ProviderValue;

public static MethodInfo Materialize(GeneratedValueObjectAttribute valueObject) => Of(valueObject).Materialize;

private static (MethodInfo ProviderValue, MethodInfo Materialize) Of(GeneratedValueObjectAttribute valueObject) =>
Methods.GetOrAdd(valueObject.ValueObjectType, static (_, description) => description.Accept(MethodsOf.Instance), valueObject);

private sealed class MethodsOf : IValueObjectVisitor<(MethodInfo, MethodInfo)>
{
public static readonly MethodsOf Instance = new();

public (MethodInfo, MethodInfo) Visit<TValueObject, T>()
where TValueObject : IValueObject<TValueObject, T>, IValueObjectMaterializer<TValueObject, T>
where T : notnull =>
(((MethodCallExpression)ValueObjectConverter<TValueObject, T>.ToProvider.Body).Method,
((MethodCallExpression)ValueObjectConverter<TValueObject, T>.FromProvider.Body).Method);
}
}
Loading
Loading