PHPLARA-268 Fix AND/OR precedence in compileWheres() - #3567
Conversation
|
Thank you for your contribution and the explanations. The logic seems correct to me. Here's what I'll be reviewing:
|
compileWheres() bucketed all "and"-connected wheres and all
"or"-connected wheres separately, then merged them as sibling $and/$or
keys at the top level. MongoDB implicitly ANDs sibling top-level keys,
so a chain like where('a',1)->where('b',2)->orWhere('c',3) compiled to
"a AND b AND c" instead of the SQL-equivalent "(a AND b) OR c".
Rewrote the grouping so consecutive "and"-connected wheres are grouped
together and a new group starts at each "or", matching SQL's
AND-before-OR precedence. Also drops the special-case rewrite of the
first where's boolean, which only handled the 2-clause case and is
superseded by the general grouping.
Regression tests for the previous commit: - 'where where orWhere' checks that (a and b) or c is compiled with the AND group nested inside the OR, not flattened into a AND b AND c. - 'where orWhere where' checks the symmetric case, a or (b and c).
Remove the intermediate array that held each compiled where with its connector: the groups can be built directly in the compilation loop. Add test cases for a long alternating chain, a leading orWhere, an orWhereNot in the middle of a chain, and a nested closure next to an orWhere. All of them fail without the precedence fix.
1858415 to
cc0a82e
Compare
AND/OR precedence in compileWheres()|
Thanks for this fix @zigzagdev, and for the clear write-up of the root cause. The analysis is right: the old code produced a logical expression that MongoDB evaluated as an implicit AND across the sibling top-level keys, and only the two-clause case ever came out correct. I reviewed it and pushed two changes to your branch:
I also verified that a leading One consequence worth recording, so I expanded the description with it: I rebased the branch on |
|
I'm hesitant to merge this PR. I'm worried about breaking changes. |
GromNaN
left a comment
There was a problem hiding this comment.
Inline notes showing the query generated for each new test case before the fix, so the change in meaning is easy to follow.
| ]; | ||
|
|
||
| // "a and b or c" must mean "(a and b) or c", matching SQL's AND-before-OR precedence. | ||
| yield 'where where orWhere' => [ |
There was a problem hiding this comment.
->where('age', 1)->where('name', 'bar')->orWhere('email', 'foo')Before this PR:
{"$and":[{"age":1},{"name":"bar"}],"$or":[{"email":"foo"}]}MongoDB ANDs the sibling top-level keys, so this was evaluated as age AND name AND email.
With this PR:
{"$or":[{"$and":[{"age":1},{"name":"bar"}]},{"email":"foo"}]}which is (age AND name) OR email, the meaning of the SQL that Laravel generates for the same chain.
Documents matching only on email were dropped.
| ]; | ||
|
|
||
| // "a or b and c" must mean "a or (b and c)", matching SQL's AND-before-OR precedence. | ||
| yield 'where orWhere where' => [ |
There was a problem hiding this comment.
->where('age', 1)->orWhere('email', 'foo')->where('name', 'bar')Before this PR:
{"$or":[{"age":1},{"email":"foo"}],"$and":[{"name":"bar"}]}MongoDB ANDs the sibling top-level keys, so this was evaluated as (age OR email) AND name.
With this PR:
{"$or":[{"age":1},{"$and":[{"email":"foo"},{"name":"bar"}]}]}which is age OR (email AND name), the meaning of the SQL that Laravel generates for the same chain.
The symmetric case. Here the old query was too restrictive instead of too permissive: the trailing where was applied to both branches.
| ]; | ||
|
|
||
| // "a or b and c or d and e" must mean "a or (b and c) or (d and e)". | ||
| yield 'alternating where and orWhere' => [ |
There was a problem hiding this comment.
->where('a', 1)->orWhere('b', 2)->where('c', 3)->orWhere('d', 4)->where('e', 5)Before this PR:
{"$or":[{"a":1},{"b":2},{"d":4}],"$and":[{"c":3},{"e":5}]}MongoDB ANDs the sibling top-level keys, so this was evaluated as (a OR b OR d) AND c AND e.
With this PR:
{"$or":[{"a":1},{"$and":[{"b":2},{"c":3}]},{"$and":[{"d":4},{"e":5}]}]}which is a OR (b AND c) OR (d AND e), the meaning of the SQL that Laravel generates for the same chain.
This is the case that shows the old bucketing most clearly: every orWhere landed in one flat $or and every where in one flat $and, losing all the grouping.
| ]; | ||
|
|
||
| // A leading "orWhere" behaves like a "where". | ||
| yield 'leading orWhere then where' => [ |
There was a problem hiding this comment.
->orWhere('a', 1)->where('b', 2)Before this PR:
{"$or":[{"a":1}],"$and":[{"b":2}]}MongoDB ANDs the sibling top-level keys, so this was evaluated as a AND b.
With this PR:
{"$and":[{"a":1},{"b":2}]}which is a AND b, the meaning of the SQL that Laravel generates for the same chain.
Semantics were already correct here, a leading orWhere behaves like a where. The test guards the $i > 0 condition in the new grouping, and the emitted query is now a plain $and instead of a single-element $or next to an $and.
| ]; | ||
|
|
||
| // "a or not b and c" must mean "a or (not b and c)". | ||
| yield 'where orWhereNot where' => [ |
There was a problem hiding this comment.
->where('a', 1)->orWhereNot('b', 2)->where('c', 3)Before this PR:
{"$or":[{"a":1},{"$nor":[{"b":2}]}],"$and":[{"c":3}]}MongoDB ANDs the sibling top-level keys, so this was evaluated as (a OR NOT b) AND c.
With this PR:
{"$or":[{"a":1},{"$and":[{"$nor":[{"b":2}]},{"c":3}]}]}which is a OR (NOT b AND c), the meaning of the SQL that Laravel generates for the same chain.
Checks that the $nor produced by the negation stays inside the group opened by the or, and is not hoisted out of it.
| ]; | ||
|
|
||
| // "a or (b or c) and d" must mean "a or ((b or c) and d)". | ||
| yield 'where orWhere nested where' => [ |
There was a problem hiding this comment.
->where('a', 1)->orWhere(fn ($q) => $q->where('b', 2)->orWhere('c', 3))->where('d', 4)Before this PR:
{"$or":[{"a":1},{"$or":[{"b":2},{"c":3}]}],"$and":[{"d":4}]}MongoDB ANDs the sibling top-level keys, so this was evaluated as (a OR b OR c) AND d.
With this PR:
{"$or":[{"a":1},{"$and":[{"$or":[{"b":2},{"c":3}]},{"d":4}]}]}which is a OR ((b OR c) AND d), the meaning of the SQL that Laravel generates for the same chain.
Nested groups built by a closure compose with the new grouping, and an explicit closure keeps behaving as its own unit. This is the pattern to recommend to anyone who wants the old grouping back.
Fixes PHPLARA-268
compileWheres()bucketed all "and"-connected wheres and all "or"-connected wheres separately, then merged them as sibling$andand$orkeys at the top level of the query. Since MongoDB implicitly ANDs sibling top-level keys, a chain like:compiled to
{'$and': [{a:1},{b:2}], '$or': [{c:3}]}, which MongoDB evaluates asa AND b AND c, silently dropping documents that should match through thecbranch. Laravel generateswhere "a" = ? and "b" = ? or "c" = ?for the same chain, and SQL puts AND before OR, so the expected meaning is(a AND b) OR c.The bug was hidden because only the trivial two-clause case was covered by tests. The special case that rewrote the boolean of the first where based on the second one happened to produce the right result for two clauses only.
Changes
$or. This mirrors the precedence of the SQL generated by Laravel.compileWheres()no longer mutates$this->wheres. Nothing else in the package relies on that mutation, and the method becomes idempotent.orWhere, anorWhereNotin the middle of a chain, and a nested closure next to anorWhere.Backwards compatibility
The generated query changes for any chain of three clauses or more that mixes
whereandorWhere.compileWheres()is also used byupdate()anddelete(), so an application that relied on the old, more restrictive result will now match a wider set of documents, including on writes:This has a security dimension for applications that hand-write an authorization filter next to an
orWhere: the old behavior was fail-closed by accident, not by contract, and the new one matches Laravel and SQL. Applications that want the old grouping should make it explicit with a closure, which is stable across both versions:Eloquent global scopes are not affected.
callScope()goes throughaddNewWheresWithinGroup(), so SoftDeletes and multi-tenant scopes stay in their own nested group connected with "and", before and after this change.Because this changes the result of existing queries, it targets the next minor version and must appear as a behavioral change in the release notes.