Skip to content
Merged
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
32 changes: 32 additions & 0 deletions src/Query/Traits/WhereTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,38 @@ public function orWhereNot(mixed ...$args): self
return $this;
}

/**
* Wrap all currently registered WHERE tokens into a single nested AND-group.
*
* After the call, the WHERE state holds exactly one top-level token β€” a nested
* group containing everything added before. Subsequent `where/orWhere/...` calls
* append at the top level, alongside this group:
*
* $q->where('a', 1)->orWhere('a', 2)->wrapWhere()->where('b', 3);
* // WHERE (a = 1 OR a = 2) AND b = 3
*
* No-op when no where tokens have been registered yet.
*
* Typical use case: ORM scopes that must stay protected from a later `orWhere`
* added by user code.
*
* @return $this|self
*/
public function wrapWhere(): self
{
if ($this->whereTokens === []) {
return $this;
}

$this->whereTokens = [
['AND', '('],
...$this->whereTokens,
['', ')'],
];

return $this;
}

/**
* Convert various amount of where function arguments into valid where token.
*
Expand Down
245 changes: 245 additions & 0 deletions tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2565,6 +2565,251 @@ public function testWhereNotWithSequenceCalls(): void
);
}

public function testWrapWhereGroupsExistingConditions(): void
{
$select = $this->database
->select()
->from(['users'])
->where('name', 'Anton')
->orWhere('name', 'John')
->wrapWhere()
->where('active', true);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE ({name} = ? OR {name} = ?) AND {active} = ?',
$select,
);
}

public function testWrapWhereProtectsScopeFromLaterOrWhere(): void
{
// Models a soft-delete-style scope: condition added first, then wrapped,
// then a user-supplied orWhere β€” without wrapWhere the scope would be lost.
$select = $this->database
->select()
->from(['users'])
->where('deleted_at', null)
->wrapWhere()
->orWhere('id', 5);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE ({deleted_at} IS NULL) OR {id} = ?',
$select,
);
}

public function testWrapWhereOnEmptyWhereIsNoop(): void
{
$select = $this->database
->select()
->from(['users'])
->wrapWhere()
->where('active', true);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE {active} = ?',
$select,
);
}

public function testWrapWhereWithMultipleAndConditions(): void
{
$select = $this->database
->select()
->from(['users'])
->where('a', 1)
->where('b', 2)
->where('c', 3)
->wrapWhere()
->where('d', 4);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE ({a} = ? AND {b} = ? AND {c} = ?) AND {d} = ?',
$select,
);
}

public function testWrapWhereWithMixedAndOrChain(): void
{
$select = $this->database
->select()
->from(['users'])
->where('a', 1)
->andWhere('b', 2)
->orWhere('c', 3)
->wrapWhere()
->where('d', 4);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE ({a} = ? AND {b} = ? OR {c} = ?) AND {d} = ?',
$select,
);
}

public function testWrapWhereWithWhereNot(): void
{
$select = $this->database
->select()
->from(['users'])
->whereNot('status', 'blocked')
->orWhere('role', 'admin')
->wrapWhere()
->where('active', true);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE (NOT {status} = ? OR {role} = ?) AND {active} = ?',
$select,
);
}

public function testWrapWhereAroundClosureGroup(): void
{
$select = $this->database
->select()
->from(['users'])
->where(static function (SelectQuery $q): void {
$q->where('a', 1)->orWhere('b', 2);
})
->wrapWhere()
->where('c', 3);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE (({a} = ? OR {b} = ?)) AND {c} = ?',
$select,
);
}

public function testClosureGroupAddedAfterWrapWhere(): void
{
$select = $this->database
->select()
->from(['users'])
->where('a', 1)
->orWhere('b', 2)
->wrapWhere()
->andWhere(static function (SelectQuery $q): void {
$q->where('c', 3)->orWhere('d', 4);
});

$this->assertSameQuery(
'SELECT * FROM {users} WHERE ({a} = ? OR {b} = ?) AND ({c} = ? OR {d} = ?)',
$select,
);
}

public function testWrapWhereCalledTwiceInARowIsIdempotentForSql(): void
{
// Double-wrap adds a redundant pair of parens but is semantically harmless.
$select = $this->database
->select()
->from(['users'])
->where('a', 1)
->orWhere('b', 2)
->wrapWhere()
->wrapWhere()
->where('c', 3);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE (({a} = ? OR {b} = ?)) AND {c} = ?',
$select,
);
}

public function testWrapWhereStackedScopes(): void
{
// Models multiple stacked ORM scopes: each scope wraps the current state and
// appends its own AND-condition. Final SQL should always be logically equivalent
// to `user AND s1 AND s2`, with each layer isolated by parentheses.
$select = $this->database
->select()
->from(['users'])
->where('a', 1)
->orWhere('a', 2)
// first scope
->wrapWhere()
->where('s1', 'x')
// second scope
->wrapWhere()
->where('s2', 'y');

$this->assertSameQuery(
'SELECT * FROM {users} WHERE (({a} = ? OR {a} = ?) AND {s1} = ?) AND {s2} = ?',
$select,
);
}

public function testWrapWhereThenOrWhereNot(): void
{
$select = $this->database
->select()
->from(['users'])
->where('a', 1)
->andWhere('b', 2)
->wrapWhere()
->orWhereNot('c', 3);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE ({a} = ? AND {b} = ?) OR NOT {c} = ?',
$select,
);
}

public function testWrapWhereWithArraySyntaxAndOrToken(): void
{
$select = $this->database
->select()
->from(['users'])
->where([
'name' => 'John',
'@or' => [
['status' => 'active'],
['role' => 'admin'],
],
])
->wrapWhere()
->where('deleted_at', null);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE (({name} = ? AND ({status} = ? OR {role} = ?))) AND {deleted_at} IS NULL',
$select,
);
}

public function testWrapWhereWithBetweenAndInOperators(): void
{
$select = $this->database
->select()
->from(['users'])
->where('age', 'between', 18, 65)
->orWhere('role', 'IN', new Parameter(['admin', 'editor']))
->wrapWhere()
->where('active', true);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE ({age} BETWEEN ? AND ? OR {role} IN (?, ?)) AND {active} = ?',
$select,
);
}

public function testWrapWhereWithLeadingOrWhere(): void
{
// Edge case: chain starts with orWhere β€” the leading boolean is suppressed by
// the compiler when it's the first token, so behavior should be identical to
// starting with where().
$select = $this->database
->select()
->from(['users'])
->orWhere('a', 1)
->orWhere('b', 2)
->wrapWhere()
->where('c', 3);

$this->assertSameQuery(
'SELECT * FROM {users} WHERE ({a} = ? OR {b} = ?) AND {c} = ?',
$select,
);
}

public function testAndWhereNotWithArrayOr(): void
{
$select = $this->database
Expand Down
49 changes: 49 additions & 0 deletions tests/Database/Unit/Query/Tokens/SelectQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,53 @@ public function testBuildQuery(): void
$select->getTokens(),
);
}

public function testWrapWhereOnEmptyIsNoop(): void
{
$select = (new SelectQuery())->from('table');
$select->wrapWhere();

$this->assertSame([], $select->getTokens()['where']);
}

public function testWrapWhereEnclosesExistingTokens(): void
{
$select = (new SelectQuery())
->from('table')
->where('a', 1)
->orWhere('a', 2)
->wrapWhere()
->where('b', 3);

$this->assertEquals(
[
['AND', '('],
['AND', ['a', '=', new Parameter(1)]],
['OR', ['a', '=', new Parameter(2)]],
['', ')'],
['AND', ['b', '=', new Parameter(3)]],
],
$select->getTokens()['where'],
);
}

public function testWrapWhereProtectsAgainstLaterOrWhere(): void
{
// Simulates a scope condition guarded by wrapWhere against a later user `orWhere`.
$select = (new SelectQuery())
->from('table')
->where('deleted_at', null)
->wrapWhere()
->orWhere('id', 5);

$this->assertEquals(
[
['AND', '('],
['AND', ['deleted_at', '=', new Parameter(null)]],
['', ')'],
['OR', ['id', '=', new Parameter(5)]],
],
$select->getTokens()['where'],
);
}
}
Loading