Skip to content

feat: a value object's generated operator runs in an EF Core query as the wrapped type's arithmetic - #190

Merged
CaffeinatedCoder merged 6 commits into
release/2.4.0from
feat/ef-arithmetic-translation
Oct 31, 2026
Merged

CaffeinatedCoder merged 6 commits into
release/2.4.0from
feat/ef-arithmetic-translation

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #186

Stacked on #189 (fix/ef-hand-written-operator-refused), which stacks on #188.

Problem

EF translates an operator node as the database's arithmetic, and applied the value object's converter to the other side. Measured on SQLite (EF Core 10.0.12):

  • o.Price * 2m > limit threw "Invalid cast from 'System.Decimal' to 'ProbeMoney'".
  • o.Tax / o.Price, a decimal, threw "No coercion operator is defined between types 'ProbeMoney' and 'Nullable'".
  • Unary + was "could not be translated".

Testing across the input class turned up two silent siblings:

  • SQL Server rounding. .Value of the database's arithmetic was cast to the column's store type. At 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).
  • An application's own converter. Between two value objects, EF's own translation bypassed decision 56. Over a column the application stores itself as "M:" + value:
    • Price + Tax > 4 became "p"."Price" + "p"."Tax" > 'M:4' and kept no row, where memory keeps two.
    • -Price < -4 kept all three, where memory keeps two.

Change

  • ValueObjectArithmetic now rewrites every operator fix: a value object's hand-written operator is refused in an EF Core query #189 marks [TranslatedAsWrappedArithmetic], before EF translates the query, in queries and in query filters:
    • It becomes the wrapped type's operator over ValueObjectConverter<,>.ProviderValue of each value-object operand. The other operand stays as it is.
    • A value-object result becomes ValueObjectConverter<,>.Materialize of it.
    • Nullable operands are unwrapped and wrapped again by conversions EF drops.
    • Unary + of a number is its operand.
    • byte, short and char have no arithmetic in an expression tree, so their operators are rejected naming the value object.
  • ConverterMethods (new): ProviderValue and Materialize per value object, through GeneratedValueObjectAttribute.Accept, never by name. ValueThroughInterface now uses it too.
  • The translators:
    • Materialize is translated by WrappedValue.AsValueObject. It re-types the wrapped type's result as the value object, with the value object's mapping for the same store type.
    • WrappedValue.Of re-types the database's arithmetic instead of casting it: binary arithmetic, negation, and SQLite's ef_* functions.
    • Because every generated operator goes through ProviderValue, an operator over a foreign-converted column is EF's "could not be translated". As a query's last Select, EF computes it in memory.
  • Docs:
    • EF README, Queries: arithmetic runs as the underlying type's; byte/short/char; your own converter.
    • docs/value-objects.md, the EF bullet.
    • The changelog, under Added.
  • Records: decision 124 (refines 122, on 123's mark); design/ef-core.md.
  • Oracles:
    • ArithmeticQueryTests is registered as a differential oracle, with the seed arithmetic-left-to-ef.
    • column-cast-not-re-typed is refreshed.
    • 6 claims; C28-ef-between-value-objects becomes C28-ef-runs-as-underlying-arithmetic.
    • axes.md: an EF operator row.

Tests

  • ArithmeticQueryTests is a differential oracle against memory: 32 queries, each run against the same lambda over the same rows in memory, on SQLite, PostgreSQL 17 and SQL Server.
    • Uses: filters, projections, orderings and Max.
    • Sites: a column, a nullable column, a JSON property and a navigation.
    • The other operand: a literal, a captured factor and a decimal column.
    • Also checked, unary + and -, .Value of a result, and values past the column's scale.
    • The values divide exactly. SQLite skips the 3 ordering and aggregate cases, because SQLite orders no decimal, .Value neither.
  • It also checks the SQL on SQLite (ef_multiply("p"."Price", '2.0'), no CAST) and the byte rejection.
  • ArithmeticQueryFilterTests: query filters, anonymous and named.
  • ForeignConvertedArithmeticTests: an application's own converter. It failed before every operator was rewritten.
  • Each guard proven by reverting it:
    • oracles seeds arithmetic-left-to-ef: CAUGHT, 25 of 31 fail.
    • oracles seeds column-cast-not-re-typed: CAUGHT, 2978 of 9621 fail.
    • AsValueObject casting instead of re-typing: the SQL Server scale cases and the SQLite SQL check fail.
    • Filter convention unregistered: both filter tests fail.
  • Smoke test passed. The Native AOT consumer precompiles Quantity * 3 and (Quantity + Quantity).Value (dbcontext optimize --precompile-queries --nativeaot) and prints ef-arithmetic=6 4.
  • Full suite: 31,077 passed, 927 skipped (as before).

Weaving review

  1. Reach. Every marked operator is rewritten. Seed arithmetic-left-to-ef shows the oracle notices when none is.
  2. Discovery. By the mark and GeneratedValueObjectAttribute. The only names matched are SQLite's own ef_* decimal functions, and the fallback for any other is a cast, correct and at worst rounded.
  3. EF. .Value and the operators still translate only through TypeKit's converter (decision 56, now for operators too). Re-typed, never cast, in both directions.
  4. Native AOT. No MakeGenericType or member lookup: the methods come from the converter's expressions through Accept. The smoke test's precompiled queries run.

Review fixes (ae0c73b)

An outside review found two defects, both reproduced:

  • Composed arithmetic was cast whole to the column's store type, which rounds on SQL Server: (o.Optional / 32m ?? o.Price / 32m).Value read [0.09, 0.31, 0.05] where memory reads [0.09375, 0.3125, 0.046875], a filter on it lost its row, and (cond ? a / 32m : b / 32m) * 2m rounded the same. A CASE, a COALESCE, MAX and MIN, and a subquery of one value are re-typed branch by branch now, each branch's converter checked; a navigation's Max was found by the catalogue, not the review.
  • An optional operand's null threw where EF computes the last Select in memory (over a column the application's own converter stores): "Nullable object must have a value". The rewrite now checks each optional operand, as the lifted operator does, in one CASE WHEN … IS NOT NULL THEN … END that reads the column once. The non-nullable SQL is unchanged.

ArithmeticQueries gains 13 cases (?:, ??, ?? of ?:, ValueOrNull() of an optional quotient, a navigation's Max, an optional decimal operand, nested and unary optional operands) and a nullable decimal column; the foreign-converter test computes six such projections in memory against memory's answer; the SQLite SQL test pins the CASE and the absence of a CAST. Reverting the re-typing fails 9 cases on SQL Server and the SQL test; reverting the check fails the in-memory test and the SQL test. column-cast-not-re-typed is refreshed to the new lines.

🤖 Generated with Claude Code

… the wrapped type's arithmetic

EF reads an operator node as the database's arithmetic over its operands
and applied the value object's converter to the other side:
`o.Price * 2m > limit` threw "Invalid cast from 'System.Decimal'",
`o.Tax / o.Price` (a decimal) "No coercion operator is defined", and
unary `+` was not translated (measured on SQLite).

The interceptor and query-filter convention of decision 122 now rewrite
every operator marked `[TranslatedAsWrappedArithmetic]` to the wrapped
type's operator over `ValueObjectConverter<,>.ProviderValue` of each
value-object operand; a value-object result is `Materialize` of it, which
the method translator re-types as the value object with its mapping for
the same store type. Both methods are reached through
`GeneratedValueObjectAttribute.Accept` (`ConverterMethods`, now shared
with `ValueThroughInterface`). Unary `+` of a number is its operand.
`byte`, `short` and `char`, which have no arithmetic in an expression
tree, are refused naming the value object. Decision 123.

Found across the input class (where the operand is, who stores it, how
the result is used, on three providers):
- `.Value` of the database's arithmetic was cast to the column's store
  type, which rounded on SQL Server: `(o.Price / 32m).Value` over 5 read
  0.16 where memory reads 0.15625 in the default decimal(18,2). The
  arithmetic is re-typed, as a column is, in both directions.
- Between two value objects EF's own translation bypassed decision 56:
  over a column the application stores itself as "M:" + value,
  `Price + Tax > 4` became `"p"."Price" + "p"."Tax" > 'M:4'` and kept no
  row where memory keeps two. Every generated operator is rewritten now,
  so it is EF's "could not be translated" there.

`ArithmeticQueryTests` is a differential oracle against memory: 32
queries (filters, projections, orderings, an aggregate; a column, a
nullable column, a JSON property, a navigation, a captured factor and a
decimal column as the other operand) on SQLite, PostgreSQL 17 and SQL
Server, with the seed `arithmetic-left-to-ef` caught (25 of 31 fail).
`column-cast-not-re-typed` is refreshed and still caught.

The smoke test's Native AOT consumer precompiles two such queries,
`Quantity * 3` and `(Quantity + Quantity).Value`, and prints
`ef-arithmetic=6 4`.

Closes #186

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder CaffeinatedCoder added this to the 2.4.0 milestone Oct 30, 2026
@CaffeinatedCoder CaffeinatedCoder added enhancement New feature or request stacked Base is another open PR; merge in order, CI does not run until it targets a release package/efcore Affects CodoMetis.TypeKit.EntityFrameworkCore area/value-objects Value-object contracts and what the aspects generate for them labels Oct 30, 2026
CaffeinatedCoder and others added 2 commits October 30, 2026 14:46
…hmetic-translation

# Conflicts:
#	design/ef-core.md
#	test/oracles/axes.md
…ubqueries, and its nulls in memory

Two defects in the rewrite of decision 123, found in an outside review
of #190 and reproduced:

- The database's arithmetic was re-typed only as a column, an operator
  node or one of SQLite's ef_ functions. Composed, it was cast whole to
  the column's store type, which rounds on SQL Server's default
  decimal(18,2): `(o.Optional / 32m ?? o.Price / 32m).Value` read
  [0.09, 0.31, 0.05] where memory reads [0.09375, 0.3125, 0.046875], a
  filter on it lost its row, and `(cond ? a / 32m : b / 32m) * 2m`
  rounded the same (measured on Azure SQL Edge). A CASE, a COALESCE,
  MAX and MIN, and a subquery of one value are now re-typed branch by
  branch; a branch of another shape, such as a parameter, is converted
  alone, and each branch's own converter is checked, since EF gives a
  CASE the first branch's mapping.

- An optional operand was unwrapped by a conversion, which EF drops from
  the SQL but which throws where EF computes the last Select in memory:
  over a column the application's own converter stores,
  `Select(r => r.Amount * 2m)` threw "Nullable object must have a value"
  on a null row. The rewrite of an operator with an optional operand is
  now null unless each one is there, as the lifted operator is, and an
  operator over such a rewrite takes its check over, so the SQL is one
  CASE that reads the column once: `CASE WHEN "p"."Optional" IS NOT NULL
  THEN ef_add(ef_multiply("p"."Optional", '2.0'), "p"."Tax") END`.

Input class: how the database's arithmetic is composed before it is
unwrapped (?:, ??, ?? of ?:, ValueOrNull of an optional one, a
navigation's Max), and which operand is optional (the value object, the
other operand, nested, unary), on SQLite, PostgreSQL and SQL Server.
ArithmeticQueries gains 13 cases and a nullable decimal column; the
foreign-converter test computes six such projections in memory against
memory's answer, and the SQL test pins the CASE and the absence of a
CAST on SQLite. The navigation's Max was found by this catalogue, not
the review (Found-By: VA). Reverting the re-typing fails 9 cases on SQL
Server and the SQL test; reverting the check fails the in-memory test
and the SQL test. The seed column-cast-not-re-typed is refreshed to the
new lines.

Defect-Class: EFT
Found-By: OR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CaffeinatedCoder and others added 3 commits October 30, 2026 17:26
…used' into feat/ef-arithmetic-translation

# Conflicts:
#	design/adr/README.md
#	design/ef-core.md
2.3.1 takes decision 0121 (#198), and the 2.4.0 records move up one:
the arithmetic record is 0122 (#188), the hand-written-operator record
0123 (#189), and this one 0124. Its heading and index row move, its
citations of 121 and 122 become 122 and 123, the arithmetic record's
index row names it as refining, and design/ef-core.md links it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…etic-translation

The base took feat/value-object-arithmetic and, through it, 2.3.1. The one
conflict is design/adr/README.md: 0121 to 0124 in order. The changelog
merged clean, its 2.3.1 section release/2.4.0's line for line.

EntityFrameworkCore.Tests 24424 passed, Conventions.Tests 2053/2053.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from fix/ef-hand-written-operator-refused to release/2.4.0 October 31, 2026 18:01
@CaffeinatedCoder CaffeinatedCoder removed the stacked Base is another open PR; merge in order, CI does not run until it targets a release label Oct 31, 2026
@CaffeinatedCoder
CaffeinatedCoder merged commit 251f016 into release/2.4.0 Oct 31, 2026
12 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the feat/ef-arithmetic-translation branch October 31, 2026 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/value-objects Value-object contracts and what the aspects generate for them enhancement New feature or request package/efcore Affects CodoMetis.TypeKit.EntityFrameworkCore

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant