Repository navigation
fix(query): return the real result of terminals after Select and after an indexed top-N window (#181) - #193
Conversation
…r an indexed top-N window (EntglDb#181) - TryPushDownSelect reports success with a bool and an out value instead of using default(TResult) as "not applicable". For a value-type result (Select(...).Any(), .Max(), .Count(pred), ...) default is false or 0, which the provider returned as the query result. The push-down now only runs when the projection is the whole query. - A scalar terminal after Select on the direct pipeline (e.g. Take(n).Select(...).Count()) runs through EnumerableRewriter. ProjectEnumerable used to return default for a non-sequence result; it now throws if it is ever reached that way. - The OrderBy(indexed).Skip/Take fast path takes its shortcut only for the plain window: Take, or Skip then Take, directly on the ordered collection, followed by nothing but Select or a plain Count/LongCount. Anything else after the window (Sum/Max with a selector, Any/Count with a predicate, First, a scalar after Select, another Skip/Take, Take-then-Skip) is replayed over the first skip+take entries in index order. Before, those operators were ignored or misread: OrderBy(Age).Take(10).Max(p => p.Age) returned 10. A ThenBy right after the window turns the shortcut off. - EnumerableRewriter maps non-generic Queryable overloads (parameterless Sum and Average over int/long/double/decimal/...) instead of failing to find an Enumerable counterpart. SelectTerminalTests compares 55 query shapes against LINQ-to-Objects; 39 of them fail on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks. The #181 fix is right: I compared BLite with LINQ-to-Objects over four datasets (sequential, reversed ObjectIds, relocated documents, empty) for Select(...) followed by Any/Count/First/Max/Min/Sum/Average/Distinct().Count()/Contains, and for terminals after OrderBy(indexed).Take(...), including Take(0), Take(-1), Skip(1000).Take(5) and ThenBy. All match, and most were wrong on main. 39 of the 55 new tests fail on main. Merged with main, full suite 2512 / 0 / 4 skipped; with #180 too, everything passes except the #173 test I already flagged on #180. #183 is complementary.
One change: the window replay is wrong when the query uses the root queryable a second time (BTreeQueryProvider.cs:308-311, TryGetIndexWindow ~L443). EnumerableRewriter replaces every ConstantExpression equal to the root, so the second use is also replaced by the window prefix. With var q = col.AsQueryable(); over 300 documents:
| query | LINQ | this PR | main |
|---|---|---|---|
q.OrderBy(p => p.Age).Take(5).Concat(q).Count() |
305 | 10 | 5 |
...Select(a).Concat(q.Select(a)).Count() |
305 | 10 | 0 |
...Select(a).Union(q.Select(a)).Count() |
50 | 1 | 0 |
Not a regression, but it breaks the invariant the PR states. In TryGetIndexWindow, return false when any argument after the window (other than Arguments[0]) contains a ConstantExpression whose value is an IQueryable; those shapes then take the general path, which already returns 305. Please add these as tests and mention the single-root requirement in the comment at ~L306.
Not for this PR:
- The replay reads
QueryIndexAsync(..., 0, windowEnd), soOrderBy(idx).Skip(100000).Take(10).Sum(sel)deserializes 100,010 documents; readingskip..windowEndand dropping the leadingSkipfrom the replay would avoid that. OrderByDescendingon an index returns tied keys in reverse insertion order, unlike LINQ and the non-indexed path: #210.- While probing I found that the rewriter cache keeps captured variables from the first call (
q.Count(p => p.Age > t)returns the same count for everyt). It's onmaintoo and affects the shapes this PR now routes through the rewriter: #207.
…n after it (EntglDb#181) - TryGetIndexWindow returns false when an operator after the window has an argument holding a queryable (Concat(q), Join(q, ...), Union(q.Select(...))). EnumerableRewriter replaces every use of the root with the window prefix, so that second use read the prefix instead of the collection: q.OrderBy(p => p.Age).Take(5).Concat(q).Count() returned 10 instead of 305. Such queries take the general path. - The comment on the replay states the single-source requirement. - SelectTerminalTests: four shapes that use the root again after the window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry (EntglDb#181) EnumerableRewriter compiled each quoted lambda on its own and passed the delegate as a constant. A lambda that refers to the parameter of an outer lambda, such as the predicate of a subquery, q.Count(x => q.Any(y => y.Age == x.Age + 1)), cannot be compiled on its own and threw InvalidOperationException ("variable 'x' ... is not defined"). The rewriter now passes the unquoted lambda, which is compiled with the enclosing expression. This already failed on the general path; the window replay now routes OrderBy(idx).Take(n).Count(pred) through the rewriter too, where it used to ignore the predicate. SelectTerminalTests covers both, plus a closure subquery after the window, which must read the collection, not the prefix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ond source (EntglDb#181) ExecuteAsyncCore fetched one source for the whole query, filtered by the model's WHERE, and EnumerableRewriter replaced every use of the root with it. BTreeExpressionVisitor also collects Where/Take/... from the other arguments of operators it does not model, including subqueries inside their lambdas. So a second use of the root got the first use's filter, or the other way round: q.Where(p => p.Age < 10).Concat(q).Count() 120, not 360 q.Concat(q.Where(p => p.Age < 10)).Count() 120, not 360 q.Where(p => p.Age == 1).Join(q, ...).Count() 0, not 36 q.Any(x => x.Age == 0 && q.Where(y => y.Age == 49).Any()) false, not true These are on main too. When an operator of the main chain has an argument other than its source that holds a queryable or calls a Queryable operator, the provider now reads the whole collection and replays the query over it. This also covers the index window replay, so the check added to TryGetIndexWindow in the previous commit moves here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. Addressed in three commits: aebaaf2 is the requested change. 515c88f: the general path had the same problem, and it is on
When an operator in the main chain has a non-source argument that holds a queryable or calls a 68d5e89: a query that captures Tests: On #207: it does affect the shapes this PR sends through the rewriter. Each is right on its first run, then repeats that answer when only a captured value changes. For example, Skip-window cost and #210: not touched here. |
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks. Re-checked on the current head merged into main (with #180): the second-source shapes (Concat(q), Select.Concat/Union(q.Select), Join(q, …)) and the general-path filter leaks now match LINQ-to-Objects, and moving the check into HasSecondaryQuery, ahead of both the window and general paths, covers more than the narrower TryGetIndexWindow guard. Keeping lambdas inline in EnumerableRewriter is fine, since the cache key and the closure behaviour are unchanged. I also probed the #180 fast paths with secondary sources (Concat(q).Count/LongCount/Sum/Max, Count(x => q…Any()), Except, Intersect, SelectMany, GroupJoin, Zip) and all match. Full suite on current main with this PR: green. The remaining mismatches I found (Take(n).Where(…) folding, #186) are pre-existing and outside this PR. Approving.
Fixes #181.
Terminals after a
Selectreturnedfalseor0. While testing this I found two more paths that dropped or misread the operators after the part of the query they handle. All three now return what LINQ-to-Objects returns.Changes
1.
TryPushDownSelectno longer usesdefaultas "not applicable" (the bug reported in #181)boolwith the projection in anoutparameter. For a value-type result,default(TResult)isfalseor0, and the provider returned that as the answer:Select(p => p.Age).Any(),.Max(),.Sum(),.Count(pred),.First(pred),Where(...).Select(...).Any(), and so on.!HasComplexOperators).2. A scalar terminal after
Selecton the direct pipelineTake(n).Select(...).Count(),Skip(n).Select(...).LongCount()andOrderBy(...).Select(...).Take(n).Count()reachedProjectEnumerable, which returneddefaultfor any non-sequence result. They now run throughEnumerableRewriter, like the other shapes the pipeline doesn't model.ProjectEnumerablethrows if it is ever reached with a non-sequence result again, instead of returningdefault.3. The
OrderBy(indexed).Skip/Takefast pathOrderByand aTake, and then ignored everything after the window. For example,OrderBy(p => p.Age).Take(10).Max(p => p.Age)returned 10 (the count), and.Any(p => p.Age > 5)returnedtruewhen no document in the window matched.Take(n)orSkip(s).Take(n)directly onOrderByover the collection, followed only bySelector a plainCount()/LongCount().skip + takedocuments is replayed throughEnumerableRewriterover those documents, read from the index in order. That covers aggregates with a selector, terminals with a predicate,First, a scalar afterSelect, a furtherSkip/Take, andTake(...).Skip(...).OrderByis a stable sort, so the replay keeps the index order and selects the same window.ThenBydirectly afterOrderByno longer takes the shortcut, because it changes which documents fall inside the window. The same goes for a secondOrderBythat the model would have picked up instead of the first.4.
EnumerableRewriterand non-generic overloads. ParameterlessSum()/Average()overint,long,double,decimal?and the rest are non-generic methods.MakeGenericMethodthrew on every candidate, soSelect(p => p.Age).Sum()failed withArgumentExceptiononce (1) stopped hiding it. Non-generic candidates are now used as they are.Tests
SelectTerminalTestsruns 55 query shapes over 300 documents (Age = i % 50, withidx_Age) and compares each result with LINQ-to-Objects over the same list. 39 of them fail onmain:Selectfollowed byAny/All/Count(pred)/LongCount(pred)/Max/Min/Sum/Average/Contains/First(pred)/Distinct().Count(), with and withoutWhere,Select().Select(), anonymous projections, andSum/Averageoverlong,doubleanddecimal?;Select, then a scalar;Sum/Maxwith a selector,Any/Countwith a predicate,Last,First,Min(descending), anotherSkip,Take(...).Skip(...), chained windows andThenBy, plusTake(0);Select().Count(), plain window lists andWhere().Select().ToList().Every
Agevalue occurs exactly 6 times. Windows either select onlyAgeor end on a multiple of 6, so ties at the edge of a window can't change the expected result.Full suite on macOS: 2447 passed, 0 failed, 4 skipped.
BLite.Corebuilds with no warnings.Coordination
SelectTerminalTestsand perf: index-aware LINQ terminals with predicates, single-copy page writes, single-pass B-Tree insert (#179) #180'sQueryAndWritePathTestspass (66/66). perf: index-aware LINQ terminals with predicates, single-copy page writes, single-pass B-Tree insert (#179) #180 leaves predicates afterSelecton the fallback path, which is where this PR handles them.Found along the way, not changed here
TryPushDownSelectcallsScanAsync<TProj>(projector, ct)throughMethodInfo.Invokewith one argument.Invokedoesn't fill in optional parameters, so it throwsTargetParameterCountException, which is caught. Even if the call worked, it would return anIAsyncEnumerable<TProj>, not theIEnumerable<TProj>the caller expects. The projection is still correct through the general path, just without the push-down. I kept the method correct but didn't revive it, because perf(query): LINQ Sum/Average/Min/Max from index keys or raw BSON #183 reworks the raw-BSON scan.OrderBy,SkipandTake. For example,OrderBy(p => p.Name).Take(5).OrderBy(p => p.Age)sorts all documents byAgebefore taking 5. AWhereafterTakebelongs to LINQ: VisitWhere folds a Where into the root WHERE after GroupBy/OfType/SkipWhile/Take, and from a Join inner sequence #186.🤖 Generated with Claude Code