From 5532f07a68414fa76f353f14ae113d5693f42ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 13:56:10 +0100 Subject: [PATCH 1/3] fix: a value object's hand-written operator is refused in an EF Core 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 --- CHANGELOG.md | 4 + ...-written-operator-is-refused-in-a-query.md | 43 ++++ design/adr/README.md | 3 +- design/aspects.md | 3 +- design/ef-core.md | 6 + design/packages.md | 4 +- docs/value-objects.md | 4 +- .../DatabaseReads.cs | 40 ++++ .../HandWrittenOrderingInterceptor.cs | 37 --- .../README.md | 9 + .../TypeKitConventionSetPlugin.cs | 7 +- .../TypeKitServiceCollectionExtensions.cs | 3 +- .../ValueObjectArithmetic.cs | 138 +++++++++++ .../CodeAnnotations.cs | 3 + src/CodoMetis.TypeKit.Generators/README.md | 6 +- .../ValueObjectArithmeticAspect.cs | 3 + .../TranslatedAsWrappedArithmeticAttribute.cs | 28 +++ .../HandWrittenArithmeticTests.cs | 223 ++++++++++++++++++ .../ArithmeticTests.cs | 24 ++ test/oracles/claims.json | 78 ++++++ 20 files changed, 620 insertions(+), 46 deletions(-) create mode 100644 design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md create mode 100644 src/CodoMetis.TypeKit.EntityFrameworkCore/DatabaseReads.cs create mode 100644 src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectArithmetic.cs create mode 100644 src/CodoMetis.TypeKit/CompilerServices/TranslatedAsWrappedArithmeticAttribute.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenArithmeticTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f12319d..f0b7ca35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ section is the GitHub release's notes. - Packages: the public surface is checked against 2.3.0's, so nothing 2.3.0 ships is removed or changed in 2.4.0. +### Fixed + +- EF Core: a query that uses an arithmetic or bitwise operator a value object declares by hand throws, naming the value object, where the database ran its own arithmetic and skipped the operator; a query filter fails when the model is built ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). + ## 2.3.0 — 2026-10-29 ### Added diff --git a/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md b/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md new file mode 100644 index 00000000..da5c0d09 --- /dev/null +++ b/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md @@ -0,0 +1,43 @@ +# 0122. A hand-written operator is refused in a query, and a generated one is marked + +Date: 2026-10-30 +Status: accepted + +Context: [#187](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/187), under the 2.4.0 epic +[#177](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/177); [EF Core](../ef-core.md); refines +[decision 121](0121-arithmetic-operators-are-opt-in-and-a-hand-written-one-is-a-seam.md), beside +[decision 57](0057-hand-written-ordering-refused-in-query.md). + +EF translates an operator node as the database's arithmetic over its operands, and never calls the operator's +method. A hand-written operator therefore ran as the database's arithmetic, not as written: with its `+` rounding +to cents, `Price + Tax > 1.006` over 1.005 and 0.001 became `ef_compare(ef_add("l"."Price", "l"."Tax"), @limit) > 0` +on SQLite and kept no row where memory keeps the one, and `Select(l => l.Price + l.Tax)` read 1.006 where memory +reads 1.01 (measured with EF Core 10.0.12, the refusal taken out). Decision 121 keeps such an operator as a seam, so +the build cannot stop it; the query must. + +**Any arithmetic, bitwise or shift operator a value object declares without `[TranslatedAsWrappedArithmetic]` is +refused in a query, naming the value object and `.Value`.** + +- **The mark.** The arithmetic aspect puts `[TranslatedAsWrappedArithmetic]` (new, `[EditorBrowsable(Never)]`, in + `CodoMetis.TypeKit.CompilerServices`) on every operator it generates, which is the wrapped type's arithmetic over + the wrapped values. An operator without it was written by hand, whatever its interface: a 2.3 operator, one kept + as a seam, one without an interface, a validated value object's. Not `[CompilerGenerated]`, which another source + generator may put on an operator that does more, and not the interface, which an operator written without one does + not declare. +- **Where.** An `IQueryExpressionInterceptor` that `UseTypeKit()` registers, before translation, `Queryable`'s and + `Enumerable`'s over a navigation alike, as decision 57's; and a finalizing convention for query filters, named or + not, which fails when the model is built. An operator over the context's own values in a filter runs in memory, + as written, and stays allowed. +- **Which.** `+`, `-`, `*`, `/`, `%`, unary `-` and `+`, their checked forms, `&`, `|`, `^`, `<<`, `>>`, `~` and + `!`: the operators EF translates as the database's own. Comparison is decision 57's, equality the record's. A + conversion operator is left to EF: `implicit operator string(Name n) => n.Value` is common, and translates to what + it does. +- **A refusal in a minor.** A query with a hand-written operator that ran before now throws. It did not return what + the code says. Decision 34 holds the build's diagnostics, not a query's answer, and a silent wrong answer is a + defect, fixed in the release that finds it, as the refusal of a hand-written `CompareTo` was. + +`HandWrittenArithmeticTests` holds it: `+`, `*` 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 a query filter, named or not; the generated `-` beside a hand-written `+`, `.Value` arithmetic, and an +operator over captured values stay allowed. `ArithmeticTests` holds the mark on every generated operator and on no +hand-written one. diff --git a/design/adr/README.md b/design/adr/README.md index 64063bdd..f15435be 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,4 +127,5 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [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-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 | +| [0121](0121-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 [0122](0122-a-hand-written-operator-is-refused-in-a-query.md) | +| [0122](0122-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 121 | 2026-10-30 | accepted | diff --git a/design/aspects.md b/design/aspects.md index c36016ab..85fac893 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -17,7 +17,8 @@ each calling the wrapped type's through `GeneratedArithmetic`, a constrained generic call with its context stated. 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, never CMTK1011, so code that wrote them on - 2.3 builds unchanged. + 2.3 builds unchanged. Each generated operator carries `[TranslatedAsWrappedArithmetic]`, which is how an EF + Core query tells it from a hand-written one ([decision 122](adr/0122-a-hand-written-operator-is-refused-in-a-query.md)). - **Declarations that cannot be generated** are errors, so no type is left half-generated: CMTK1000/1001/1002 (not `partial`, not a record, a struct not `readonly`), CMTK1003 (more than one marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a diff --git a/design/ef-core.md b/design/ef-core.md index f83a23f2..a5ce81b0 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -59,6 +59,12 @@ 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 122](adr/0122-a-hand-written-operator-is-refused-in-a-query.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, naming the value object (#187). A conversion operator is left to EF. - **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 diff --git a/design/packages.md b/design/packages.md index f5f1505d..178112b3 100644 --- a/design/packages.md +++ b/design/packages.md @@ -2,7 +2,7 @@ | Package | Depends on | Metalama | Contents | |---|---|---|---| -| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault` and `Fault.Of`, and `Fault` and `Fault`, a fault of one of several types ([decision 78](adr/0078-fault-sets-compose-side-by-side.md)); `Patch`, `PatchState` and `PatchJsonConverterFactory`; `Result.Catch`/`CatchAsync`; the text guards `AsText()`/`AsIdentifier()` (`StringTextExtensions`) and `TextFault`; the type-argument constraint attributes `[Abstract]`, `[Concrete]`, `[Sealed]`, `[Record]`; `RequireCustomInitializationAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([Discovery](discovery.md), [Aspects](aspects.md), [Native AOT](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `GeneratedClosedSetAttribute<>`, `IValueObjectVisitor<>`, `IClosedSetVisitor<>`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedArithmetic`, `GeneratedJson`, `GeneratedJsonPlan<>`, `GeneratedClosedSets`, `IStoredJsonConverterSource` | +| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option`, `Result`, `Result` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault` and `Fault.Of`, and `Fault` and `Fault`, a fault of one of several types ([decision 78](adr/0078-fault-sets-compose-side-by-side.md)); `Patch`, `PatchState` and `PatchJsonConverterFactory`; `Result.Catch`/`CatchAsync`; the text guards `AsText()`/`AsIdentifier()` (`StringTextExtensions`) and `TextFault`; the type-argument constraint attributes `[Abstract]`, `[Concrete]`, `[Sealed]`, `[Record]`; `RequireCustomInitializationAttribute`, `RequireParameterlessConstructorAttribute` and `ConstructorAccessibility`; `GuidValueExtensions` (`OrderId.New()` from a version 7 Guid); and what only generated code and the satellites use ([Discovery](discovery.md), [Aspects](aspects.md), [Native AOT](native-aot.md)): `GeneratedValueObjectAttribute<,>`, `GeneratedClosedSetAttribute<>`, `IValueObjectVisitor<>`, `IClosedSetVisitor<>`, `TranslatedAsWrappedValueAttribute`, `TranslatedAsWrappedArithmeticAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedArithmetic`, `GeneratedJson`, `GeneratedJsonPlan<>`, `GeneratedClosedSets`, `IStoredJsonConverterSource` | | `CodoMetis.TypeKit.Analyzers` | — | no | Roslyn analyzers and code fixes ([Analyzers](analyzers.md)). Nobody references it directly: it reaches every consumer through the base package | | `CodoMetis.TypeKit.Generators` | TypeKit, Metalama.Framework 2026.1.x (flows) | yes | Internal aspects (the value-object family and `ClosedSetAspect`), internal `TransitiveProjectFabric`, `AspectOrder`. Future generated type families join this package too: one fabric, one aspect order | | `CodoMetis.TypeKit.EntityFrameworkCore` | TypeKit, EF Core Relational | no | Generic converter, convention, `.Value` / `GetValue` / `ValueOrNull` translators, `UseTypeKit()` | @@ -25,7 +25,7 @@ objects from a referenced domain assembly never needs Metalama itself. | `CodoMetis.TypeKit` (2.0.0) | `Fault`, `FaultRuleExtensions` and `FaultClosedSetRuleExtensions` (2.1), `Patch`, `PatchState`, `PatchJsonConverterFactory`, `StringTextExtensions`, `TextFault`, the constraint attributes, `RequireParameterlessConstructorAttribute`, `ConstructorAccessibility` | | `CodoMetis.TypeKit.ClosedSets` | `IClosedSet<>`, `IFault`, `IFaultSet<>`, `FaultKind`: what a user declares a closed set or a fault set with | | `CodoMetis.TypeKit.ValueObjects` | Every contract a user declares or constrains on: `IValue`, `IValidatedValue`, `IValueObject`, `IPlainValueObject`, `IValueObjectMaterializer`, and `StoredJsonConverterFactory` | -| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `GeneratedClosedSetAttribute`, `IValueObjectVisitor`, `IClosedSetVisitor`, `TranslatedAsWrappedValueAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedArithmetic`, `GeneratedJson`, `GeneratedJsonPlan`, `GeneratedClosedSets`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | +| `CodoMetis.TypeKit.CompilerServices` | What only generated code and the satellites touch, all `[EditorBrowsable(Never)]`: `GeneratedValueObjectAttribute`, `GeneratedClosedSetAttribute`, `IValueObjectVisitor`, `IClosedSetVisitor`, `TranslatedAsWrappedValueAttribute`, `TranslatedAsWrappedArithmeticAttribute`, `HandWrittenOrderingAttribute`, `GeneratedFactories`, `GeneratedParsing`, `GeneratedFormatting`, `GeneratedArithmetic`, `GeneratedJson`, `GeneratedJsonPlan`, `GeneratedClosedSets`, `IStoredJsonConverterSource`. The name follows `System.Runtime.CompilerServices` | | `CodoMetis.TypeKit.EntityFrameworkCore` | `ValueObjectConverter<,>`, `ClosedSetConverter`; the plugins, internal | | `CodoMetis.TypeKit.AspNetCore` | the schema transformers, internal; `FaultProblem`, `FaultResponse`, `FaultResponseOptions`, `FaultStatusAttribute`, `IFaultFeature` | | `Microsoft.AspNetCore.Http` | `ToHttpResult()`, for a `Result` and for a closed set's fault (`TypeKitHttpResultExtensions`, `TypeKitClosedSetHttpResultExtensions`), beside ASP.NET's own `Results` ([decision 22](adr/0022-registration-extensions-live-framework-s-namespaces.md)) | diff --git a/docs/value-objects.md b/docs/value-objects.md index 82aadc85..6a3ee23c 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -234,7 +234,9 @@ object or another type, as long as the underlying type has that operator itself. 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. + 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)). ## Enums, dates and other types diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/DatabaseReads.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/DatabaseReads.cs new file mode 100644 index 00000000..905f9f36 --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/DatabaseReads.cs @@ -0,0 +1,40 @@ +using System.Linq.Expressions; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// Whether an expression reads a parameter it does not declare itself, or a query. +internal sealed class DatabaseReads : ExpressionVisitor +{ + private readonly HashSet _declared = []; + private bool _found; + + public static bool In(Expression expression) + { + var reads = new DatabaseReads(); + reads.Visit(expression); + + return reads._found; + } + + public override Expression? Visit(Expression? node) + { + if (_found || node is null) return node; + if (typeof(IQueryable).IsAssignableFrom(node.Type)) _found = true; + + return _found ? node : base.Visit(node); + } + + protected override Expression VisitLambda(Expression node) + { + _declared.UnionWith(node.Parameters); + + return base.VisitLambda(node); + } + + protected override Expression VisitParameter(ParameterExpression node) + { + if (!_declared.Contains(node)) _found = true; + + return node; + } +} diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs index 09a9e8cf..906eb0df 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs @@ -204,41 +204,4 @@ private void Refuse(Type type, string how) _ => ">=", }; } - - /// Whether an expression reads a parameter it does not declare itself, or a query. - private sealed class DatabaseReads : ExpressionVisitor - { - private readonly HashSet _declared = []; - private bool _found; - - public static bool In(Expression expression) - { - var reads = new DatabaseReads(); - reads.Visit(expression); - - return reads._found; - } - - public override Expression? Visit(Expression? node) - { - if (_found || node is null) return node; - if (typeof(IQueryable).IsAssignableFrom(node.Type)) _found = true; - - return _found ? node : base.Visit(node); - } - - protected override Expression VisitLambda(Expression node) - { - _declared.UnionWith(node.Parameters); - - return base.VisitLambda(node); - } - - protected override Expression VisitParameter(ParameterExpression node) - { - if (!_declared.Contains(node)) _found = true; - - return node; - } - } } diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 47641fc3..36e21b82 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -215,6 +215,15 @@ instead, which works everywhere and may add a cast: (`OrderBy(o => o.Priority.Value)`), or order in memory after the query. Equality isn't ordering, and stays allowed. A query filter (`HasQueryFilter`, named or not) that does the same fails when the model is built, naming the entity type, since it applies to every query of that type. +- **Your own operator can't run in the database.** EF reads `o.Price + o.Tax` as the database's own + addition of the two columns and never calls your `+`. Whatever your operator does beyond that, such + as rounding, would be skipped. So a query that uses an arithmetic or bitwise operator you wrote on a + value object throws an `InvalidOperationException` naming the value object. Compute it from `.Value` + where the underlying type's arithmetic is what you mean (`o.Price.Value + o.Tax.Value`), or do it in + memory after the query. A query filter that uses one fails when the model is built. An operator over + values you pass in, which EF computes before the query, stays allowed. The operators TypeKit + generates ([Add, subtract and scale](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/value-objects.md#add-subtract-and-scale)) + are the underlying type's, and work in a query. ### On SQL Server diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs index f0ec2c3a..8df12b4d 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs @@ -7,8 +7,8 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore; /// Adds TypeKit's conventions to every model UseTypeKit() builds: the key generation of /// and , /// the rewrite of , -/// the generator of , and the refusal of -/// . +/// the generator of , and the refusals of +/// and . /// internal sealed class TypeKitConventionSetPlugin : IConventionSetPlugin { @@ -27,6 +27,9 @@ public ConventionSet ModifyConventions(ConventionSet conventionSet) // A query filter's .Value read through the value-object interface, before the model is final. finalizing.Add(new ValueThroughInterfaceQueryFilterConvention()); + // A query filter's value-object operator written by hand, refused. + finalizing.Add(new ValueObjectArithmeticQueryFilterConvention()); + // Last, once whether a property is generated is final: the provider's generator for the wrapped type. finalizing.Add(new ValueObjectValueGeneratorConvention()); diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs index d3d9e5dc..dd8ad7a0 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitServiceCollectionExtensions.cs @@ -30,7 +30,8 @@ public static IServiceCollection AddEntityFrameworkTypeKit(this IServiceCollecti .TryAdd() .TryAdd() .TryAdd() - .TryAdd(); + .TryAdd() + .TryAdd(); return serviceCollection; } diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectArithmetic.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectArithmetic.cs new file mode 100644 index 00000000..49c23cfc --- /dev/null +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectArithmetic.cs @@ -0,0 +1,138 @@ +using System.Linq.Expressions; +using System.Reflection; +using CodoMetis.TypeKit.CompilerServices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; + +namespace CodoMetis.TypeKit.EntityFrameworkCore; + +/// +/// Refuses an operator a value object declares by hand (), before EF translates +/// the query. +/// +/// +/// QueryCompilationStarting sees the LINQ tree once per query shape, after EF has evaluated what reads no +/// database, so what is left reads a column. It does not see the query filters EF adds later, which +/// checks. +/// +internal sealed class ValueObjectArithmeticInterceptor : IQueryExpressionInterceptor +{ + public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) => + ValueObjectArithmetic.Rewrite(queryExpression); +} + +/// Refuses the same in the model's query filters, named ones included, when the model is built. +/// +/// EF adds an entity type's query filters to a query after QueryCompilationStarting, so +/// never sees them. A filter applies to every query of its entity type, +/// so a hand-written operator in one is refused where the model is built, as an invalid configuration is. +/// +internal sealed class ValueObjectArithmeticQueryFilterConvention : IModelFinalizingConvention +{ + public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + { + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + foreach (var filter in entityType.GetDeclaredQueryFilters().ToList()) + { + if (filter.Expression is not { } expression) continue; + + var where = filter.IsAnonymous + ? $"in the query filter of {entityType.DisplayName()}" + : $"in the query filter \"{filter.Key}\" of {entityType.DisplayName()}"; + + if (ValueObjectArithmetic.RewriteFilter(expression, where) is not LambdaExpression rewritten || rewritten == expression) continue; + + if (filter.IsAnonymous) entityType.SetQueryFilter(rewritten); + else entityType.SetQueryFilter(filter.Key, rewritten); + } + } +} + +/// A value object's operator in a query: one written by hand refused. +/// +/// +/// EF translates an operator as the database's arithmetic over the operands' columns, and never calls its method. +/// For a generated operator that is what it does: the generators mark the ones they write +/// , and each calls the wrapped type's through +/// . +/// +/// +/// An operator the value object declares without the mark was written by hand, and its body is what the database +/// cannot run: with From(Math.Round(left.Value + right.Value, 2)), Price + Tax > 1.006 became +/// ef_compare(ef_add("l"."Price", "l"."Tax"), @limit) > 0 and kept no row where memory keeps one +/// (measured with EF Core 10.0.12 on SQLite). It is refused, an arithmetic, bitwise or shift operator alike, naming +/// the value object and .Value, as a hand-written CompareTo is (). A +/// conversion operator is left to EF: implicit operator string(Name n) => n.Value is common and +/// translates to what it does. +/// +/// +internal static class ValueObjectArithmetic +{ + /// In a query's LINQ tree, which EF has already stripped of what it evaluates in memory. + public static Expression Rewrite(Expression query) => + new Rewriter("in a query", ", or do it in memory after the query", skipsWhatRunsInMemory: false).Visit(query); + + /// + /// In a query filter, where EF has not yet evaluated what reads nothing of the database: an operator over the + /// context's own values runs in memory, as written, and is not refused. + /// + public static Expression RewriteFilter(LambdaExpression filter, string where) => + new Rewriter(where, "", skipsWhatRunsInMemory: true).Visit(filter); + + private sealed class Rewriter(string where, string orElse, bool skipsWhatRunsInMemory) : ExpressionVisitor + { + protected override Expression VisitBinary(BinaryExpression node) + { + if (Operator(node.NodeType) is not null) Check(node.Method, node); + + return base.VisitBinary(node); + } + + protected override Expression VisitUnary(UnaryExpression node) + { + if (Operator(node.NodeType) is not null) Check(node.Method, node); + + return base.VisitUnary(node); + } + + /// Refuses where it is an operator of a value object written by hand, and reads the database. + private void Check(MethodInfo? method, Expression node) + { + if (method?.DeclaringType is not { } declaringType || ValueObjectTypes.Describe(declaringType) is not { } valueObject) return; + if (skipsWhatRunsInMemory && !DatabaseReads.In(node)) return; + + if (method.GetCustomAttribute() is null + || method.GetParameters() is not [{ ParameterType: var first }, ..] + || first != declaringType) + throw new InvalidOperationException( + $"{declaringType.Name}'s operator {Operator(node.NodeType)} is used {where}, but it is written by hand, and the database cannot run it: " + + $"there it is the {valueObject.WrappedType.Name} arithmetic of the column, which need not be what the operator does. " + + $"Compute it from .Value where the {valueObject.WrappedType.Name} arithmetic is meant{orElse}."); + } + } + + /// + /// The operators a value object can declare and EF translates as the database's own: arithmetic, bitwise and + /// shift. Comparison is 's, equality is the record's, and a conversion is left to EF. + /// + private static string? Operator(ExpressionType type) => type switch + { + ExpressionType.Add or ExpressionType.AddChecked => "+", + ExpressionType.Subtract or ExpressionType.SubtractChecked => "-", + ExpressionType.Multiply or ExpressionType.MultiplyChecked => "*", + ExpressionType.Divide => "/", + ExpressionType.Modulo => "%", + ExpressionType.Negate or ExpressionType.NegateChecked => "-", + ExpressionType.UnaryPlus => "+", + ExpressionType.And => "&", + ExpressionType.Or => "|", + ExpressionType.ExclusiveOr => "^", + ExpressionType.LeftShift => "<<", + ExpressionType.RightShift => ">>", + ExpressionType.OnesComplement => "~", + ExpressionType.Not => "!", + _ => null, + }; +} diff --git a/src/CodoMetis.TypeKit.Generators/CodeAnnotations.cs b/src/CodoMetis.TypeKit.Generators/CodeAnnotations.cs index 3e9ea062..fa341719 100644 --- a/src/CodoMetis.TypeKit.Generators/CodeAnnotations.cs +++ b/src/CodoMetis.TypeKit.Generators/CodeAnnotations.cs @@ -40,4 +40,7 @@ internal static class CodeAnnotations public static AttributeConstruction TranslatedAsWrappedValue => AttributeConstruction.Create(typeof(TranslatedAsWrappedValueAttribute)); + + public static AttributeConstruction TranslatedAsWrappedArithmetic => + AttributeConstruction.Create(typeof(TranslatedAsWrappedArithmeticAttribute)); } diff --git a/src/CodoMetis.TypeKit.Generators/README.md b/src/CodoMetis.TypeKit.Generators/README.md index 438acf9b..75fcc2dd 100644 --- a/src/CodoMetis.TypeKit.Generators/README.md +++ b/src/CodoMetis.TypeKit.Generators/README.md @@ -220,7 +220,11 @@ An arithmetic operator you write is kept as well, and so is an explicit implemen interface, yours or a base record's. Its `checked` form is then yours too: TypeKit generates neither form of that operator, and generates the ones you didn't write. A `checked` operator you write needs the plain one beside it, as C# requires (CS9025), and TypeKit doesn't write that one for you either. Code -that wrote these operators by hand before 2.4 builds unchanged. C# allows one operator per pair of operand types, so of two interfaces +that wrote these operators by hand before 2.4 builds unchanged. A database can't run an operator you +write, so an EF Core query that uses one is rejected, naming the value object +([CodoMetis.TypeKit.EntityFrameworkCore](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +The generated ones carry `[TranslatedAsWrappedArithmetic]`, which tells the query they are the +underlying type's arithmetic. C# allows one operator per pair of operand types, so of two interfaces that ask for the same operator, such as `IDivisionOperators` and `IDivisionOperators`, only the first is generated, and the second fails the build (CS0738) until you implement it explicitly. diff --git a/src/CodoMetis.TypeKit.Generators/ValueObjectArithmeticAspect.cs b/src/CodoMetis.TypeKit.Generators/ValueObjectArithmeticAspect.cs index 5a9acb86..f01bbd80 100644 --- a/src/CodoMetis.TypeKit.Generators/ValueObjectArithmeticAspect.cs +++ b/src/CodoMetis.TypeKit.Generators/ValueObjectArithmeticAspect.cs @@ -159,6 +159,9 @@ void Introduce(OperatorKind kind, string helperName) method.ReturnType = result; method.OperatorKind = kind; method.AddAttribute(CodeAnnotations.CompilerGenerated); + + // What tells an EF Core query this operator from one written by hand, which it refuses. + method.AddAttribute(CodeAnnotations.TranslatedAsWrappedArithmetic); }, tags: tags); } diff --git a/src/CodoMetis.TypeKit/CompilerServices/TranslatedAsWrappedArithmeticAttribute.cs b/src/CodoMetis.TypeKit/CompilerServices/TranslatedAsWrappedArithmeticAttribute.cs new file mode 100644 index 00000000..899c92e9 --- /dev/null +++ b/src/CodoMetis.TypeKit/CompilerServices/TranslatedAsWrappedArithmeticAttribute.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; + +namespace CodoMetis.TypeKit.CompilerServices; + +/// +/// Marks an operator of a value object that is the wrapped type's own operator over the wrapped values: +/// inside a relational query, it is translated as the wrapped type's arithmetic over the columns. +/// +/// +/// +/// CodoMetis.TypeKit.Generators puts it on every arithmetic operator it generates, which calls the +/// wrapped type's through : an operand of the value object's type is its +/// Value, any other operand is passed as it is, and a result of the value object's type wraps the +/// wrapped type's result. +/// +/// +/// An operator without it, one written by hand, is refused in a query, naming the value object: EF +/// translates an operator as the database's arithmetic and never runs its body, so a hand-written one +/// that does anything more (rounds, converts, checks) would answer differently there than in memory. +/// +/// +/// Nothing to write by hand: on an operator that does anything but the wrapped type's arithmetic, it +/// breaks that promise only on the database side, where nothing checks it. +/// +/// +[AttributeUsage(AttributeTargets.Method)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class TranslatedAsWrappedArithmeticAttribute : Attribute; diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenArithmeticTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenArithmeticTests.cs new file mode 100644 index 00000000..36a7ba9f --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenArithmeticTests.cs @@ -0,0 +1,223 @@ +using System.Numerics; + +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// An operator a value object declares by hand cannot run in the database, where EF translates it as the database's +/// arithmetic and never calls it: the query is refused, naming the value object and .Value (#187). +/// +/// +/// Measured on SQLite with the refusal taken out, with holding 1.005 and 0.001: its +/// + rounds the sum to 1.01, the database added the columns to 1.006, and Price + Tax > 1.006 kept +/// no row where memory keeps the one; projected, the sum read back as 1.006. +/// +public sealed class HandWrittenArithmeticTests : IDisposable +{ + private static readonly RoundedMoney Limit = RoundedMoney.From(1.006m); + + private static readonly Dictionary> Refused = new() + { + ["+ between two"] = db => db.Lines.Where(l => l.Price + l.Tax > Limit).ToList(), + ["+ in a projection"] = db => db.Lines.Select(l => l.Price + l.Tax).ToList(), + ["+ in a checked context"] = db => db.Lines.Where(l => checked(l.Price + l.Tax) > Limit).ToList(), + ["+ of an optional one"] = db => db.Lines.Where(l => l.Optional + l.Tax > Limit).ToList(), + ["* without an interface"] = db => db.Lines.Where(l => l.Price * 2m > Limit).ToList(), + ["unary -"] = db => db.Lines.Select(l => -l.Price).ToList(), + ["| of a bitwise one"] = db => db.Lines.Where(l => (l.Access | Permissions.From(1)) == Permissions.From(3)).ToList(), + ["~ of a bitwise one"] = db => db.Lines.Select(l => ~l.Access).ToList(), + ["+ of a validated one"] = db => db.Lines.Where(l => l.Allowance + l.Allowance == Allowance.FromKnownGood(4)).ToList(), + ["+ over a navigation"] = db => db.Ledgers.Where(g => g.Lines.Any(l => l.Price + l.Tax > Limit)).ToList(), + ["+ in OrderBy"] = db => db.Lines.OrderBy(l => l.Price + l.Tax).ToList(), + }; + + public static TheoryData RefusedCases => [.. Refused.Keys]; + + private readonly SqliteConnection _connection = new("Data Source=:memory:"); + + [Theory] + [MemberData(nameof(RefusedCases))] + public void A_hand_written_operator_in_a_query_is_refused_naming_the_value_object_and_Value(string name) + { + using var db = Seeded(); + + var refused = Should.Throw(() => Refused[name](db)); + + refused.Message.ShouldMatch(@"^(RoundedMoney|Permissions|Allowance)'s operator \S+ is used in a query, but it is written by hand"); + refused.Message.ShouldContain(".Value"); + } + + /// The generated - beside a hand-written +, the wrapped type's arithmetic, and an operator over captured values alone, which EF runs in memory before the query. + [Fact] + public void What_the_database_can_compute_as_written_stays_allowed() + { + using var db = Seeded(); + + db.Lines.Where(l => l.Price - l.Tax > RoundedMoney.From(1.004m)).Select(l => l.Id).ToList().ShouldBe([2]); + db.Lines.Where(l => l.Price.Value + l.Tax.Value > 1.006m).Select(l => l.Id).ToList().ShouldBe([2]); + + // Rounded in memory to 1.01; added in the database it would be 1.006, and no row. + db.Lines.Where(l => l.Price == RoundedMoney.From(1.005m) + RoundedMoney.From(0.001m)).Select(l => l.Id).ToList().ShouldBe([2]); + } + + [Fact] + public void A_query_filter_with_a_hand_written_operator_is_refused_when_the_model_is_built() + { + _connection.Open(); + using var db = new FilteredDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + + var refused = Should.Throw(() => db.Model); + + refused.Message.ShouldStartWith("RoundedMoney's operator + is used in the query filter of Line, but it is written by hand"); + } + + [Fact] + public void A_named_query_filter_with_a_hand_written_operator_is_refused_naming_it() + { + _connection.Open(); + using var db = new NamedFilteredDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + + var refused = Should.Throw(() => db.Model); + + refused.Message.ShouldStartWith("RoundedMoney's operator + is used in the query filter \"Rounded\" of Line, but it is written by hand"); + } + + /// + /// A filter's operator over the context's own values runs in memory, as written, when the query runs: it rounds + /// 1.005 + 0.001 to 1.01, where the database's sum would keep the other row. + /// + [Fact] + public void A_query_filter_with_a_hand_written_operator_over_captured_values_stays_allowed() + { + _connection.Open(); + using var db = new CapturedFilterDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + db.Database.EnsureCreated(); + db.Ledgers.Add(new Ledger { Id = 1, Lines = [NewLine(1, 1.01m, 0m), NewLine(2, 1.006m, 0m)] }); + db.SaveChanges(); + + db.Lines.Select(l => l.Id).ToList().ShouldBe([1]); + } + + public void Dispose() => _connection.Dispose(); + + private LedgerDb Seeded() + { + _connection.Open(); + var db = new LedgerDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + db.Database.EnsureCreated(); + db.Ledgers.Add(new Ledger { Id = 1, Lines = [NewLine(1, 1.005m, 0.001m), NewLine(2, 1.01m, 0m)] }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + return db; + } + + private static Line NewLine(int id, decimal price, decimal tax) => new() + { + Id = id, LedgerId = 1, Price = RoundedMoney.From(price), Tax = RoundedMoney.From(tax), + Access = Permissions.From(2), Allowance = Allowance.FromKnownGood(2), + }; + + public sealed class Line + { + public int Id { get; set; } + + public int LedgerId { get; set; } + + public RoundedMoney Price { get; set; } + + public RoundedMoney Tax { get; set; } + + public RoundedMoney? Optional { get; set; } + + public Permissions Access { get; set; } + + public Allowance Allowance { get; set; } + } + + public sealed class Ledger + { + public int Id { get; set; } + + public List Lines { get; set; } = []; + } + + public class LedgerDb(DbContextOptions options) : DbContext(options) + { + public DbSet Lines => Set(); + + public DbSet Ledgers => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(l => l.Id).ValueGeneratedNever(); + modelBuilder.Entity().Property(l => l.Id).ValueGeneratedNever(); + } + } + + private sealed class FilteredDb(DbContextOptions options) : LedgerDb(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().HasQueryFilter(l => l.Price + l.Tax > RoundedMoney.From(1m)); + } + } + + private sealed class NamedFilteredDb(DbContextOptions options) : LedgerDb(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().HasQueryFilter("Plain", l => l.Price.Value > 0m).HasQueryFilter("Rounded", l => l.Price + l.Tax > RoundedMoney.From(1m)); + } + } + + private sealed class CapturedFilterDb(DbContextOptions options) : LedgerDb(options) + { + private readonly RoundedMoney _base = RoundedMoney.From(1.005m); + + private readonly RoundedMoney _step = RoundedMoney.From(0.001m); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().HasQueryFilter(l => l.Price == _base + _step); + } + } +} + +/// +/// Rounds its sum to cents by hand, as 2.3 wrote an operator, beside a generated -; scales and negates by +/// hand without declaring an interface. +/// +public readonly partial record struct RoundedMoney : IValue, + IAdditionOperators, + ISubtractionOperators +{ + public static RoundedMoney operator +(RoundedMoney left, RoundedMoney right) => From(Math.Round(left.Value + right.Value, 2)); + + public static RoundedMoney operator *(RoundedMoney money, decimal factor) => From(Math.Round(money.Value * factor, 2)); + + public static RoundedMoney operator -(RoundedMoney money) => From(-money.Value); +} + +/// Bitwise operators by hand, which TypeKit never generates: ~ keeps three bits. +public readonly partial record struct Permissions : IValue +{ + public static Permissions operator |(Permissions left, Permissions right) => From(left.Value | right.Value); + + public static Permissions operator ~(Permissions permissions) => From(~permissions.Value & 7); +} + +public enum AllowanceFault { Negative } + +/// A validated value object, which gets no generated operator: its + is always its own. +public readonly partial record struct Allowance : IValidatedValue +{ + public static Result Create(int value) => value >= 0 ? new Allowance(value) : Result.Error(AllowanceFault.Negative); + + public static Allowance operator +(Allowance left, Allowance right) => FromKnownGood(left.Value + right.Value); +} diff --git a/test/CodoMetis.TypeKit.Generators.Tests/ArithmeticTests.cs b/test/CodoMetis.TypeKit.Generators.Tests/ArithmeticTests.cs index d4a1cb8d..7055136e 100644 --- a/test/CodoMetis.TypeKit.Generators.Tests/ArithmeticTests.cs +++ b/test/CodoMetis.TypeKit.Generators.Tests/ArithmeticTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using System.Reflection; +using CodoMetis.TypeKit.CompilerServices; using CodoMetis.TypeKit.Generators.Probes; using CodoMetis.TypeKit.ValueObjects; @@ -195,6 +196,29 @@ public void A_validated_value_object_keeps_its_own_operator_and_gets_none() Operators(typeof(Score), "op_CheckedAddition").ShouldBeEmpty(); } + /// + /// Every generated operator carries , which an EF Core query + /// translates as the wrapped type's arithmetic, and none written by hand does: the query refuses those. + /// + [Fact] + public void A_generated_operator_is_marked_as_the_wrapped_types_arithmetic_and_a_hand_written_one_is_not() + { + static bool Marked(MethodInfo method) => method.GetCustomAttribute() is not null; + + var generated = typeof(ProbeMoney).GetMethods(BindingFlags.Public | BindingFlags.Static).Where(method => method.IsSpecialName && method.Name.StartsWith("op_", StringComparison.Ordinal) + && method.Name is not ("op_Equality" or "op_Inequality" or "op_LessThan" or "op_LessThanOrEqual" or "op_GreaterThan" or "op_GreaterThanOrEqual")) + .ToList(); + + generated.Select(method => method.Name).Distinct().Order().ShouldBe( + ["op_Addition", "op_CheckedAddition", "op_CheckedDivision", "op_CheckedMultiply", "op_CheckedSubtraction", "op_CheckedUnaryNegation", + "op_Division", "op_Modulus", "op_Multiply", "op_Subtraction", "op_UnaryNegation", "op_UnaryPlus"]); + generated.Where(method => !Marked(method)).Select(method => method.ToString()).ShouldBeEmpty(); + + Operators(typeof(RoundedMoney), "op_Subtraction").ShouldHaveSingleItem().ShouldSatisfyAllConditions(method => Marked(method).ShouldBeTrue()); + Operators(typeof(RoundedMoney), "op_Addition").ShouldHaveSingleItem().ShouldSatisfyAllConditions(method => Marked(method).ShouldBeFalse()); + Operators(typeof(Score), "op_Addition").ShouldHaveSingleItem().ShouldSatisfyAllConditions(method => Marked(method).ShouldBeFalse()); + } + /// No interface declared, nothing generated: the arithmetic is opt-in. [Fact] public void A_value_object_that_declares_no_operator_interface_gets_none() diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 22158fe5..71f06db0 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1648,6 +1648,45 @@ "ArithmeticQueryTests" ] }, + { + "id": "C28-ef-own-operator-rejected", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "A database can't run an operator you write, so an EF Core query that uses one is rejected, naming the value object", + "claim": "A database can't run an operator you write, so an EF Core query that uses one is rejected, naming the value object", + "oracle": "exception", + "surfaces": [ + "TranslatedAsWrappedArithmeticAttribute.*" + ], + "heldBy": [ + "HandWrittenArithmeticTests" + ] + }, + { + "id": "C28-ef-own-operator-throws", + "source": "docs/value-objects.md#add-subtract-and-scale", + "quote": "An operator you wrote yourself can't run there, so a query that uses it throws, naming the value object", + "claim": "An operator you wrote yourself can't run there, so a query that uses it throws, naming the value object", + "oracle": "exception", + "surfaces": [ + "TranslatedAsWrappedArithmeticAttribute.*" + ], + "heldBy": [ + "HandWrittenArithmeticTests" + ] + }, + { + "id": "C28-generated-operators-marked", + "source": "src/CodoMetis.TypeKit.Generators/README.md#write-your-own-members", + "quote": "The generated ones carry `[TranslatedAsWrappedArithmetic]`, which tells the query they are the underlying type's arithmetic.", + "claim": "The generated ones carry `[TranslatedAsWrappedArithmetic]`, which tells the query they are the underlying type's arithmetic.", + "oracle": "claim", + "surfaces": [ + "TranslatedAsWrappedArithmeticAttribute.*" + ], + "heldBy": [ + "ArithmeticTests" + ] + }, { "id": "C28-nothing-unless-declared", "source": "docs/value-objects.md#add-subtract-and-scale", @@ -2395,6 +2434,45 @@ "SqlServerQueryVariantTests" ] }, + { + "id": "E18-own-operator-over-passed-values", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", + "quote": "An operator over values you pass in, which EF computes before the query, stays allowed.", + "claim": "An operator over values you pass in, which EF computes before the query, stays allowed.", + "oracle": "claim", + "surfaces": [ + "TranslatedAsWrappedArithmeticAttribute.*" + ], + "heldBy": [ + "HandWrittenArithmeticTests" + ] + }, + { + "id": "E18-own-operator-query-filter", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", + "quote": "A query filter that uses one fails when the model is built.", + "claim": "A query filter that uses one fails when the model is built.", + "oracle": "exception", + "surfaces": [ + "TranslatedAsWrappedArithmeticAttribute.*" + ], + "heldBy": [ + "HandWrittenArithmeticTests" + ] + }, + { + "id": "E18-own-operator-throws", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", + "quote": "So a query that uses an arithmetic or bitwise operator you wrote on a value object throws an `InvalidOperationException` naming the value object.", + "claim": "So a query that uses an arithmetic or bitwise operator you wrote on a value object throws an `InvalidOperationException` naming the value object.", + "oracle": "exception", + "surfaces": [ + "TranslatedAsWrappedArithmeticAttribute.*" + ], + "heldBy": [ + "HandWrittenArithmeticTests" + ] + }, { "id": "G01-a-version-7-guid", "source": "src/CodoMetis.TypeKit.Generators/README.md#declare-a-value-object", From 41f01552409ec380cbea187880e4ab80e3dc095a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 14:46:09 +0100 Subject: [PATCH 2/3] fix: a value object's hand-written && and || are refused in an EF Core 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 --- CHANGELOG.md | 2 +- ...-written-operator-is-refused-in-a-query.md | 8 ++-- design/ef-core.md | 2 +- .../README.md | 2 +- .../ValueObjectArithmetic.cs | 7 +++ .../HandWrittenArithmeticTests.cs | 48 ++++++++++++++++++- test/oracles/claims.json | 4 +- 7 files changed, 63 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b7ca35..94bbbe11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ section is the GitHub release's notes. ### Fixed -- EF Core: a query that uses an arithmetic or bitwise operator a value object declares by hand throws, naming the value object, where the database ran its own arithmetic and skipped the operator; a query filter fails when the model is built ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). +- EF Core: a query that uses an arithmetic or bitwise operator a value object declares by hand, `&&` and `||` included, throws, naming the value object, where the database ran its own arithmetic and skipped the operator; a query filter fails when the model is built ([Queries](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries)). ## 2.3.0 — 2026-10-29 diff --git a/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md b/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md index da5c0d09..57c15304 100644 --- a/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md +++ b/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md @@ -29,9 +29,11 @@ refused in a query, naming the value object and `.Value`.** not, which fails when the model is built. An operator over the context's own values in a filter runs in memory, as written, and stays allowed. - **Which.** `+`, `-`, `*`, `/`, `%`, unary `-` and `+`, their checked forms, `&`, `|`, `^`, `<<`, `>>`, `~` and - `!`: the operators EF translates as the database's own. Comparison is decision 57's, equality the record's. A - conversion operator is left to EF: `implicit operator string(Name n) => n.Value` is common, and translates to what - it does. + `!`, and `&&` and `||`, whose nodes carry a value object's `&` or `|` where it declares `operator true` and + `false`: the operators EF translates as the database's own. Left out, `&&` and `||` of a `Switch` over `bool` read + true where its operators answer false, unrefused (an outside review of #189). A condition over `operator true` is a + call of `op_True`, which EF runs in memory. Comparison is decision 57's, equality the record's. A conversion + operator is left to EF: `implicit operator string(Name n) => n.Value` is common, and translates to what it does. - **A refusal in a minor.** A query with a hand-written operator that ran before now throws. It did not return what the code says. Decision 34 holds the build's diagnostics, not a query's answer, and a silent wrong answer is a defect, fixed in the release that finds it, as the refusal of a hand-written `CompareTo` was. diff --git a/design/ef-core.md b/design/ef-core.md index a5ce81b0..df1003fc 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -64,7 +64,7 @@ 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, naming the value object (#187). A conversion operator is left to EF. + declares, `&&` and `||` over its `&` and `|` included, naming the value object (#187). A conversion operator is left to EF. - **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 diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index 36e21b82..d9447ee0 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -218,7 +218,7 @@ instead, which works everywhere and may add a cast: - **Your own operator can't run in the database.** EF reads `o.Price + o.Tax` as the database's own addition of the two columns and never calls your `+`. Whatever your operator does beyond that, such as rounding, would be skipped. So a query that uses an arithmetic or bitwise operator you wrote on a - value object throws an `InvalidOperationException` naming the value object. Compute it from `.Value` + value object, `&&` and `||` included, throws an `InvalidOperationException` naming the value object. Compute it from `.Value` where the underlying type's arithmetic is what you mean (`o.Price.Value + o.Tax.Value`), or do it in memory after the query. A query filter that uses one fails when the model is built. An operator over values you pass in, which EF computes before the query, stays allowed. The operators TypeKit diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectArithmetic.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectArithmetic.cs index 49c23cfc..00e619c6 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectArithmetic.cs +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectArithmetic.cs @@ -117,6 +117,11 @@ private void Check(MethodInfo? method, Expression node) /// The operators a value object can declare and EF translates as the database's own: arithmetic, bitwise and /// shift. Comparison is 's, equality is the record's, and a conversion is left to EF. /// + /// + /// && and || of a value object with operator true and false are their own nodes, + /// whose method is its & or |: left out, Flag && Flag read true where the operators + /// answer false, and nothing was refused. + /// private static string? Operator(ExpressionType type) => type switch { ExpressionType.Add or ExpressionType.AddChecked => "+", @@ -128,6 +133,8 @@ private void Check(MethodInfo? method, Expression node) ExpressionType.UnaryPlus => "+", ExpressionType.And => "&", ExpressionType.Or => "|", + ExpressionType.AndAlso => "&&", + ExpressionType.OrElse => "||", ExpressionType.ExclusiveOr => "^", ExpressionType.LeftShift => "<<", ExpressionType.RightShift => ">>", diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenArithmeticTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenArithmeticTests.cs index 36a7ba9f..d319db1f 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenArithmeticTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/HandWrittenArithmeticTests.cs @@ -32,6 +32,10 @@ public sealed class HandWrittenArithmeticTests : IDisposable ["+ of a validated one"] = db => db.Lines.Where(l => l.Allowance + l.Allowance == Allowance.FromKnownGood(4)).ToList(), ["+ over a navigation"] = db => db.Ledgers.Where(g => g.Lines.Any(l => l.Price + l.Tax > Limit)).ToList(), ["+ in OrderBy"] = db => db.Lines.OrderBy(l => l.Price + l.Tax).ToList(), + ["&& in a projection"] = db => db.Lines.Select(l => l.Switch && l.Switch).ToList(), + ["|| in a projection"] = db => db.Lines.Select(l => l.Switch || l.Switch).ToList(), + ["&& in a filter"] = db => db.Lines.Where(l => (l.Switch && l.Switch).Value).ToList(), + ["|| in a filter"] = db => db.Lines.Where(l => (l.Switch || l.Switch).Value).ToList(), }; public static TheoryData RefusedCases => [.. Refused.Keys]; @@ -46,7 +50,7 @@ public void A_hand_written_operator_in_a_query_is_refused_naming_the_value_objec var refused = Should.Throw(() => Refused[name](db)); - refused.Message.ShouldMatch(@"^(RoundedMoney|Permissions|Allowance)'s operator \S+ is used in a query, but it is written by hand"); + refused.Message.ShouldMatch(@"^(RoundedMoney|Permissions|Allowance|Switch)'s operator \S+ is used in a query, but it is written by hand"); refused.Message.ShouldContain(".Value"); } @@ -61,6 +65,9 @@ public void What_the_database_can_compute_as_written_stays_allowed() // Rounded in memory to 1.01; added in the database it would be 1.006, and no row. db.Lines.Where(l => l.Price == RoundedMoney.From(1.005m) + RoundedMoney.From(0.001m)).Select(l => l.Id).ToList().ShouldBe([2]); + + // A condition over operator true is a call of op_True, no operator node: EF runs it in memory, as written. + db.Lines.OrderBy(l => l.Id).Select(l => l.Switch ? 1 : 2).ToList().ShouldBe([1, 1]); } [Fact] @@ -74,6 +81,17 @@ public void A_query_filter_with_a_hand_written_operator_is_refused_when_the_mode refused.Message.ShouldStartWith("RoundedMoney's operator + is used in the query filter of Line, but it is written by hand"); } + [Fact] + public void A_query_filter_with_a_hand_written_conditional_operator_is_refused_when_the_model_is_built() + { + _connection.Open(); + using var db = new SwitchFilteredDb(new DbContextOptionsBuilder().UseSqlite(_connection).UseTypeKit().Options); + + var refused = Should.Throw(() => db.Model); + + refused.Message.ShouldStartWith("Switch's operator || is used in the query filter of Line, but it is written by hand"); + } + [Fact] public void A_named_query_filter_with_a_hand_written_operator_is_refused_naming_it() { @@ -117,7 +135,7 @@ private LedgerDb Seeded() private static Line NewLine(int id, decimal price, decimal tax) => new() { Id = id, LedgerId = 1, Price = RoundedMoney.From(price), Tax = RoundedMoney.From(tax), - Access = Permissions.From(2), Allowance = Allowance.FromKnownGood(2), + Access = Permissions.From(2), Allowance = Allowance.FromKnownGood(2), Switch = Switch.From(true), }; public sealed class Line @@ -135,6 +153,8 @@ public sealed class Line public Permissions Access { get; set; } public Allowance Allowance { get; set; } + + public Switch Switch { get; set; } } public sealed class Ledger @@ -166,6 +186,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } + private sealed class SwitchFilteredDb(DbContextOptions options) : LedgerDb(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().HasQueryFilter(l => (l.Switch || l.Switch).Value); + } + } + private sealed class NamedFilteredDb(DbContextOptions options) : LedgerDb(options) { protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -221,3 +250,18 @@ public enum AllowanceFault { Negative } public static Allowance operator +(Allowance left, Allowance right) => FromKnownGood(left.Value + right.Value); } + +/// +/// A switch with its own logic by hand: && and || call its & and |, which here +/// never switch on, where the database's AND and OR of the column would. +/// +public readonly partial record struct Switch : IValue +{ + public static Switch operator &(Switch left, Switch right) => From(false); + + public static Switch operator |(Switch left, Switch right) => From(false); + + public static bool operator true(Switch value) => value.Value; + + public static bool operator false(Switch value) => !value.Value; +} diff --git a/test/oracles/claims.json b/test/oracles/claims.json index dd1d7d2f..96e63687 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -2463,8 +2463,8 @@ { "id": "E18-own-operator-throws", "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries", - "quote": "So a query that uses an arithmetic or bitwise operator you wrote on a value object throws an `InvalidOperationException` naming the value object.", - "claim": "So a query that uses an arithmetic or bitwise operator you wrote on a value object throws an `InvalidOperationException` naming the value object.", + "quote": "So a query that uses an arithmetic or bitwise operator you wrote on a value object, `&&` and `||` included, throws an `InvalidOperationException` naming the value object.", + "claim": "So a query that uses an arithmetic or bitwise operator you wrote on a value object, `&&` and `||` included, throws an `InvalidOperationException` naming the value object.", "oracle": "exception", "surfaces": [ "TranslatedAsWrappedArithmeticAttribute.*" From 154f2b2c63f416beb1a1b6dd6c320c465a476df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 30 Oct 2026 17:25:01 +0100 Subject: [PATCH 3/3] docs: renumber the hand-written-operator decision record from 0122 to 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 --- ...> 0123-a-hand-written-operator-is-refused-in-a-query.md} | 6 +++--- design/adr/README.md | 4 ++-- design/aspects.md | 2 +- design/ef-core.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) rename design/adr/{0122-a-hand-written-operator-is-refused-in-a-query.md => 0123-a-hand-written-operator-is-refused-in-a-query.md} (95%) diff --git a/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md b/design/adr/0123-a-hand-written-operator-is-refused-in-a-query.md similarity index 95% rename from design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md rename to design/adr/0123-a-hand-written-operator-is-refused-in-a-query.md index 57c15304..4d683a76 100644 --- a/design/adr/0122-a-hand-written-operator-is-refused-in-a-query.md +++ b/design/adr/0123-a-hand-written-operator-is-refused-in-a-query.md @@ -1,18 +1,18 @@ -# 0122. A hand-written operator is refused in a query, and a generated one is marked +# 0123. A hand-written operator is refused in a query, and a generated one is marked Date: 2026-10-30 Status: accepted Context: [#187](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/187), under the 2.4.0 epic [#177](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/177); [EF Core](../ef-core.md); refines -[decision 121](0121-arithmetic-operators-are-opt-in-and-a-hand-written-one-is-a-seam.md), beside +[decision 122](0122-arithmetic-operators-are-opt-in-and-a-hand-written-one-is-a-seam.md), beside [decision 57](0057-hand-written-ordering-refused-in-query.md). EF translates an operator node as the database's arithmetic over its operands, and never calls the operator's method. A hand-written operator therefore ran as the database's arithmetic, not as written: with its `+` rounding to cents, `Price + Tax > 1.006` over 1.005 and 0.001 became `ef_compare(ef_add("l"."Price", "l"."Tax"), @limit) > 0` on SQLite and kept no row where memory keeps the one, and `Select(l => l.Price + l.Tax)` read 1.006 where memory -reads 1.01 (measured with EF Core 10.0.12, the refusal taken out). Decision 121 keeps such an operator as a seam, so +reads 1.01 (measured with EF Core 10.0.12, the refusal taken out). Decision 122 keeps such an operator as a seam, so the build cannot stop it; the query must. **Any arithmetic, bitwise or shift operator a value object declares without `[TranslatedAsWrappedArithmetic]` is diff --git a/design/adr/README.md b/design/adr/README.md index 7100158c..501ed1d7 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -127,5 +127,5 @@ per area ([design](../plan.md)) link to the records they rest on. | [0118](0118-an-error-may-learn-a-form-only-new-api-can-write.md) | An Error may report, in a minor version, a form that only an API new in that version can write, since no build that worked before contains it: CMTK0003's dropped assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask` in 2.3.0, its `ConfigureAwait`, `WaitAsync` and branch forms (#152) included, known by the package's assembly; the forms freeze at the tag; refines decision 34; one exception the maintainer took into 2.3.0, a dropped `Task.WhenAny` of tasks of results (#159) | 2026-10-29 | accepted; widened by [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | | [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 | -| [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 [0122](0122-a-hand-written-operator-is-refused-in-a-query.md) | -| [0122](0122-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 121 | 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) | +| [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 | diff --git a/design/aspects.md b/design/aspects.md index 888966a2..3b9cfa37 100644 --- a/design/aspects.md +++ b/design/aspects.md @@ -18,7 +18,7 @@ stated. A hand-written operator for the same operands, or an explicit implementation of the interface, its own, a base record's or an interface's, is a seam: kept with its `checked` form, never CMTK1011, so code that wrote them on 2.3 builds unchanged. Each generated operator carries `[TranslatedAsWrappedArithmetic]`, which is - how an EF Core query tells it from a hand-written one ([decision 122](adr/0122-a-hand-written-operator-is-refused-in-a-query.md)). + how an EF Core query tells it from a hand-written one ([decision 123](adr/0123-a-hand-written-operator-is-refused-in-a-query.md)). - **Declarations that cannot be generated** are errors, so no type is left half-generated: CMTK1000/1001/1002 (not `partial`, not a record, a struct not `readonly`), CMTK1003 (more than one marker), CMTK1004 (a validated marker whose first type argument is another type), CMTK1005 (a diff --git a/design/ef-core.md b/design/ef-core.md index df1003fc..7d45a38e 100644 --- a/design/ef-core.md +++ b/design/ef-core.md @@ -59,7 +59,7 @@ 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 122](adr/0122-a-hand-written-operator-is-refused-in-a-query.md)).** + - **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)).** 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