Repository navigation
feat: a value object's generated operator runs in an EF Core query as the wrapped type's arithmetic - #190
Merged
Conversation
… 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>
This was referenced Oct 30, 2026
…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>
…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>
This was referenced Oct 31, 2026
Base automatically changed from
fix/ef-hand-written-operator-refused
to
release/2.4.0
October 31, 2026 18:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 > limitthrew "Invalid cast from 'System.Decimal' to 'ProbeMoney'".o.Tax / o.Price, adecimal, threw "No coercion operator is defined between types 'ProbeMoney' and 'Nullable'".+was "could not be translated".Testing across the input class turned up two silent siblings:
.Valueof the database's arithmetic was cast to the column's store type. At SQL Server's defaultdecimal(18,2),(o.Price / 32m).Valueover 5 read 0.16 where memory reads 0.15625, and a filter on it lost the row (measured on Azure SQL Edge)."M:" + value:Price + Tax > 4became"p"."Price" + "p"."Tax" > 'M:4'and kept no row, where memory keeps two.-Price < -4kept all three, where memory keeps two.Change
ValueObjectArithmeticnow 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:ValueObjectConverter<,>.ProviderValueof each value-object operand. The other operand stays as it is.ValueObjectConverter<,>.Materializeof it.+of a number is its operand.byte,shortandcharhave no arithmetic in an expression tree, so their operators are rejected naming the value object.ConverterMethods(new):ProviderValueandMaterializeper value object, throughGeneratedValueObjectAttribute.Accept, never by name.ValueThroughInterfacenow uses it too.Materializeis translated byWrappedValue.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.Ofre-types the database's arithmetic instead of casting it: binary arithmetic, negation, and SQLite'sef_*functions.ProviderValue, an operator over a foreign-converted column is EF's "could not be translated". As a query's lastSelect, EF computes it in memory.byte/short/char; your own converter.docs/value-objects.md, the EF bullet.design/ef-core.md.ArithmeticQueryTestsis registered as a differential oracle, with the seedarithmetic-left-to-ef.column-cast-not-re-typedis refreshed.C28-ef-between-value-objectsbecomesC28-ef-runs-as-underlying-arithmetic.axes.md: an EF operator row.Tests
ArithmeticQueryTestsis 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.Max.decimalcolumn.checked, unary+and-,.Valueof a result, and values past the column's scale.decimal,.Valueneither.ef_multiply("p"."Price", '2.0'), noCAST) and thebyterejection.ArithmeticQueryFilterTests: query filters, anonymous and named.ForeignConvertedArithmeticTests: an application's own converter. It failed before every operator was rewritten.oracles seeds arithmetic-left-to-ef: CAUGHT, 25 of 31 fail.oracles seeds column-cast-not-re-typed: CAUGHT, 2978 of 9621 fail.AsValueObjectcasting instead of re-typing: the SQL Server scale cases and the SQLite SQL check fail.Quantity * 3and(Quantity + Quantity).Value(dbcontext optimize --precompile-queries --nativeaot) and printsef-arithmetic=6 4.Weaving review
arithmetic-left-to-efshows the oracle notices when none is.GeneratedValueObjectAttribute. The only names matched are SQLite's ownef_*decimal functions, and the fallback for any other is a cast, correct and at worst rounded..Valueand the operators still translate only through TypeKit's converter (decision 56, now for operators too). Re-typed, never cast, in both directions.MakeGenericTypeor member lookup: the methods come from the converter's expressions throughAccept. The smoke test's precompiled queries run.Review fixes (ae0c73b)
An outside review found two defects, both reproduced:
(o.Optional / 32m ?? o.Price / 32m).Valueread[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) * 2mrounded the same. ACASE, aCOALESCE,MAXandMIN, and a subquery of one value are re-typed branch by branch now, each branch's converter checked; a navigation'sMaxwas found by the catalogue, not the review.Selectin 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 oneCASE WHEN … IS NOT NULL THEN … ENDthat reads the column once. The non-nullable SQL is unchanged.ArithmeticQueriesgains 13 cases (?:,??,?? of ?:,ValueOrNull()of an optional quotient, a navigation'sMax, 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 theCASEand the absence of aCAST. 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-typedis refreshed to the new lines.🤖 Generated with Claude Code