From 38b6cbafd463d19c818182a1dfc9603580135f74 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 22 Aug 2026 14:56:45 +0900 Subject: [PATCH 1/3] fix: correct AND/OR precedence in compileWheres() 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. --- src/Query/Builder.php | 61 +++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 0f263a561..aaa3cbada 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -40,7 +40,6 @@ use function array_key_first; use function array_keys; use function array_map; -use function array_merge; use function array_replace; use function array_values; use function assert; @@ -1283,10 +1282,15 @@ protected function compileWheres(): array // The wheres to compile. $wheres = $this->wheres ?: []; - // We will add all compiled wheres to this array. - $compiled = []; + if (! $wheres) { + return []; + } + + // Compile each where, paired with how it connects to the previous one + // ("and" or "or"; the connector of the very first where is unused). + $items = []; - foreach ($wheres as $i => &$where) { + foreach ($wheres as $where) { // Make sure the operator is in lowercase. if (isset($where['operator'])) { $where['operator'] = strtolower($where['operator']); @@ -1327,17 +1331,6 @@ protected function compileWheres(): array ]; } - // In a sequence of "where" clauses, the logical operator of the - // first "where" is determined by the 2nd "where". - // $where['boolean'] = "and", "or", "and not" or "or not" - if ( - $i === 0 && count($wheres) > 1 - && str_starts_with($where['boolean'], 'and') - && str_starts_with($wheres[$i + 1]['boolean'], 'or') - ) { - $where['boolean'] = 'or' . (str_ends_with($where['boolean'], 'not') ? ' not' : ''); - } - // We use different methods to compile different wheres. $method = 'compileWhere' . $where['type']; $result = $this->{$method}($where); @@ -1347,30 +1340,30 @@ protected function compileWheres(): array $result = ['$nor' => [$result]]; } - // Wrap the where with an $or operator. - if (str_starts_with($where['boolean'], 'or')) { - $result = ['$or' => [$result]]; - // phpcs:ignore Squiz.ControlStructures.ControlSignature.SpaceAfterCloseBrace - } + $connector = str_starts_with($where['boolean'], 'or') ? 'or' : 'and'; - // If there are multiple wheres, we will wrap it with $and. This is needed - // to make nested wheres work. - elseif (count($wheres) > 1) { - $result = ['$and' => [$result]]; - } + $items[] = [$connector, $result]; + } - // Merge the compiled where with the others. - // array_merge_recursive can't be used here because it converts int keys to sequential int. - foreach ($result as $key => $value) { - if (in_array($key, ['$and', '$or', '$nor'])) { - $compiled[$key] = array_merge($compiled[$key] ?? [], $value); - } else { - $compiled[$key] = $value; - } + // Group consecutive "and"-connected wheres together, starting a new + // group at each "or". This mirrors SQL's AND-before-OR precedence, + // where "a and b or c" means "(a and b) or c", not "a and b and c". + $groups = [[]]; + + foreach ($items as $i => [$connector, $result]) { + if ($i > 0 && $connector === 'or') { + $groups[] = []; } + + $groups[count($groups) - 1][] = $result; } - return $compiled; + $groups = array_map( + static fn (array $group) => count($group) === 1 ? $group[0] : ['$and' => $group], + $groups, + ); + + return count($groups) === 1 ? $groups[0] : ['$or' => $groups]; } protected function compileWhereBasic(array $where): array From b9c004bd7151d2263a8d6885c0beeda55f49db47 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 22 Aug 2026 14:56:56 +0900 Subject: [PATCH 2/3] test: verify AND/OR precedence for mixed where/orWhere chains 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). --- tests/Query/BuilderTest.php | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index cf132632b..58fa16b73 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -238,6 +238,54 @@ public static function provideQueryBuilderToMql(): iterable ->orWhere('email', '=', 'foo'), ]; + // "a and b or c" must mean "(a and b) or c", matching SQL's AND-before-OR precedence. + yield 'where where orWhere' => [ + [ + 'find' => [ + [ + '$or' => [ + [ + '$and' => [ + ['age' => 1], + ['name' => 'bar'], + ], + ], + ['email' => 'foo'], + ], + ], + [], // options + ], + ], + fn (Builder $builder) => $builder + ->where('age', '=', 1) + ->where('name', '=', 'bar') + ->orWhere('email', '=', 'foo'), + ]; + + // "a or b and c" must mean "a or (b and c)", matching SQL's AND-before-OR precedence. + yield 'where orWhere where' => [ + [ + 'find' => [ + [ + '$or' => [ + ['age' => 1], + [ + '$and' => [ + ['email' => 'foo'], + ['name' => 'bar'], + ], + ], + ], + ], + [], // options + ], + ], + fn (Builder $builder) => $builder + ->where('age', '=', 1) + ->orWhere('email', '=', 'foo') + ->where('name', '=', 'bar'), + ]; + /** @see DatabaseQueryBuilderTest::testBasicOrWhereNot() */ yield 'orWhereNot' => [ [ From cc0a82ee2cf57bac371c64c8e1d58347de7b6826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 24 Aug 2026 13:09:26 +0200 Subject: [PATCH 3/3] PHPLARA-268 Build where groups in a single pass and add precedence tests 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. --- src/Query/Builder.php | 26 +++++------ tests/Query/BuilderTest.php | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index aaa3cbada..d8922cdca 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1286,11 +1286,14 @@ protected function compileWheres(): array return []; } - // Compile each where, paired with how it connects to the previous one - // ("and" or "or"; the connector of the very first where is unused). - $items = []; + // Consecutive "and"-connected wheres are collected in the same group, and + // a new group starts at each "or". This mirrors the operator precedence of + // the SQL generated by Laravel, where "a and b or c" means "(a and b) or c" + // and not "a and b and c". The connector of the first where is ignored, as + // a leading "orWhere" behaves like a "where". + $groups = [[]]; - foreach ($wheres as $where) { + foreach ($wheres as $i => $where) { // Make sure the operator is in lowercase. if (isset($where['operator'])) { $where['operator'] = strtolower($where['operator']); @@ -1340,24 +1343,15 @@ protected function compileWheres(): array $result = ['$nor' => [$result]]; } - $connector = str_starts_with($where['boolean'], 'or') ? 'or' : 'and'; - - $items[] = [$connector, $result]; - } - - // Group consecutive "and"-connected wheres together, starting a new - // group at each "or". This mirrors SQL's AND-before-OR precedence, - // where "a and b or c" means "(a and b) or c", not "a and b and c". - $groups = [[]]; - - foreach ($items as $i => [$connector, $result]) { - if ($i > 0 && $connector === 'or') { + // Start a new group when this where is connected with "or". + if ($i > 0 && str_starts_with($where['boolean'], 'or')) { $groups[] = []; } $groups[count($groups) - 1][] = $result; } + // A group of a single where needs no $and wrapper. $groups = array_map( static fn (array $group) => count($group) === 1 ? $group[0] : ['$and' => $group], $groups, diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 58fa16b73..d224f00d8 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -286,6 +286,94 @@ public static function provideQueryBuilderToMql(): iterable ->where('name', '=', 'bar'), ]; + // "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' => [ + [ + 'find' => [ + [ + '$or' => [ + ['a' => 1], + ['$and' => [['b' => 2], ['c' => 3]]], + ['$and' => [['d' => 4], ['e' => 5]]], + ], + ], + [], // options + ], + ], + fn (Builder $builder) => $builder + ->where('a', 1) + ->orWhere('b', 2) + ->where('c', 3) + ->orWhere('d', 4) + ->where('e', 5), + ]; + + // A leading "orWhere" behaves like a "where". + yield 'leading orWhere then where' => [ + [ + 'find' => [ + [ + '$and' => [ + ['a' => 1], + ['b' => 2], + ], + ], + [], // options + ], + ], + fn (Builder $builder) => $builder + ->orWhere('a', 1) + ->where('b', 2), + ]; + + // "a or not b and c" must mean "a or (not b and c)". + yield 'where orWhereNot where' => [ + [ + 'find' => [ + [ + '$or' => [ + ['a' => 1], + [ + '$and' => [ + ['$nor' => [['b' => 2]]], + ['c' => 3], + ], + ], + ], + ], + [], // options + ], + ], + fn (Builder $builder) => $builder + ->where('a', 1) + ->orWhereNot('b', 2) + ->where('c', 3), + ]; + + // "a or (b or c) and d" must mean "a or ((b or c) and d)". + yield 'where orWhere nested where' => [ + [ + 'find' => [ + [ + '$or' => [ + ['a' => 1], + [ + '$and' => [ + ['$or' => [['b' => 2], ['c' => 3]]], + ['d' => 4], + ], + ], + ], + ], + [], // options + ], + ], + fn (Builder $builder) => $builder + ->where('a', 1) + ->orWhere(fn (Builder $query) => $query->where('b', 2)->orWhere('c', 3)) + ->where('d', 4), + ]; + /** @see DatabaseQueryBuilderTest::testBasicOrWhereNot() */ yield 'orWhereNot' => [ [