Skip to content

LINQ: VisitWhere folds a Where into the root WHERE after GroupBy/OfType/SkipWhile/Take, and from a Join inner sequence #186

Closed

Description

@CaffeinatedCoder

BTreeExpressionVisitor.VisitWhere (~L57-88) merges every Queryable.Where it reaches into QueryModel.WhereClause, whatever operators come before it and whichever sequence it belongs to. The provider then pre-filters with FetchAsync(WhereClause) before EnumerableRewriter replays the full expression. That breaks in two ways.

1. Where after an operator that changes the element type or depends on order

Query (300 docs, Age = i % 50) Expected Actual
GroupBy(p => p.Age).Where(g => g.Count() > 5).Count() 50 InvalidCastException (lambda over IGrouping cast to Expression<Func<T,bool>>)
Where((p, i) => i < 10).ToList() 10 items InvalidCastException (two-parameter lambda)
SkipWhile(p => p.Age < 10).Where(p => p.Age == 5).ToList() 6 (scan order) 0
Take(10).Where(p => p.Age == 5).ToList() 1 (scan order) 6

OfType<X>, Cast<X>, SelectMany, Join and Zip followed by Where go down the same path.

2. Where on another sequence (e.g. a Join inner)

For operators the visitor doesn't model, VisitMethodCall falls through to base.VisitMethodCall, which visits every argument. A Where on the inner sequence of a Join is therefore ANDed into the outer collection's predicate:

db.VectorItems.AsQueryable().Where(x => x.Title == "B")
    .Join(db.VectorItems.AsQueryable().Where(y => y.Title == "A"),
          x => 1, y => 1, (x, y) => x.Title + "/" + y.Title)
    .ToList();

With items A, A, B, the expected result is ["B/A", "B/A"]. The actual result is [], because the outer fetch runs Title == "B" && Title == "A". The VectorSearch variant (.Join(VectorItems.Where(y => y.Embedding.VectorSearch(q, 1)), ...)) also returns [] instead of ["B/A"].

All of the above behaves the same on main.

Suggested fix

Merge into WhereClause only the Where calls on the root chain (reached through Arguments[0]) where every operator below them is Where/OrderBy*/ThenBy* and the lambda takes the entity type. That is the CanFoldPredicate check from #180. Leave every other Where to EnumerableRewriter.

Found during the reviews of #173 and #180.

Activity

  1. added
    bugSomething isn't working
    area/linqLINQ provider, expression visitor, rewriter
    severity/highWrong results, crash or failed writes in a realistic scenario
    and removed on Oct 15, 2026
  2. added this to the 5.3.0 milestone on Oct 15, 2026
  3. moved this from Todo to In Progress in BLiteon Oct 15, 2026
  4. moved this from In Progress to Done in BLiteon Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/linqLINQ provider, expression visitor, rewriterbugSomething isn't workingpackage/coreAffects BLite.Corepriority/P1Must be in the next minor releaseseverity/highWrong results, crash or failed writes in a realistic scenario

    Type

    No type

    Projects

    • Status
      Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions