Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ section is the GitHub release's notes.

- Packages: the public surface is checked against 2.3.1's, so nothing 2.3.1 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, `&&` 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.1 — 2026-10-31

### Changed
Expand Down
45 changes: 45 additions & 0 deletions design/adr/0123-a-hand-written-operator-is-refused-in-a-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 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 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 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
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
`!`, 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.

`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.
3 changes: 2 additions & 1 deletion design/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,5 @@ per area ([design](../plan.md)) link to the records they rest on.
| [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-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); a closed set's base class counts as a base record does, its `sealed` `ToString()` the seam and one not sealed refused (#205); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197, #205) | 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 |
| [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 |
5 changes: 3 additions & 2 deletions design/aspects.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
declares, where the wrapped type implements the same interface with the value object replaced by itself,
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, 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.
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 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
Expand Down
6 changes: 6 additions & 0 deletions design/ef-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
convention for query filters, refuses any other arithmetic, bitwise or shift operator a value object
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
Expand Down
4 changes: 2 additions & 2 deletions design/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| Package | Depends on | Metalama | Contents |
|---|---|---|---|
| `CodoMetis.TypeKit` | Analyzers (flows, [Analyzers](analyzers.md)) | **no** | `Option<T>`, `Result<T,TError>`, `Result<TError>` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue<T>`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault<TFault>` and `Fault.Of`, and `Fault<T1, T2>` and `Fault<T1, T2, T3>`, a fault of one of several types ([decision 78](adr/0078-fault-sets-compose-side-by-side.md)); `Patch<T>`, `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<T>`, `Result<T,TError>`, `Result<TError>` and their extensions, all carrying `NotWireTypeJsonConverterFactory` ([Option and Result](option-result.md)); the value-object contracts `IValueObject<,>`, `IPlainValueObject<,>`, `IValue<T>`, `IValidatedValue<,,>`, `IValueObjectMaterializer<,>` ([EF Core](ef-core.md)); the closed-set contracts `IClosedSet<>`, `IFault`, `IFaultSet<>` and `FaultKind`; `Fault<TFault>` and `Fault.Of`, and `Fault<T1, T2>` and `Fault<T1, T2, T3>`, a fault of one of several types ([decision 78](adr/0078-fault-sets-compose-side-by-side.md)); `Patch<T>`, `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()` |
Expand All @@ -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<TSet>`; the plugins, internal |
| `CodoMetis.TypeKit.AspNetCore` | the schema transformers, internal; `FaultProblem<TFault>`, `FaultResponse`, `FaultResponseOptions`, `FaultStatusAttribute<TFault>`, `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)) |
Expand Down
4 changes: 3 additions & 1 deletion docs/value-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions src/CodoMetis.TypeKit.EntityFrameworkCore/DatabaseReads.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Linq.Expressions;

namespace CodoMetis.TypeKit.EntityFrameworkCore;

/// <summary>Whether an expression reads a parameter it does not declare itself, or a query.</summary>
internal sealed class DatabaseReads : ExpressionVisitor
{
private readonly HashSet<ParameterExpression> _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<T>(Expression<T> node)
{
_declared.UnionWith(node.Parameters);

return base.VisitLambda(node);
}

protected override Expression VisitParameter(ParameterExpression node)
{
if (!_declared.Contains(node)) _found = true;

return node;
}
}
Loading
Loading