diff --git a/src/Driver/Compiler.php b/src/Driver/Compiler.php index b3a8b269..1521cad6 100644 --- a/src/Driver/Compiler.php +++ b/src/Driver/Compiler.php @@ -422,6 +422,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/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/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..4cc3cd45 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 @@ -599,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 @@ -620,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 @@ -641,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 @@ -662,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); @@ -2307,4 +2380,225 @@ 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 + ); + } + + 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 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 + ->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 + ); + } }