Fix/8262 orderby props nested selector#9840
Conversation
|
I added a third commit that fixes a similar case when a sort key itself contains a nested Sorting: new SortDefinition<Brand>()
.AddAscending(t => t.Products.OrderBy(p => p.Price).First().Price)
.AddAscending(t => t.Id)The visitor now tracks the key lambda's parameter and collects only members rooted directly at it. I'm sure queries could still be constructed that break the paging expression visitors, but this fix is reasonably scoped and covers the common cases. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds regression coverage and refactors pagination-related expression visitors to avoid traversing nested lambdas (e.g., OrderBy inside projections/predicates), preventing accidental hoisting/collection of non-pagination order keys during cursor generation.
Changes:
- Added new paging regression tests plus snapshots for NET8/NET9/NET10 target frameworks.
- Introduced
QueryChainVisitorto restrict visitors to the top-level LINQ method chain (excluding lambda bodies by default). - Updated visitors/parsers used by paging (cursor parsing, order reversing, order property extraction, order removal) to derive from
QueryChainVisitor.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/PagingHelperTests.cs | Adds regression tests for nested OrderBy in selector/predicate/order keys and cursor creation. |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Selector_With_Nested_OrderBy_Forward_Paging_NET8_0.md | Snapshot for SQL/expression when nested OrderBy exists in selector (NET8). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Selector_With_Nested_OrderBy_Forward_Paging_NET9_0.md | Snapshot for SQL/expression when nested OrderBy exists in selector (NET9). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Selector_With_Nested_OrderBy_Forward_Paging_NET10_0.md | Snapshot for SQL/expression when nested OrderBy exists in selector (NET10). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Selector_With_Nested_OrderBy_Does_Not_Hoist_Inner_Order_Properties_NET8_0.md | Snapshot verifying inner order properties are not hoisted (NET8). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Selector_With_Nested_OrderBy_Does_Not_Hoist_Inner_Order_Properties_NET9_0.md | Snapshot verifying inner order properties are not hoisted (NET9). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Selector_With_Nested_OrderBy_Does_Not_Hoist_Inner_Order_Properties_NET10_0.md | Snapshot verifying inner order properties are not hoisted (NET10). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Predicate_With_Nested_OrderBy_Forward_Paging_NET8_0.md | Snapshot for nested OrderBy in predicate forward paging (NET8). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Predicate_With_Nested_OrderBy_Forward_Paging_NET9_0.md | Snapshot for nested OrderBy in predicate forward paging (NET9). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Predicate_With_Nested_OrderBy_Forward_Paging_NET10_0.md | Snapshot for nested OrderBy in predicate forward paging (NET10). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Predicate_With_Nested_OrderBy_Backward_Paging_NET8_0.md | Snapshot for nested OrderBy in predicate backward paging (NET8). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Predicate_With_Nested_OrderBy_Backward_Paging_NET9_0.md | Snapshot for nested OrderBy in predicate backward paging (NET9). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_Predicate_With_Nested_OrderBy_Backward_Paging_NET10_0.md | Snapshot for nested OrderBy in predicate backward paging (NET10). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_OrderKey_With_Nested_OrderBy_Forward_Paging_NET8_0.md | Snapshot for nested OrderBy inside sort key forward paging (NET8). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_OrderKey_With_Nested_OrderBy_Forward_Paging_NET9_0.md | Snapshot for nested OrderBy inside sort key forward paging (NET9). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_OrderKey_With_Nested_OrderBy_Forward_Paging_NET10_0.md | Snapshot for nested OrderBy inside sort key forward paging (NET10). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_OrderKey_With_Nested_OrderBy_Second_Page_NET8_0.md | Snapshot verifying second-page keyset predicate translation (NET8). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_OrderKey_With_Nested_OrderBy_Second_Page_NET9_0.md | Snapshot verifying second-page keyset predicate translation (NET9). |
| src/GreenDonut/test/GreenDonut.Data.EntityFramework.Tests/snapshots/PagingHelperTests.QueryContext_OrderKey_With_Nested_OrderBy_Second_Page_NET10_0.md | Snapshot verifying second-page keyset predicate translation (NET10). |
| src/GreenDonut/src/GreenDonut.Data/Expressions/QueryChainVisitor.cs | Introduces a base visitor that skips lambda bodies during traversal. |
| src/GreenDonut/src/GreenDonut.Data/Cursors/CursorKeyParser.cs | Switches cursor key parsing visitor to QueryChainVisitor to avoid nested lambda traversal. |
| src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ReverseOrderExpressionRewriter.cs | Switches reverse-order rewriter to QueryChainVisitor so only top-level order ops are rewritten. |
| src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExtractOrderPropertiesVisitor.cs | Refines order property extraction to avoid collecting from nested lambdas/intermediate results. |
| src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs | Updates order-by removal rewriter to rely on chain-only traversal rather than projection state tracking. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| protected override Expression VisitMember(MemberExpression node) | ||
| { | ||
| if (_isOrderScope) | ||
| // an order key is a lambda. we only collect members rooted at that lambda's own | ||
| // parameter; members rooted on nested lambdas or intermediate results cannot be | ||
| // hoisted into the selector. | ||
| if (_orderKeyParameter is not null && node.Expression == _orderKeyParameter) | ||
| { | ||
| // we only collect members that are within an order method. | ||
| OrderProperties.Add(node); | ||
| } |
There was a problem hiding this comment.
The check runs per-node within a recursive traversal: base.VisitMember(node) descends into node.Expression, so for t.Parent.Child.Id the visitor also fires on t.Parent.Child and t.Parent, and then t.Parent matches, since its Expression is the parameter.
| DisplayName = t.Products | ||
| .OrderByDescending(p => p.Price) | ||
| .ThenBy(p => p.AvailableStock) | ||
| .FirstOrDefault()!.Name |
Fixes #8262 and adds associated tests.
The root cause was the paging expression visitors walking the entire query expression tree and treating every
OrderBy/ThenBythey encountered as a pagination concern, including order operations inside lambda bodies.The first commit addresses this issue in the four location this occurs by overriding
VisitLambda. The second commit moves the duplicated override into a shared base class.