From e18adda141e84298818ec038183d9fcaf3d62d31 Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Tue, 2 Apr 2024 14:07:49 +0300 Subject: [PATCH 1/5] Add support for NOT operator --- src/Driver/Compiler.php | 6 + src/Query/Traits/WhereTrait.php | 63 ++++++++ .../Driver/Common/Query/SelectQueryTest.php | 153 +++++++++++++++--- 3 files changed, 198 insertions(+), 24 deletions(-) diff --git a/src/Driver/Compiler.php b/src/Driver/Compiler.php index 3a68cdf1..45e4e481 100644 --- a/src/Driver/Compiler.php +++ b/src/Driver/Compiler.php @@ -418,6 +418,12 @@ protected function where(QueryParameters $params, Quoter $q, array $tokens): str // first condition in group/query, no any AND, OR required if ($activeGroup) { + // first condition can have a `NOT` keyword (WHERE NOT ...) + if (\str_contains(\strtoupper($boolean), 'NOT')) { + $statement .= 'NOT'; + $statement .= ' '; + } + // next conditions require AND or OR $activeGroup = false; } else { diff --git a/src/Query/Traits/WhereTrait.php b/src/Query/Traits/WhereTrait.php index 01fe2643..83b29d0f 100644 --- a/src/Query/Traits/WhereTrait.php +++ b/src/Query/Traits/WhereTrait.php @@ -83,6 +83,69 @@ public function orWhere(mixed ...$args): self return $this; } + /** + * Simple WHERE NOT condition with various set of arguments. + * + * @param mixed ...$args [(column, value), (column, operator, value)] + * + * @throws BuilderException + * + * @return $this|self + */ + public function whereNot(mixed ...$args): self + { + $this->registerToken( + 'AND NOT', + $args, + $this->whereTokens, + $this->whereWrapper() + ); + + return $this; + } + + /** + * Simple AND WHERE NOT condition with various set of arguments. + * + * @param mixed ...$args [(column, value), (column, operator, value)] + * + * @throws BuilderException + * + * @return $this|self + */ + public function andWhereNot(mixed ...$args): self + { + $this->registerToken( + 'AND NOT', + $args, + $this->whereTokens, + $this->whereWrapper() + ); + + return $this; + } + + /** + * Simple OR WHERE NOT condition with various set of arguments. + * + * @param mixed ...$args [(column, value), (column, operator, value)] + * + * @throws BuilderException + * + * @return $this|self + */ + public function orWhereNot(mixed ...$args): self + { + $this->registerToken( + 'OR NOT', + $args, + $this->whereTokens, + $this->whereWrapper() + ); + + 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 c5e0b657..3436b376 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -70,20 +70,13 @@ public function testSimpleWhere(): void public function testArrayWhere(): void { - $select = $this->database->select() - ->from('table') - ->where('id', 'IN', new Parameter([1, 2, 3, 4])); + $select = $this->database + ->select() + ->from('table') + ->where('id', 'IN', new Parameter([1, 2, 3, 4])); $this->assertSameQuery('SELECT * FROM {table} WHERE {id} IN (?, ?, ?, ?)', $select); - $this->assertSameParameters( - [ - 1, - 2, - 3, - 4, - ], - $select - ); + $this->assertSameParameters([1, 2, 3, 4], $select); } public function testCompileNestedQuery(): void @@ -208,11 +201,10 @@ static function (): void { public function testSelectWithSimpleWhereNotNull(): void { $select = $this->database->select()->distinct()->from(['users'])->where('name', '!=', null); + $this->assertSameQuery('SELECT DISTINCT * FROM {users} WHERE {name} IS NOT NULL', $select); - $this->assertSameQuery( - 'SELECT DISTINCT * FROM {users} WHERE {name} IS NOT NULL', - $select - ); + $select = $this->database->select()->distinct()->from(['users'])->whereNot('name', null); + $this->assertSameQuery('SELECT DISTINCT * FROM {users} WHERE NOT {name} IS NULL', $select); } public function testSelectWithWhereWithOperator(): void @@ -239,13 +231,11 @@ public function testSelectWithWhereWithBetween(): void public function testSelectWithWhereWithNotBetween(): void { - $select = $this->database->select()->distinct()->from(['users']) - ->where('balance', 'NOT BETWEEN', 0, 1000); + $select = $this->database->select()->distinct()->from(['users'])->where('balance', 'NOT BETWEEN', 0, 1000); + $this->assertSameQuery('SELECT DISTINCT * FROM {users} WHERE {balance} NOT BETWEEN ? AND ?', $select); - $this->assertSameQuery( - 'SELECT DISTINCT * FROM {users} WHERE {balance} NOT BETWEEN ? AND ?', - $select - ); + $select = $this->database->select()->distinct()->from(['users'])->whereNot('balance', 'BETWEEN', 0, 1000); + $this->assertSameQuery('SELECT DISTINCT * FROM {users} WHERE NOT {balance} BETWEEN ? AND ?', $select); } public function testSelectWithWhereBetweenBadValue(): void @@ -253,8 +243,7 @@ public function testSelectWithWhereBetweenBadValue(): void $this->expectExceptionMessage('Between statements expects exactly 2 values'); $this->expectException(BuilderException::class); - $select = $this->database->select()->distinct()->from(['users']) - ->where('balance', 'BETWEEN', 0); + $this->database->select()->distinct()->from(['users'])->where('balance', 'BETWEEN', 0); } public function testSelectWithFullySpecificColumnNameInWhere(): void @@ -2307,4 +2296,120 @@ public function testLeftJoinQuoting(): void $select ); } + + // WHERE NOT + public function testSimpleWhereNot(): void + { + $select = $this->db() + ->select('*') + ->from('table') + ->whereNot('name', 'John Doe'); + + $this->assertSameQuery('SELECT * FROM {table} WHERE NOT {name} = \'John Doe\'', (string) $select); + } + + public function testArrayWhereNot(): void + { + $select = $this->database->select() + ->from('table') + ->whereNot('id', 'IN', [1, 2, 3, 4]); + + $this->assertSameQuery('SELECT * FROM {table} WHERE NOT {id} IN (?, ?, ?, ?)', $select); + $this->assertSameParameters([1, 2, 3, 4], $select); + } + + public function testSelectWithWhereNotWithOperator(): void + { + $select = $this->database->select()->distinct()->from(['users'])->whereNot('name', 'LIKE', 'Anton%'); + + $this->assertSameQuery('SELECT DISTINCT * FROM {users} WHERE NOT {name} LIKE ?', $select); + } + + public function testSelectWithWhereNotWithBetween(): void + { + $select = $this->database->select()->distinct()->from(['users'])->whereNot('balance', 'BETWEEN', 0, 1000); + + $this->assertSameQuery('SELECT DISTINCT * FROM {users} WHERE NOT {balance} BETWEEN ? AND ?', $select); + } + + public function testWhereWithOrWhereNot(): void + { + $select = $this->db() + ->select('*') + ->from('table') + ->where('status', 'active') + ->orWhereNot('name', 'John Doe'); + + $this->assertSameQuery( + 'SELECT * FROM {table} WHERE {status} = \'active\' OR NOT {name} = \'John Doe\'', + (string) $select + ); + } + + public function testWhereWithAndWhereNot(): void + { + $select = $this->db() + ->select('*') + ->from('table') + ->where('status', 'active') + ->andWhereNot('name', 'John Doe'); + + $this->assertSameQuery( + 'SELECT * FROM {table} WHERE {status} = \'active\' AND NOT {name} = \'John Doe\'', + (string) $select + ); + } + + public function testWhereNotAndOrWhere(): void + { + $select = $this->db() + ->select('*') + ->from('table') + ->whereNot('status', 'blocked') + ->orWhere('id', 1); + + $this->assertSameQuery( + 'SELECT * FROM {table} WHERE NOT {status} = \'blocked\' OR {id} = 1', + (string) $select + ); + } + + public function testCompileNestedQueryWithWhereNot(): void + { + $select = $this->db() + ->select('*') + ->from('table', 'table2') + ->where(['name' => 'Antony']) + ->whereNot('id', 'in', (new SelectQuery())->from('other')->columns('id')->where('x', 123)); + + $this->assertSameQuery( + 'SELECT * FROM {table}, {table2} WHERE {name} = \'Antony\' AND NOT {id} IN ( + SELECT {id} FROM {other} WHERE {x} = 123)', + (string) $select + ); + + $this->assertSameParameters(['Antony', 123], $select); + } + + public function testPrefixedSelectWithFullySpecificColumnNameInWhereNot(): void + { + $select = $this->db('prefixed', 'prefix_') + ->select() + ->distinct() + ->from(['users']) + ->whereNot('users.balance', 0); + + $this->assertSameQuery('SELECT DISTINCT * FROM {prefix_users} WHERE NOT {prefix_users}.{balance} = ?', $select); + } + + public function testPrefixedSelectWithFullySpecificColumnNameInWhereNotButAliased(): void + { + $select = $this->db('prefixed', 'prefix_')->select()->distinct()->from(['users as u']) + ->whereNot('u.balance', 0); + + $this->assertSameQuery( + 'SELECT DISTINCT * FROM {prefix_users} AS {u} WHERE NOT {u}.{balance} = ?', + $select + ); + } } From 277baa90904ba466d0ef3dc70bb23c02429cd842 Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Wed, 3 Apr 2024 16:39:49 +0300 Subject: [PATCH 2/5] Fix nested not conditions --- src/Driver/CompilerInterface.php | 2 + src/Query/Traits/TokenTrait.php | 38 +++- .../Driver/Common/Query/SelectQueryTest.php | 173 ++++++++++++++++++ 3 files changed, 204 insertions(+), 9 deletions(-) diff --git a/src/Driver/CompilerInterface.php b/src/Driver/CompilerInterface.php index 8e1b17d5..a1861409 100644 --- a/src/Driver/CompilerInterface.php +++ b/src/Driver/CompilerInterface.php @@ -27,6 +27,8 @@ interface CompilerInterface public const TOKEN_AND = '@AND'; public const TOKEN_OR = '@OR'; + public const TOKEN_AND_NOT = '@AND NOT'; + public const TOKEN_OR_NOT = '@OR NOT'; /** * @param string $identifier diff --git a/src/Query/Traits/TokenTrait.php b/src/Query/Traits/TokenTrait.php index 869d2dd0..82bdff43 100644 --- a/src/Query/Traits/TokenTrait.php +++ b/src/Query/Traits/TokenTrait.php @@ -52,12 +52,7 @@ protected function registerToken(string $boolean, array $params, array &$tokens, } if (\count($complex) === 1) { - $this->flattenWhere( - $boolean === 'AND' ? CompilerInterface::TOKEN_AND : CompilerInterface::TOKEN_OR, - $complex, - $tokens, - $wrapper - ); + $this->flattenWhere($this->booleanToToken($boolean), $complex, $tokens, $wrapper); return; } @@ -161,7 +156,7 @@ protected function registerToken(string $boolean, array $params, array &$tokens, */ private function flattenWhere(string $grouper, array $where, array &$tokens, callable $wrapper): void { - $boolean = ($grouper === CompilerInterface::TOKEN_AND ? 'AND' : 'OR'); + $boolean = $this->tokenToBoolean($grouper); foreach ($where as $key => $value) { // Support for closures @@ -175,7 +170,12 @@ private function flattenWhere(string $grouper, array $where, array &$tokens, cal $token = strtoupper($key); // Grouping identifier (@OR, @AND), MongoDB like style - if ($token === CompilerInterface::TOKEN_AND || $token === CompilerInterface::TOKEN_OR) { + if ( + $token === CompilerInterface::TOKEN_AND || + $token === CompilerInterface::TOKEN_OR || + $token === CompilerInterface::TOKEN_AND_NOT || + $token === CompilerInterface::TOKEN_OR_NOT + ) { $tokens[] = [$boolean, '(']; foreach ($value as $nested) { @@ -184,7 +184,7 @@ private function flattenWhere(string $grouper, array $where, array &$tokens, cal continue; } - $tokens[] = [$token === CompilerInterface::TOKEN_AND ? 'AND' : 'OR', '(']; + $tokens[] = [$this->tokenToBoolean($token), '(']; $this->flattenWhere(CompilerInterface::TOKEN_AND, $nested, $tokens, $wrapper); $tokens[] = ['', ')']; } @@ -267,4 +267,24 @@ private function pushCondition(string $innerJoiner, string $key, array $where, & return $tokens; } + + private function tokenToBoolean(string $token): string + { + return match ($token) { + CompilerInterface::TOKEN_AND => 'AND', + CompilerInterface::TOKEN_AND_NOT => 'AND NOT', + CompilerInterface::TOKEN_OR_NOT => 'OR NOT', + default => 'OR', + }; + } + + private function booleanToToken(string $boolean): string + { + return match ($boolean) { + 'AND' => CompilerInterface::TOKEN_AND, + 'AND NOT' => CompilerInterface::TOKEN_AND_NOT, + 'OR NOT' => CompilerInterface::TOKEN_OR_NOT, + default => 'OR', + }; + } } diff --git a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php index 3436b376..81dbe24f 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -588,6 +588,27 @@ public function testAndShortWhereOR(): void ); } + public function testAndShortWhereOrNot(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'Anton']) + ->andWhere( + [ + '@or not' => [ + ['value' => 1], + ['value' => ['>' => 12]], + ], + ] + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? AND (NOT {value} = ? OR NOT {value} > ?)', + $select + ); + } + public function testOrShortWhereOR(): void { $select = $this->database @@ -609,6 +630,27 @@ public function testOrShortWhereOR(): void ); } + public function testOrShortWhereOrNot(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'Anton']) + ->orWhere( + [ + '@or not' => [ + ['value' => 1], + ['value' => ['>' => 12]], + ], + ] + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? OR (NOT {value} = ? OR NOT {value} > ?)', + $select + ); + } + public function testAndShortWhereAND(): void { $select = $this->database @@ -630,6 +672,27 @@ public function testAndShortWhereAND(): void ); } + public function testAndShortWhereAndNot(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'Anton']) + ->andWhere( + [ + '@and not' => [ + ['value' => 1], + ['value' => ['>' => 12]], + ], + ] + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? AND (NOT {value} = ? AND NOT {value} > ?)', + $select + ); + } + public function testOrShortWhereAND(): void { $select = $this->database @@ -651,6 +714,27 @@ public function testOrShortWhereAND(): void ); } + public function testOrShortWhereAndNot(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'Anton']) + ->orWhere( + [ + '@and not' => [ + ['value' => 1], + ['value' => ['>' => 12]], + ], + ] + ); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? OR (NOT {value} = ? AND NOT {value} > ?)', + $select + ); + } + public function testBadShortExpression(): void { $this->expectException(BuilderException::class); @@ -2412,4 +2496,93 @@ public function testPrefixedSelectWithFullySpecificColumnNameInWhereNotButAliase $select ); } + + public function testShortWhereNotMultiple(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->whereNot([ + 'name' => 'John Doe', + 'value' => 1, + ]); + + $this->assertSameQuery('SELECT * FROM {users} WHERE NOT ({name} = ? AND {value} = ?)', $select); + } + + public function testAndWhereNotWithArrayOr(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'John Doe']) + ->andWhereNot([ + '@or' => [ + ['id' => ['between' => [10, 100]], 'name' => 'John Doe'], + ['status' => 'disabled'] + ] + ]); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? AND NOT (({id} BETWEEN ? AND ? AND {name} = ?) OR {status} = ?)', + $select + ); + } + + public function testAndWhereNotWithArrayAnd(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'John Doe']) + ->andWhereNot([ + '@and' => [ + ['id' => ['between' => [10, 100]], 'name' => 'John Doe'], + ['status' => 'disabled'] + ] + ]); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? AND NOT (({id} BETWEEN ? AND ? AND {name} = ?) AND {status} = ?)', + $select + ); + } + + public function testOrWhereNotWithArrayOr(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'John Doe']) + ->orWhereNot([ + '@or' => [ + ['id' => ['between' => [10, 100]], 'name' => 'John Doe'], + ['status' => 'disabled'] + ] + ]); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? OR NOT (({id} BETWEEN ? AND ? AND {name} = ?) OR {status} = ?)', + $select + ); + } + + public function testOrWhereNotWithArrayAnd(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->where(['name' => 'John Doe']) + ->orWhereNot([ + '@and' => [ + ['id' => ['between' => [10, 100]], 'name' => 'John Doe'], + ['status' => 'disabled'] + ] + ]); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE {name} = ? OR NOT (({id} BETWEEN ? AND ? AND {name} = ?) AND {status} = ?)', + $select + ); + } } From 76043969e696d8216a4314602d3b4c4e8a36aa32 Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Wed, 3 Apr 2024 16:41:39 +0300 Subject: [PATCH 3/5] Fix CS --- .../Driver/Common/Query/SelectQueryTest.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php index 81dbe24f..03dcc5b9 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -2519,8 +2519,8 @@ public function testAndWhereNotWithArrayOr(): void ->andWhereNot([ '@or' => [ ['id' => ['between' => [10, 100]], 'name' => 'John Doe'], - ['status' => 'disabled'] - ] + ['status' => 'disabled'], + ], ]); $this->assertSameQuery( @@ -2538,8 +2538,8 @@ public function testAndWhereNotWithArrayAnd(): void ->andWhereNot([ '@and' => [ ['id' => ['between' => [10, 100]], 'name' => 'John Doe'], - ['status' => 'disabled'] - ] + ['status' => 'disabled'], + ], ]); $this->assertSameQuery( @@ -2557,8 +2557,8 @@ public function testOrWhereNotWithArrayOr(): void ->orWhereNot([ '@or' => [ ['id' => ['between' => [10, 100]], 'name' => 'John Doe'], - ['status' => 'disabled'] - ] + ['status' => 'disabled'], + ], ]); $this->assertSameQuery( @@ -2576,8 +2576,8 @@ public function testOrWhereNotWithArrayAnd(): void ->orWhereNot([ '@and' => [ ['id' => ['between' => [10, 100]], 'name' => 'John Doe'], - ['status' => 'disabled'] - ] + ['status' => 'disabled'], + ], ]); $this->assertSameQuery( From f58a93ed7a487a228bd2e419e6ee7faff821725f Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Wed, 3 Apr 2024 16:47:33 +0300 Subject: [PATCH 4/5] Add tests with sequence calls --- .../Driver/Common/Query/SelectQueryTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php index 03dcc5b9..4cc3cd45 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectQueryTest.php @@ -2510,6 +2510,22 @@ public function testShortWhereNotMultiple(): void $this->assertSameQuery('SELECT * FROM {users} WHERE NOT ({name} = ? AND {value} = ?)', $select); } + public function testWhereNotWithSequenceCalls(): void + { + $select = $this->database + ->select() + ->from(['users']) + ->whereNot('status', 'blocked') + ->where('email_confirmed', true) + ->whereNot('name', 'John Doe') + ->orWhere('id', 1); + + $this->assertSameQuery( + 'SELECT * FROM {users} WHERE NOT {status} = ? AND {email_confirmed} = ? AND NOT {name} = ? OR {id}=?', + $select + ); + } + public function testAndWhereNotWithArrayOr(): void { $select = $this->database From 2b37d6ceacfa3231ef2c30cd081c81472b073b83 Mon Sep 17 00:00:00 2001 From: Maxim Smakouz Date: Wed, 3 Apr 2024 17:16:58 +0300 Subject: [PATCH 5/5] Update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c88711ab..64f56f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # CHANGELOG -v2.10.0 (01.04.2024) +v2.10.0 (04.04.2024) -------------------- +- Add support for the `NOT` operator in SQL queries. New methods `whereNot`, `andWhereNot`, and `orWhereNot` + have been added to the query builder by @msmakouz (#185) - Add `mediumText` column type by @msmakouz (#178) - Fix caching of SQL insert query with Fragment values by @msmakouz (#177) - Fix detection of enum values in PostgreSQL when a enum field has only one value by @msmakouz (#181)