Skip to content

EF Core: translate an arithmetic operator whose other operand or result is not the value object #186

Closed

Description

@CaffeinatedCoder

Problem

The arithmetic operators a plain value object declares (#184) translate in an EF Core query only between two value objects: o.Price + o.Tax > limit runs in the database as ef_add("p"."Price", "p"."Tax") on SQLite, as two decimal columns would. An operator with another operand or result type throws instead, though it works in memory:

db.Orders.Where(o => o.Price * 2m > Money.From(10m)).ToList();   // InvalidCastException: Invalid cast from 'System.Decimal' to 'Money'
db.Orders.Select(o => o.Tax / o.Price).ToList();                  // InvalidOperationException: No coercion operator is defined between types 'Money' and 'Nullable<Decimal>'

EF infers the binary expression's type mapping from the column and applies the value object's converter to the decimal side. o.Price.Value * 2m translates. Measured on SQLite with EF Core 10 (ArithmeticQueryTests, which pins both throws).

Change

  • Translate an operator whose other operand or result is not the value object as the wrapped type's arithmetic over the column, in the EF Core package, on SQLite, Npgsql and SQL Server.
  • docs/value-objects.md ("Add, subtract and scale") then drops its .Value advice for queries, and ArithmeticQueryTests asserts the rows instead of the throws.

Found while doing it (#190)

  • .Value of the database's arithmetic was cast to the column's store type, which rounds on SQL Server: (o.Price / 32m).Value over 5 read 0.16 where memory reads 0.15625 at the default decimal(18,2) (measured on Azure SQL Edge). Re-typed now, in both directions.
  • Between two value objects, EF's own translation bypassed decision 56: over a column the application stores with its own converter ("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.
  • byte, short, char have no arithmetic in an expression tree; their operators are refused naming the value object.
  • Composed, the database's arithmetic was still cast (found in review): a CASE (?:) or a COALESCE (??) of it was cast whole to the column's store type, so (o.Optional / 32m ?? o.Price / 32m).Value read [0.09, 0.31, 0.05] where memory reads [0.09375, 0.3125, 0.046875] on SQL Server, a filter on it lost its row, and arithmetic over a ?: rounded the same. A CASE, a COALESCE, MAX and MIN, and a subquery of one value (a navigation's largest quotient, found across the input class) are re-typed branch by branch now.
  • An optional operand's null threw in memory (found in review): 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 checks each optional operand now, as the lifted operator does: one CASE WHEN … IS NOT NULL THEN … END around the arithmetic, which reads the column once.

Done when

  • Price * 2m, Price / 2m, Price % 2m and Tax / Price (a decimal) in a filter and a projection answer as memory does on the three providers.

Activity

  1. added this to the 2.4.0 milestone on Oct 30, 2026
  2. added
    enhancementNew feature or request
    package/efcoreAffects CodoMetis.TypeKit.EntityFrameworkCore
    area/value-objectsValue-object contracts and what the aspects generate for them
    on Oct 30, 2026
  3. CaffeinatedCoder commented on Oct 30, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Implemented in #190, stacked on #189 (#187) and #188: every generated operator is rewritten to the wrapped type's arithmetic over ProviderValue, its value-object result Materialized and re-typed. 32 queries answer as memory on SQLite, PostgreSQL 17 and SQL Server, and a precompiled query runs under Native AOT. Decision 124.

  4. CaffeinatedCoder commented on Oct 31, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Merged into release/2.4.0 by #190 (251f016).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/value-objectsValue-object contracts and what the aspects generate for themenhancementNew feature or requestpackage/efcoreAffects CodoMetis.TypeKit.EntityFrameworkCore

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions