Fix AND/OR precedence when compiling a chain of where and orWhere clauses

XMLWordPrintableJSON

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Unknown
    • None
    • Affects Version/s: None
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Problem

      Builder::compileWheres() bucketed all "and"-connected wheres and all "or"-connected wheres separately, then merged them as sibling $and and $or keys at the top level. Since MongoDB implicitly ANDs sibling top-level keys, a chain of three clauses or more compiled to the wrong logical expression:

      Model::where\('a', 1\)\->where\('b', 2\)\->orWhere\('c', 3\);
      // compiled to {"$and": \[{a:1},{b:2}\], "$or": \[{c:3}\]}
      // evaluated by MongoDB as: a AND b AND c
      

      Laravel generates where "a" = ? and "b" = ? or "c" = ? for the same chain, and SQL operator precedence puts AND before OR, so the expected meaning is (a AND b) OR c. Documents matching only the c branch were silently excluded.

      The bug was hidden because only the trivial two-clause case was covered by tests, and a special case rewrote the boolean of the first where based on the second one, which happened to produce the right result for two clauses only.

      Fix

      Group consecutive "and"-connected wheres together and start a new group at each "or", then combine the groups with $or. This mirrors the precedence of the SQL generated by Laravel.

      Behavioral change

      The generated query changes for any chain of three clauses or more that mixes where and orWhere. compileWheres() is also used by update() and delete(), so an application that relied on the old, more restrictive result will now match a wider set of documents, including on writes:

      Post::where\('a', 1\)\->orWhere\('b', 2\)\->where\('owner\_id', $me\->id\)\->delete\(\);
      // before: \(a OR b\) AND owner  \-> the owner filter applied to both branches
      // after:  a OR \(b AND owner\)  \-> the a=1 branch is no longer filtered
      

      The new behavior matches Laravel and SQL, so the old one was fail-closed by accident rather than by contract. Applications that want the old grouping should make it explicit with a closure, which is stable across both versions:

      \->where\('owner\_id', $me\->id\)\->where\(fn \($q\) => $q\->where\('a', 1\)\->orWhere\('b', 2\)\);
      

      Eloquent global scopes are not affected. callScope() goes through addNewWheresWithinGroup(), so SoftDeletes and multi-tenant scopes stay in their own nested group connected with "and", before and after the change.

      Notes

      Community contribution: https://github.com/mongodb/laravel-mongodb/pull/3567

              Assignee:
              Unassigned
              Reporter:
              Jérôme Tamarelle
              Votes:
              0 Vote for this issue
              Watchers:
              2 Start watching this issue

                Created:
                Updated: