Skip to content

fix: refuse ordering a value object with its own CompareTo that a projection boxed first - #217

Merged
CaffeinatedCoder merged 3 commits into
release/2.3.1from
fix/ef-boxed-ordering-refused-2-3-1
Oct 31, 2026
Merged

CaffeinatedCoder merged 3 commits into
release/2.3.1from
fix/ef-boxed-ordering-refused-2-3-1

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #206

Problem

Select(r => (object)r.Rank).Max(), .Min(), .Order(), .OrderDescending() and .OrderBy(x => x) over a value object whose ordering is its own CompareTo were not refused: EF dropped the cast and answered the column's order, Max 3 and Order 1, 2, 3 where memory answers 1 and 3, 2, 1 for a descending CompareTo (measured on SQLite). HandWrittenOrderingInterceptor's Finder judged an ordering operator by its declared type, object here, and looked under a conversion only in a key selector's body (8f6af8b). The same held for a key that reads the boxed value back through ?? or a member of a projection: OrderBy(r => (object?)r.Optional ?? r.Rank) and Select(r => new { Key = (object)r.Rank }).OrderBy(a => a.Key) answered 1, 2, 3 where memory answers 3, 2, 1 (reported by an outside review, measured here at 3d2abfd). And through other operators' lambdas: a grouping's boxed key or elements, GroupBy's, Join's and SelectMany's result selectors, and a subquery's boxed First() as a key, all answering the column's order at d573eef.

Change

The Finder reads the types a sequence's elements have under their boxing: a Select's body unconverted, followed through the operators that pass elements on (Where, Take, Distinct, the orderings, Cast/OfType, Concat and its kind). An operator without a key, or whose key is the element itself, is refused by those types. A key or selector body is read through both sides of a ?? and a ?:, and a member of a projection is followed back to the expression its new or member initializer set it from, in the same lambda or in the Select that built the elements, through nested projections (d573eef). Every LINQ operator binds its lambdas' parameters to what they stand for (the source's elements, each side of a join, SelectMany's collection, GroupBy's key and elements); a grouping's Key reads its key selector, First and its kind read their source's element, and the visitor carries the bindings into nested lambdas, so Select(g => g.Max()) is refused too (8562de6). Query filters go through the same Finder, so such a filter is refused when the model is built. The EF Core README says the cast changes nothing, through ??, a projection's member, a grouping, a join or a subquery too (claim E05-ordering-boxed); changelog line under 2.3.1.

Tests

  • QueryShapes' Op axis gains OrderBoxed, OrderByItselfBoxed, MinBoxed and MaxBoxed over every wrapped kind, converter and accessor of the column and nullable sites, green on SQLite, PostgreSQL's translation and SQL Server.
  • HandWrittenOrderingTests gains explicit rows: Max, Min, Order, OrderDescending, OrderBy(x => x), a record class, a navigation's Enumerable.Max (which threw a NullReferenceException inside EF before), a Where and Take between the box and the operator, and a query filter.
  • Reverting the source fails 33 of 11,095 (24 catalogue rows, 9 explicit).
  • d573eef: the Op axis gains OrderByCoalescedBoxed, OrderByProjectedBoxed and MaxProjectedBoxed; explicit rows for a ?? and a ?: key, an anonymous projection's member under OrderBy, Max(a => a.Key) and Select(a => a.Key).Max(), a nested member, a member-init type, a Where in between, a ?? inside the projection, a navigation (a NullReferenceException inside EF before) and a query filter; controls over .Value and a generated ordering still translate. Reverting its source fails 29 (18 catalogue rows, 11 explicit). EF Core tests green (23,341 passed, SQL Server on azure-sql-edge), conventions green.
  • 8562de6: the Op axis gains OrderByGroupKeyBoxed and OrderByJoinedBoxed; explicit rows for a grouping's boxed key and elements, GroupBy's, Join's, GroupJoin's and SelectMany's result selectors, SelectMany under Max, and a subquery's and a navigation's boxed First(); controls through a grouping, a join and a subquery over .Value or a generated ordering still translate. Reverting its source fails 21 (12 catalogue rows, 9 explicit). EF Core tests green (24,258 passed), conventions green.
  • Full suite green on a merge of this branch with A closed set over a base class that declares a generated member fails with a Metalama error, or its ToString() is replaced #205's and CMTK0005 and CMTK0006 miss a type parameter constrained to a value object by new(), which CMTK0001 reports #207's fix branches (33,026 passed, SQL Server on azure-sql-edge).

Found by a sweep of release/2.3.1 @ dbdd44e for the defect class of #191-#197.

🤖 Generated with Claude Code

…jection boxed first

Select(r => (object)r.Rank).Max(), .Min(), .Order(), .OrderDescending()
and .OrderBy(x => x) over a value object whose ordering is its own
CompareTo were not refused: EF dropped the cast and answered the
column's order, Max 3 and Order 1, 2, 3 where memory answered 1 and
3, 2, 1 for a descending CompareTo (measured on SQLite). The Finder
judged an ordering operator by its declared type, object here, and
looked under a conversion only in a key selector's body, which
8f6af8b added for OrderBy(r => (object)r.Rank).

The Finder now reads the types a sequence's elements have under their
boxing: a Select's body unconverted, followed through the operators
that pass elements on (Where, Take, Distinct, the orderings, Cast and
OfType, Concat and its kind). An operator without a key, or whose key
is the element itself, is refused by those types. Query filters go
through the same Finder, so a filter that does this is refused when
the model is built. The README says the cast changes nothing.

Input class (EFT): an ordering operator over a value object, by where
the conversion sits. Catalogue: QueryShapes' Op axis gains OrderBoxed,
OrderByItselfBoxed, MinBoxed and MaxBoxed over every wrapped kind,
converter and accessor of the column and nullable sites (green on
SQLite, PostgreSQL's translation and SQL Server). Explicit rows hold
OrderDescending, a record class, a navigation's Enumerable.Max, a Where
and Take between the box and the operator, and a query filter. Sibling
found: over a navigation, the same query threw a NullReferenceException
inside EF rather than answering; it is refused before EF now.

Reverting the source fails 33 of 11095 (24 catalogue rows, 9 explicit).

Refs #206

Defect-Class: EFT
Found-By: CA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder CaffeinatedCoder added this to the 2.3.1 milestone Oct 30, 2026
@CaffeinatedCoder CaffeinatedCoder added bug Something isn't working silent Compiles or answers and does less than it appears to severity/medium Inconsistency or failure in a narrow scenario package/efcore Affects CodoMetis.TypeKit.EntityFrameworkCore area/value-objects Value-object contracts and what the aspects generate for them labels Oct 30, 2026
…rojection's member

OrderBy(r => (object?)r.Optional ?? r.Rank) and
Select(r => new { Key = (object)r.Rank }).OrderBy(a => a.Key) over a
value object whose ordering is its own CompareTo were not refused: EF
dropped the cast and answered 1, 2, 3 where memory answered 3, 2, 1 for
a descending CompareTo (measured on SQLite at 3d2abfd). The Finder looked
under a conversion only at the top of a key's body or a Select's, so a
?? over the box, or a member of a projection that holds it, read as
object.

The Finder now reads the types a key or selector body yields under its
boxing: both sides of a ?? and a ?:, the elements for the lambda's
parameter (as before), and for a member of a projection, the expression
its new or member initializer set it from, found in the same lambda or
in the Select that built the elements, through nested projections and
the operators that pass elements on. The README, its claim and the
changelog line name both shapes.

Input class (EFT): an ordering operator over a value object, by where
the boxing sits and how the key reads it back. Siblings found by the
same probe and fixed here (VA): Max(a => a.Key) and
Select(a => a.Key).Max() over the projection, a nested anonymous member,
a member-init type, a Where between the projection and the ordering, a
?? inside the projection; each answered the column's order. A ?: key
failed translation on SQLite and is refused before it now. Over a
navigation the projected Max threw a NullReferenceException inside EF;
it is refused before EF. Catalogue: QueryShapes' Op axis gains
OrderByCoalescedBoxed, OrderByProjectedBoxed and MaxProjectedBoxed over
every kind, converter and accessor of the column and nullable sites.
Explicit rows hold the anonymous shapes, the navigation and a query
filter; controls over .Value and a generated ordering still translate.

Reverting the source fails 29: 18 catalogue rows, 11 explicit.

Refs #206

Defect-Class: EFT
Found-By: OR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…query's First carries

Eight shapes still answered the column's order at d573eef, 1, 2, 3 (Max
3) where memory answers 3, 2, 1 (Max 1) for a descending CompareTo,
measured on SQLite: a grouping's boxed key under OrderBy(g => g.Key), a
grouping's boxed elements under g.Max(), GroupBy's, Join's and
SelectMany's result selectors holding the boxed value, SelectMany of
boxed values under Max(), and a subquery's or a navigation's boxed
First() as a key. The Finder followed a lambda's parameter only through
Select and the operators that pass elements on, and visited a nested
operator, g.Max(), knowing nothing of its source.

The Finder now knows what each lambda parameter stands for. Every LINQ
operator binds its lambdas' parameters: to the source's elements for a
selector, key or predicate; to each side's elements for Join and
GroupJoin (the inner side whole for GroupJoin); to the collection's
elements for SelectMany's result selector; to the key and the grouped
elements for GroupBy's. A grouping's Key reads its key selector, and
First, Single, Last, ElementAt and their OrDefault forms read the element
of their source. The visitor carries these bindings into nested lambdas,
so an operator inside one reads what its source was built from. The
README, its claim and the changelog line name the new shapes.

Input class (EFT): an ordering operator over a value object, by the
operator whose lambda carries the boxed value to it. Sibling found by
the same rows (VA): a GroupJoin result reading cs.First().Rank, refused
before EF now. Loud and left as it is: a Tuple.Create projection, which
EF does not translate. Catalogue: QueryShapes' Op axis gains
OrderByGroupKeyBoxed and OrderByJoinedBoxed over every kind, converter
and accessor of the column and nullable sites. Controls over .Value and
a generated ordering, through a grouping, a join and a subquery, still
translate.

Reverting the source fails 21: 12 catalogue rows, 9 explicit.

Refs #206

Defect-Class: EFT
Found-By: RS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/value-objects Value-object contracts and what the aspects generate for them bug Something isn't working package/efcore Affects CodoMetis.TypeKit.EntityFrameworkCore severity/medium Inconsistency or failure in a narrow scenario silent Compiles or answers and does less than it appears to

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant