Skip to content

fix: a value object's hand-written operator is refused in an EF Core query - #189

Merged
CaffeinatedCoder merged 6 commits into
release/2.4.0from
fix/ef-hand-written-operator-refused
Oct 31, 2026
Merged

CaffeinatedCoder merged 6 commits into
release/2.4.0from
fix/ef-hand-written-operator-refused

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #187

Stacked on #188 (feat/value-object-arithmetic); the translation (#190) stacks on this one.

Problem

EF translates an operator node as the database's arithmetic over the columns and never calls the operator's method. A hand-written operator therefore ran as the database's arithmetic, not as written. With a + that rounds to cents, measured on SQLite (EF Core 10.0.12) with the refusal taken out, over 1.005 and 0.001:

  • Price + Tax > 1.006 became ef_compare(ef_add("l"."Price", "l"."Tax"), @limit) > 0 and kept no row, where memory keeps the one.
  • Select(l => l.Price + l.Tax) read 1.006, where memory reads 1.01.

Decision 122 keeps such an operator as a seam, so the build can't stop it. The query has to.

Change

  • TranslatedAsWrappedArithmeticAttribute is new, [EditorBrowsable(Never)], in CodoMetis.TypeKit.CompilerServices. ValueObjectArithmeticAspect puts it on every operator it generates, via CodeAnnotations.TranslatedAsWrappedArithmetic.
  • ValueObjectArithmetic (EF) holds an IQueryExpressionInterceptor, and a finalizing convention for query filters, named or not.
    • They reject any arithmetic, bitwise or shift operator a value object declares without the mark, naming the value object and .Value.
    • A filter fails when the model is built. An operator over the context's own values runs in memory and stays allowed.
    • Conversion operators are left to EF.
  • DatabaseReads moves out of HandWrittenOrdering and is now shared with the new convention.
  • Docs:
    • EF README, Queries: "Your own operator can't run in the database".
    • docs/value-objects.md, the EF bullet.
    • Generators README, "Write your own members".
    • The changelog, under Fixed.
  • Records: decision 123 (refines 122); notes in design/ef-core.md, aspects.md and packages.md.
  • Oracles: 6 claims (C28-ef-own-operator-*, C28-generated-operators-marked, E18-own-operator-*).

Tests

  • HandWrittenArithmeticTests covers the input class, every operator a value object can declare:
    • +, * without an interface, unary -, |, ~ and a validated value object's +;
    • in a filter, a projection, a checked context and OrderBy, over an optional operand and over a navigation;
    • in query filters, named or not.
    • What stays allowed: the generated - beside a hand-written +, arithmetic on .Value, and an operator over captured values, in a query and in a filter.
  • ArithmeticTests.A_generated_operator_is_marked_…: every generated ProbeMoney operator carries the mark, and no hand-written one does.
  • Each guard proven by reverting it:
    • Refusal taken out: 13 tests fail (the measurement above).
    • Mark not generated: every generated operator is rejected, 95 query tests fail. Loud, not silent.
    • Filter convention unregistered: 2 tests fail.
    • Filters no longer skipping what runs in memory: the captured-values test fails.
  • Full suite: 30,981 passed, 924 skipped (as before), with SQL Server on Azure SQL Edge.

Weaving review

  1. Reach. The mark is added where the operator is introduced, and the test reads it on every generated operator. Without it the result is loud (everything rejected), not silent.
  2. Discovery. By the attribute on the operator's MethodInfo and GeneratedValueObjectAttribute, never by name.
  3. EF. Queries via QueryCompilationStarting, filters at model finalizing, navigations included. Decision 57's ordering refusal is untouched.
  4. Packaging. The base package gains one attribute and stays Metalama-free. No reflection beyond GetCustomAttribute, and the package still builds IsAotCompatible.

Review fix (41f0155)

An outside review found that && and || bypassed the refusal: over a value object with operator true and false they are AndAlso and OrElse nodes whose method is its & or |, and Switch && Switch returned true where the operators answer false. Both are refused now. HandWrittenArithmeticTests gains them in a projection and a filter, and || in a query filter; reverting the two entries fails all five. A condition over operator true compiles to a call of op_True, which EF runs in memory as written; that stays allowed and is tested. EF suite 20,165 passed, 915 skipped, on SQLite, PostgreSQL and Azure SQL Edge.

🤖 Generated with Claude Code

…query

EF translates an operator node as the database's arithmetic over the
columns and never calls the operator. A hand-written `+` that rounds to
cents ran as the plain sum: `Price + Tax > 1.006` over 1.005 and 0.001
kept no row where memory keeps one, and the projection read 1.006 where
memory reads 1.01 (measured on SQLite with the refusal taken out).

The arithmetic aspect now marks every operator it generates
`[TranslatedAsWrappedArithmetic]`. An interceptor, and a finalizing
convention for query filters, refuse any other arithmetic, bitwise or
shift operator a value object declares, naming the value object and
`.Value`; a filter fails when the model is built, and an operator over
the context's own values in a filter stays allowed. Conversion
operators are left to EF. Decision 122.

The input class is every operator a value object can declare: `+`, `*`
without an interface, unary `-`, `|`, `~` and a validated value object's
`+`, in a filter, a projection, a checked context, `OrderBy`, over an
optional operand and a navigation, and in query filters, named or not
(`HandWrittenArithmeticTests`).

Closes #187

Defect-Class: EFT
Found-By: SF
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 bug Something isn't working silent Compiles or answers and does less than it appears to stacked Base is another open PR; merge in order, CI does not run until it targets a release severity/medium Inconsistency or failure in a narrow scenario 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:40
…operator-refused

# Conflicts:
#	design/aspects.md
…e query

C# compiles `a && b` over a value object with `operator true` and
`false` to its own node, AndAlso, whose method is the value object's
`&`, and `||` to OrElse over its `|`. The guard of decision 122 knew And
and Or only, so `Switch && Switch` over a column passed: EF read the
column where the operators answer false, and the query returned true,
in a projection and in a filter alike (found in an outside review of
#189, reproduced on SQLite).

AndAlso and OrElse join the refused nodes, so both are refused naming
the value object and `.Value`, and a query filter with one fails when
the model is built.

Input class: the expression nodes a value object's hand-written
operator can reach a query as. Measured the rest of it: a condition
over `operator true` is a call of op_True, no operator node, which EF
runs in memory as written; that stays allowed and is held by a test.
HandWrittenArithmeticTests gains `&&` and `||` in a projection and a
filter, and `||` in a query filter. Reverting the two entries fails all
five.

Defect-Class: EFT
Found-By: OR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CaffeinatedCoder and others added 2 commits October 30, 2026 17:24
…to fix/ef-hand-written-operator-refused

# Conflicts:
#	design/adr/README.md
… 0123

2.3.1 took 0121, so the 2.4.0 records move up by one: the arithmetic
record is 0122 (merged from feat/value-object-arithmetic) and this one
0123. Its heading, its index row, and the citations in design/aspects.md
and design/ef-core.md follow, and its own citations of the arithmetic
record move from 121 to 122.

Until 2.3.1's 0121 reaches release/2.4.0,
The_records_are_numbered_without_a_gap fails on this branch, the only
conventions test that does.

Refs #187

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CaffeinatedCoder added a commit that referenced this pull request Oct 30, 2026
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>
…erator-refused

The base took release/2.4.0 and with it 2.3.1. Three things to resolve:

- design/adr/README.md: 0121, 0122, 0123 in order.
- HandWrittenOrderingInterceptor: this branch moved DatabaseReads to its
  own file; 2.3.1 added LambdaFinder beside it (#234) and left DatabaseReads'
  summary stacked on LambdaFinder's. LambdaFinder stays nested with its own
  summary; DatabaseReads, unchanged by 2.3.1, stays in DatabaseReads.cs.
- CHANGELOG.md: git put this branch's 2.4.0 Fixed entry at the end of the
  dated 2.3.1 section (ChangelogTests caught its blob/main link). It is back
  under 2.4.0, and the 2.3.1 section is release/2.4.0's line for line.

EntityFrameworkCore.Tests 24289 passed, Conventions.Tests 2045/2045.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from feat/value-object-arithmetic to release/2.4.0 October 31, 2026 17:45
@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 3dc4695 into release/2.4.0 Oct 31, 2026
12 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/ef-hand-written-operator-refused branch October 31, 2026 18:01
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 bug Something isn't working package/efcore Affects CodoMetis.TypeKit.EntityFrameworkCore severity/medium Inconsistency or failure in a narrow scenario silent Compiles or answers and does less than it appears to

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant