diff --git a/src/BLite.Core/Query/BTreeQueryProvider.cs b/src/BLite.Core/Query/BTreeQueryProvider.cs index aa8a65c..0b82279 100644 --- a/src/BLite.Core/Query/BTreeQueryProvider.cs +++ b/src/BLite.Core/Query/BTreeQueryProvider.cs @@ -256,14 +256,31 @@ private async Task ExecuteAsyncCore(Expression expression, Can // ── Push-down SELECT (+ optional WHERE) ─────────────────────────────── // Single-pass BSON projection: no T instantiation, no EnumerableRewriter. - // Only applicable when there is no post-scan ordering or paging. + // Only applicable when the projection is the whole query: no post-scan ordering, + // paging or other operators. A terminal after the Select (Any, Max, Count(pred), ...) + // runs the usual way. if (model.SelectClause is not null && model.OrderByClause is null && !model.Take.HasValue - && !model.Skip.HasValue) + && !model.Skip.HasValue + && !model.HasComplexOperators + && TryPushDownSelect(model.SelectClause, model.WhereClause, out var pushed)) { - var pushed = TryPushDownSelect(model.SelectClause, model.WhereClause); - if (pushed is not null) return pushed; + return pushed; + } + + // ── Several query sources ───────────────────────────────────────────── + // A query that reads a queryable outside its main chain (q.Where(...).Concat(q), a + // self-join, a subquery inside a lambda) cannot be served by one filtered or paged + // fetch: the model mixes the clauses of the different uses, and EnumerableRewriter + // replaces every use of the root with the same source. Replay the whole query over + // the whole collection. + if (HasSecondaryQuery(expression)) + { + var all = new List(); + await foreach (var item in _collection.FetchAsync(null, int.MaxValue, cancellationToken)) + all.Add(item); + return ExecuteViaEnumerableRewriter(expression, all); } // 2. Data fetching. @@ -278,20 +295,37 @@ private async Task ExecuteAsyncCore(Expression expression, Can // O(n log n) in-memory sort over all documents. Skip is performed at the // index-entry level (no document reads for skipped positions), so only the // requested page window is ever deserialised. - if (model.WhereClause == null && model.OrderByClause != null && model.Take.HasValue) + if (model.WhereClause == null && model.OrderByClause != null && model.Take.HasValue + && TryGetIndexWindow(expression, model.OrderByClause, out int windowEnd, out bool plainWindow)) { var orderOpt = IndexOptimizer.TryOptimizeOrderBy(model, _collection.GetIndexes()); if (orderOpt is not null) { bool ascending = !model.OrderDescending; - int skip = model.Skip ?? 0; - int take = model.Take.Value; - var topN = new List(take); - await foreach (var item in _collection.QueryIndexAsync(orderOpt.IndexName, null, null, ascending, skip, take, cancellationToken)) - topN.Add(item); - if (model.SelectClause != null) - return ProjectEnumerable(topN, model.SelectClause); - return TerminalReturn(topN); + if (plainWindow && !model.HasComplexOperators + && (model.SelectClause == null || IsSequenceResult())) + { + int skip = model.Skip ?? 0; + int take = model.Take.Value; + var topN = new List(take); + await foreach (var item in _collection.QueryIndexAsync(orderOpt.IndexName, null, null, ascending, skip, take, cancellationToken)) + topN.Add(item); + if (model.SelectClause != null) + return ProjectEnumerable(topN, model.SelectClause); + return TerminalReturn(topN); + } + + // Operators after the window (a terminal with a predicate or selector, a scalar + // after Select, ...): read the first windowEnd entries in index order and replay the + // whole query over them. OrderBy is a stable sort, so the replay keeps the index + // order and its Skip/Take select the same window. + // This needs the root queryable to be the query's only source: EnumerableRewriter + // replaces every use of it with the prefix. A query that reads a queryable again + // (Concat(q), Join(q, ...)) took the HasSecondaryQuery path above. + var prefix = new List(windowEnd); + await foreach (var item in _collection.QueryIndexAsync(orderOpt.IndexName, null, null, ascending, 0, windowEnd, cancellationToken)) + prefix.Add(item); + return ExecuteViaEnumerableRewriter(expression, prefix); } } @@ -321,6 +355,11 @@ private async Task ExecuteAsyncCore(Expression expression, Can if (model.OrderByClause != null && model.OrderByClause.Parameters[0].Type != typeof(T)) return ExecuteViaEnumerableRewriter(expression, sourceData); + // The direct pipeline can only project into a sequence. A scalar terminal after a + // Select (e.g. Take(n).Select(...).Count()) needs the full expression. + if (model.SelectClause != null && !IsSequenceResult()) + return ExecuteViaEnumerableRewriter(expression, sourceData); + // 3. Direct pipeline — no expression-tree rewrite, no EnumerableRewriter, no Compile(). return ExecutePipeline(model, sourceData, whereAlreadyApplied); } @@ -378,11 +417,11 @@ private TResult ExecutePipeline(QueryModel model, IEnumerable source [RequiresUnreferencedCode("BLite LINQ queries use reflection to resolve methods and types at runtime. Ensure all entity types and their members are preserved.")] private TResult ProjectEnumerable(IEnumerable source, LambdaExpression selectLambda) { - var resultType = typeof(TResult); - if (!resultType.IsGenericType || resultType.GetGenericTypeDefinition() != typeof(IEnumerable<>)) - return default!; + if (!IsSequenceResult()) + throw new NotSupportedException( + $"BLite query pipeline: cannot project into result type '{typeof(TResult)}'."); - var projType = resultType.GetGenericArguments()[0]; + var projType = typeof(TResult).GetGenericArguments()[0]; var method = s_projectMethodCache.GetOrAdd( projType, t => typeof(BTreeQueryProvider) @@ -403,6 +442,120 @@ private static IEnumerable ProjectTyped(IEnumerable source, Lam private static readonly System.Collections.Concurrent.ConcurrentDictionary s_projectMethodCache = new(); + /// Whether is IEnumerable<TElement>, i.e. the query is enumerated rather than ended by a terminal. + private static bool IsSequenceResult() + => typeof(TResult).IsGenericType && typeof(TResult).GetGenericTypeDefinition() == typeof(IEnumerable<>); + + /// + /// Checks that starts with OrderBy/OrderByDescending + /// on the collection (the call whose key selector is ), followed by + /// Skip/Take calls that bound it, so that the result only depends on the first + /// documents in that order. + /// + /// + /// true when the window is exactly Take(n) or Skip(s).Take(n), as + /// and describe it, and only + /// Select or a plain Count()/LongCount() follows it. + /// + private static bool TryGetIndexWindow(Expression expression, LambdaExpression orderBy, + out int windowEnd, out bool plainWindow) + { + windowEnd = 0; + plainWindow = false; + + // Collect the Queryable calls from the root outwards. + var calls = new List(); + var current = expression; + while (current is MethodCallExpression call && call.Method.DeclaringType == typeof(Queryable)) + { + calls.Add(call); + current = call.Arguments[0]; + } + if (current is not ConstantExpression { Value: IQueryable } || calls.Count < 2) return false; + calls.Reverse(); + + var first = calls[0]; + if (first.Method.Name is not ("OrderBy" or "OrderByDescending") + || first.Arguments[1] is not UnaryExpression { Operand: var key } + || !ReferenceEquals(key, orderBy)) + return false; + + long start = 0, length = long.MaxValue; + int i = 1; + for (; i < calls.Count && calls[i].Method.Name is "Skip" or "Take"; i++) + { + if (calls[i].Arguments[1] is not ConstantExpression { Value: int n }) return false; + n = Math.Max(n, 0); + if (calls[i].Method.Name == "Skip") + { + start += n; + if (length != long.MaxValue) length = Math.Max(0, length - n); + } + else + { + length = Math.Min(length, n); + } + } + + // No Take bounds the window, or a ThenBy refines the order of the documents at its edge. + if (length == long.MaxValue) return false; + if (i < calls.Count && calls[i].Method.Name is "ThenBy" or "ThenByDescending") return false; + + long end = start + length; + if (end > int.MaxValue) return false; + windowEnd = (int)end; + + var window = calls.GetRange(1, i - 1).ConvertAll(c => c.Method.Name); + plainWindow = (window.Count == 1 && window[0] == "Take" + || window.Count == 2 && window[0] == "Skip" && window[1] == "Take") + && calls.Skip(i).All(c => c.Method.Name == "Select" + || c.Method.Name is "Count" or "LongCount" && c.Arguments.Count == 1); + return true; + } + + /// + /// Whether an operator of the main chain (the Queryable calls from the result down + /// to the root) has an argument other than its source that holds a queryable or calls a + /// Queryable operator: a second use of the root (Concat(q), Join(q, ...), + /// Union(q.Select(...))), another source, or a subquery inside a lambda. + /// + private static bool HasSecondaryQuery(Expression expression) + { + var finder = new SecondaryQueryFinder(); + for (var current = expression; + current is MethodCallExpression call && call.Method.DeclaringType == typeof(Queryable); + current = call.Arguments[0]) + { + for (int i = 1; i < call.Arguments.Count && !finder.Found; i++) + finder.Visit(call.Arguments[i]); + if (finder.Found) return true; + } + return false; + } + + private sealed class SecondaryQueryFinder : ExpressionVisitor + { + public bool Found { get; private set; } + + public override Expression? Visit(Expression? node) => Found ? node : base.Visit(node); + + protected override Expression VisitConstant(ConstantExpression node) + { + if (node.Value is IQueryable) Found = true; + return node; + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method.DeclaringType == typeof(Queryable)) + { + Found = true; + return node; + } + return base.VisitMethodCall(node); + } + } + /// /// Type-switch dispatch for terminal results — first-match, no reflection per call. /// Covers all standard LINQ terminal operators: First/Single/Count/Any/All/ToList/ToArray. @@ -592,20 +745,25 @@ private static bool AggregateValues( /// Attempts to execute the query entirely via a single-pass BSON projection scan, /// bypassing full deserialisation. /// - /// The projected IEnumerable<TProj>, or null if push-down is - /// not applicable (caller should fall through to the standard path). + /// + /// true and the projected IEnumerable<TProj> in ; + /// false if push-down is not applicable (caller should fall through to the standard path). + /// default(TResult) cannot signal this: for a value-type result such as bool it + /// is a valid answer (#181). + /// [RequiresDynamicCode("BLite LINQ queries use Expression.Compile() and MakeGenericMethod which require dynamic code generation.")] [RequiresUnreferencedCode("BLite LINQ queries use reflection to resolve methods and types at runtime. Ensure all entity types and their members are preserved.")] - private TResult? TryPushDownSelect(LambdaExpression selectLambda, - LambdaExpression? whereLambda = null) + private bool TryPushDownSelect(LambdaExpression selectLambda, + LambdaExpression? whereLambda, + out TResult result) { + result = default!; + // We need TResult = IEnumerable for some projection type TProj. - var resultType = typeof(TResult); - if (!resultType.IsGenericType) return default; - if (resultType.GetGenericTypeDefinition() != typeof(IEnumerable<>)) return default; + if (!IsSequenceResult()) return false; - var projType = resultType.GetGenericArguments()[0]; - if (projType == typeof(T)) return default; // not a real projection + var projType = typeof(TResult).GetGenericArguments()[0]; + if (projType == typeof(T)) return false; // not a real projection // Compile the push-down projector via reflection (TProj is only known at runtime). // BsonProjectionCompiler.TryCompile(selectLambda, whereLambda?) handles @@ -618,9 +776,9 @@ private static bool AggregateValues( t => s_tryCompileMethod.MakeGenericMethod(typeof(T), t)); projector = compileMethod.Invoke(null, [selectLambda, whereLambda, _collection.GetKeyMap()]); } - catch { return default; } + catch { return false; } - if (projector is null) return default; // compilation soft-failed + if (projector is null) return false; // compilation soft-failed // Invoke _collection.Scan(projector) — MethodInfo cached per TProj. try @@ -628,10 +786,11 @@ private static bool AggregateValues( var scanMethod = s_compiledScanMethods.GetOrAdd( projType, t => s_scanProjectorMethod.MakeGenericMethod(t)); - var result = scanMethod.Invoke(_collection, [projector]); - return (TResult?)result; + if (scanMethod.Invoke(_collection, [projector]) is not TResult scanned) return false; + result = scanned; + return true; } - catch { return default; } + catch { return false; } } // ─── Expression-tree helpers ────────────────────────────────────────────── diff --git a/src/BLite.Core/Query/EnumerableRewriter.cs b/src/BLite.Core/Query/EnumerableRewriter.cs index 01c89bf..d7f7008 100644 --- a/src/BLite.Core/Query/EnumerableRewriter.cs +++ b/src/BLite.Core/Query/EnumerableRewriter.cs @@ -60,12 +60,11 @@ protected override Expression VisitMethodCall(MethodCallExpression node) for (int i = 0; i < node.Arguments.Count; i++) { var arg = Visit(node.Arguments[i]); - // Strip Quote wrapping around lambda arguments. + // Strip Quote wrapping around lambda arguments. The lambda stays in the tree and is + // compiled with the enclosing expression: compiling it on its own would fail when + // it refers to the parameter of an outer lambda (q.Count(x => q.Any(y => y.Age == x.Age))). if (arg is UnaryExpression { NodeType: ExpressionType.Quote } quote) - { - var lambda = (LambdaExpression)quote.Operand; - arg = Expression.Constant(lambda.Compile()); - } + arg = quote.Operand; args[i] = arg; } @@ -78,9 +77,18 @@ protected override Expression VisitMethodCall(MethodCallExpression node) { if (candidate.GetGenericArguments().Length != typeArgs.Length) continue; if (candidate.GetParameters().Length != args.Length) continue; - MethodInfo closed = null!; - try { closed = candidate.MakeGenericMethod(typeArgs); } - catch { continue; } + MethodInfo closed; + if (typeArgs.Length == 0) + { + // Non-generic overloads such as Sum(IEnumerable) / Average(IEnumerable): + // MakeGenericMethod would throw on them. + closed = candidate; + } + else + { + try { closed = candidate.MakeGenericMethod(typeArgs); } + catch { continue; } + } try { return Expression.Call(closed, args); } catch { continue; } } diff --git a/tests/BLite.Tests/SelectTerminalTests.cs b/tests/BLite.Tests/SelectTerminalTests.cs new file mode 100644 index 0000000..40f8fce --- /dev/null +++ b/tests/BLite.Tests/SelectTerminalTests.cs @@ -0,0 +1,137 @@ +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// Terminals after Select, and after an indexed OrderBy(...).Take(...), +/// must return what LINQ-to-Objects returns (#181). +/// +public class SelectTerminalTests : IDisposable +{ + private readonly string _dbPath; + private readonly TestDbContext _db; + private readonly List _people; + + public SelectTerminalTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"blite_select_terminal_{Guid.NewGuid()}.db"); + _db = new TestDbContext(_dbPath); + _people = Enumerable.Range(0, 300) + .Select(i => new PersonV2 { Name = $"p{i}", Age = i % 50 }) + .ToList(); + _db.PeopleV2.InsertBulkAsync(_people).GetAwaiter().GetResult(); + } + + public void Dispose() + { + _db.Dispose(); + if (File.Exists(_dbPath)) File.Delete(_dbPath); + } + + // Every Age value occurs exactly 6 times. Windows over OrderBy(p => p.Age) either select Age only + // or end on a multiple of 6, so ties at the window edge cannot change the expected result. + public static IEnumerable Queries() => Cases.Keys.Select(k => new object[] { k }); + + private static readonly Dictionary, object?>> Cases = new() + { + // Select, then a scalar terminal (the issue's repro). + ["Select.Any"] = q => q.Select(p => p.Age).Any(), + ["Select.Any(pred)"] = q => q.Select(p => p.Age).Any(a => a == 7), + ["Select.Any(pred) none"] = q => q.Select(p => p.Age).Any(a => a == 700), + ["Select.All(pred)"] = q => q.Select(p => p.Age).All(a => a < 50), + ["Select.Count(pred)"] = q => q.Select(p => p.Age).Count(a => a == 7), + ["Select.LongCount(pred)"] = q => q.Select(p => p.Age).LongCount(a => a == 7), + ["Select.Max"] = q => q.Select(p => p.Age).Max(), + ["Select.Min"] = q => q.Select(p => p.Age).Min(), + ["Select.Sum"] = q => q.Select(p => p.Age).Sum(), + ["Select.Average"] = q => q.Select(p => p.Age).Average(), + ["Select.Contains"] = q => q.Select(p => p.Age).Contains(49), + ["Select.ElementAt via OrderBy"] = q => q.Select(p => p.Age).OrderBy(a => a).ElementAt(10), + ["Select.First(pred)"] = q => q.Select(p => p.Age).First(a => a == 7), + ["Select.FirstOrDefault none"] = q => q.Select(p => p.Age).FirstOrDefault(a => a == 700), + ["Select.Single(pred)"] = q => q.Select(p => p.Name).Single(n => n == "p7"), + ["Select.Distinct.Count"] = q => q.Select(p => p.Age).Distinct().Count(), + ["Where.Select.Any"] = q => q.Where(p => p.Age == 7).Select(p => p.Name).Any(), + ["Where.Select.Any none"] = q => q.Where(p => p.Age == 700).Select(p => p.Name).Any(), + ["Where.Select.Max"] = q => q.Where(p => p.Age > 10).Select(p => p.Age).Max(), + ["Where.Select.Count(pred)"] = q => q.Where(p => p.Age > 10).Select(p => p.Age).Count(a => a < 20), + ["Select.Select.Sum"] = q => q.Select(p => p.Age).Select(a => a * 2).Sum(), + ["Select(anon).Any"] = q => q.Select(p => new { p.Age }).Any(x => x.Age == 7), + + // Select after paging, then a scalar terminal. + ["Take.Select.Count"] = q => q.Take(10).Select(p => p.Age).Count(), + ["Skip.Select.LongCount"] = q => q.Skip(290).Select(p => p.Age).LongCount(), + ["OrderBy.Select.Take.Count"] = q => q.OrderBy(p => p.Name).Select(p => p.Age).Take(10).Count(), + ["OrderBy.Take.Select.Any"] = q => q.OrderBy(p => p.Name).Take(10).Select(p => p.Age).Any(), + + // OrderBy on the indexed Age, then Take: the index top-N fast path. + ["OrderByIdx.Take.Select.Sum"] = q => q.OrderBy(p => p.Age).Take(10).Select(p => p.Age).Sum(), + ["OrderByIdx.Take.Select.Max"] = q => q.OrderBy(p => p.Age).Take(10).Select(p => p.Age).Max(), + ["OrderByIdx.Take.Select.Count"] = q => q.OrderBy(p => p.Age).Take(10).Select(p => p.Age).Count(), + ["OrderByIdx.Skip.Take.Select.Sum"] = q => q.OrderBy(p => p.Age).Skip(20).Take(10).Select(p => p.Age).Sum(), + ["OrderByIdx.Take.Sum(sel)"] = q => q.OrderBy(p => p.Age).Take(10).Sum(p => p.Age), + ["OrderByIdx.Take.Max(sel)"] = q => q.OrderBy(p => p.Age).Take(10).Max(p => p.Age), + ["OrderByIdx.Take.Any(pred)"] = q => q.OrderBy(p => p.Age).Take(10).Any(p => p.Age > 5), + ["OrderByIdx.Take.Count(pred)"] = q => q.OrderBy(p => p.Age).Take(10).Count(p => p.Age == 0), + ["OrderByIdx.Take.Count"] = q => q.OrderBy(p => p.Age).Take(10).Count(), + ["OrderByIdx.Take.Last.Age"] = q => q.OrderBy(p => p.Age).Take(10).Select(p => p.Age).Last(), + ["OrderByDescIdx.Take.Select.Min"] = q => q.OrderByDescending(p => p.Age).Take(10).Select(p => p.Age).Min(), + ["OrderByIdx.Skip.Take.First.Age"] = q => q.OrderBy(p => p.Age).Skip(20).Take(10).Select(p => p.Age).First(), + + // Window shapes the index top-N shortcut must not take, or must replay. + ["OrderByIdx.Take.Skip.List"] = q => string.Join(",", q.OrderBy(p => p.Age).Take(10).Skip(5).Select(p => p.Age).ToList()), + ["OrderByIdx.Skip.Take.Skip.Take"] = q => string.Join(",", q.OrderBy(p => p.Age).Skip(10).Take(20).Skip(5).Take(3).Select(p => p.Age).ToList()), + ["OrderByIdx.Skip.Take.Select.Skip"] = q => string.Join(",", q.OrderBy(p => p.Age).Skip(5).Take(10).Select(p => p.Age).Skip(2).ToList()), + ["OrderByIdx.Take.Select.Take"] = q => string.Join(",", q.OrderBy(p => p.Age).Take(10).Select(p => p.Age).Take(8).ToList()), + ["OrderByIdx.ThenBy.Take.List"] = q => string.Join(",", q.OrderBy(p => p.Age).ThenBy(p => p.Name).Take(7).Select(p => p.Name).ToList()), + ["OrderByIdx.Take.ThenBy.List"] = q => string.Join(",", q.OrderBy(p => p.Age).Take(12).OrderBy(p => p.Age).ThenByDescending(p => p.Name).Select(p => p.Name).ToList()), + ["Select(anon).OrderBy.Take.List"] = q => string.Join(",", q.Select(p => new { p.Age, p.Name }).OrderBy(x => x.Age).Take(3).Select(x => x.Age).ToList()), + ["OrderByIdx.Skip.Take.First"] = q => q.OrderBy(p => p.Age).Skip(3).Take(10).First().Age, + ["OrderByIdx.Take(0).Any"] = q => q.OrderBy(p => p.Age).Take(0).Select(p => p.Age).Any(), + + // The root queryable used a second time after the window: that use reads the whole + // collection, not the window prefix the replay is given. + ["OrderByIdx.Take.Concat(root).Count"] = q => q.OrderBy(p => p.Age).Take(5).Concat(q).Count(), + ["OrderByIdx.Take.Select.Concat(root).Count"] = q => q.OrderBy(p => p.Age).Take(5).Select(p => p.Age).Concat(q.Select(p => p.Age)).Count(), + ["OrderByIdx.Take.Select.Union(root).Count"] = q => q.OrderBy(p => p.Age).Take(5).Select(p => p.Age).Union(q.Select(p => p.Age)).Count(), + ["OrderByIdx.Take.Join(root).Count"] = q => q.OrderBy(p => p.Age).Take(5).Join(q, a => a.Age, b => b.Age, (a, b) => b.Name).Count(), + + // A lambda that captures the queryable reads it through a closure field, not a constant, + // so the replay does not replace it with the prefix: it runs as its own query. + ["OrderByIdx.Take.Count(closure root)"] = q => q.OrderBy(p => p.Age).Take(12).Count(x => q.Any(y => y.Age == 49)), + ["OrderByIdx.Take.Count(correlated closure)"] = q => q.OrderBy(p => p.Age).Take(12).Count(x => q.Any(y => y.Age == x.Age + 1)), + ["Count(correlated closure)"] = q => q.Count(x => q.Any(y => y.Age == x.Age + 1)), + + // Outside the window path: a Where on one use of the root, or inside a lambda, must not + // filter the other uses. + ["Where.Concat(root).Count"] = q => q.Where(p => p.Age < 10).Concat(q).Count(), + ["Concat(root.Where).Count"] = q => q.Concat(q.Where(p => p.Age < 10)).Count(), + ["Where.Join(root).Count"] = q => q.Where(p => p.Age == 1).Join(q, a => a.Age, b => b.Age - 1, (a, b) => b.Name).Count(), + ["Concat(root).Sum(sel)"] = q => q.Concat(q).Sum(p => p.Age), + ["Take.Concat(root.Take).Count"] = q => q.Take(5).Concat(q.Take(3)).Count(), + ["OrderByIdx.Take.Concat(root.Where).Sum"] = q => q.OrderBy(p => p.Age).Take(5).Concat(q.Where(p => p.Age >= 10)).Sum(p => p.Age), + ["Any(closure Where)"] = q => q.Any(x => x.Age == 0 && q.Where(y => y.Age == 49).Any()), + + // Parameterless Sum/Average after Select (non-generic Queryable overloads). + ["Select(long).Sum"] = q => q.Select(p => (long)p.Age).Sum(), + ["Select(double).Average"] = q => q.Select(p => (double)p.Age).Average(), + ["Select(decimal?).Sum"] = q => q.Select(p => (decimal?)p.Age).Sum(), + + // Shapes that already worked, kept as guards. + ["Select.Count"] = q => q.Select(p => p.Age).Count(), + ["Select.ToList.Count"] = q => q.Select(p => p.Age).ToList().Count, + ["Where.Select.ToList"] = q => string.Join(",", q.Where(p => p.Age == 7).Select(p => p.Name).ToList().OrderBy(n => n)), + ["OrderByIdx.Take.Select.List"]= q => string.Join(",", q.OrderBy(p => p.Age).Take(12).Select(p => p.Age).ToList()), + ["OrderByIdx.Skip.Take.List"] = q => string.Join(",", q.OrderBy(p => p.Age).Skip(20).Take(10).Select(p => p.Age).ToList()), + }; + + [Theory] + [MemberData(nameof(Queries))] + public void Terminal_MatchesLinqToObjects(string name) + { + var query = Cases[name]; + var expected = query(_people.AsQueryable()); + var actual = query(_db.PeopleV2.AsQueryable()); + Assert.Equal(expected, actual); + } +}