diff --git a/src/Query/Traits/WhereTrait.php b/src/Query/Traits/WhereTrait.php index 6fe8ffbc..bb91e27c 100644 --- a/src/Query/Traits/WhereTrait.php +++ b/src/Query/Traits/WhereTrait.php @@ -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. * diff --git a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php index d4a37ae1..6e6378ba 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -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 diff --git a/tests/Database/Unit/Query/Tokens/SelectQueryTest.php b/tests/Database/Unit/Query/Tokens/SelectQueryTest.php index 777aabde..61f90e0a 100644 --- a/tests/Database/Unit/Query/Tokens/SelectQueryTest.php +++ b/tests/Database/Unit/Query/Tokens/SelectQueryTest.php @@ -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'], + ); + } }