Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 22 additions & 35 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1283,10 +1282,18 @@ protected function compileWheres(): array
// The wheres to compile.
$wheres = $this->wheres ?: [];

// We will add all compiled wheres to this array.
$compiled = [];
if (! $wheres) {
return [];
}

// 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 $i => &$where) {
foreach ($wheres as $i => $where) {
// Make sure the operator is in lowercase.
if (isset($where['operator'])) {
$where['operator'] = strtolower($where['operator']);
Expand Down Expand Up @@ -1327,17 +1334,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);
Expand All @@ -1347,30 +1343,21 @@ 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
}

// 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]];
// Start a new group when this where is connected with "or".
if ($i > 0 && str_starts_with($where['boolean'], 'or')) {
$groups[] = [];
}

// 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;
}
}
$groups[count($groups) - 1][] = $result;
}

return $compiled;
// 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,
);

return count($groups) === 1 ? $groups[0] : ['$or' => $groups];
}

protected function compileWhereBasic(array $where): array
Expand Down
136 changes: 136 additions & 0 deletions tests/Query/BuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,142 @@ 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' => [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

->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.

[
'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' => [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

->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.

[
'find' => [
[
'$or' => [
['age' => 1],
[
'$and' => [
['email' => 'foo'],
['name' => 'bar'],
],
],
],
],
[], // options
],
],
fn (Builder $builder) => $builder
->where('age', '=', 1)
->orWhere('email', '=', 'foo')
->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' => [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

->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.

[
'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' => [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

->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.

[
'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' => [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

->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.

[
'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' => [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

->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.

[
'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' => [
[
Expand Down