diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index a583f7045f02..2dd310f1cb86 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -769,6 +769,54 @@ public function orWhereColumn(string $first, string $second, ?bool $escape = nul return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape); } + /** + * Generates a WHERE EXISTS subquery. + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + */ + public function whereExists($subquery): static + { + return $this->_whereExists($subquery); + } + + /** + * Generates an OR WHERE EXISTS subquery. + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + */ + public function orWhereExists($subquery): static + { + return $this->_whereExists($subquery, false, 'OR '); + } + + /** + * Generates a WHERE NOT EXISTS subquery. + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + */ + public function whereNotExists($subquery): static + { + return $this->_whereExists($subquery, true); + } + + /** + * Generates an OR WHERE NOT EXISTS subquery. + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + */ + public function orWhereNotExists($subquery): static + { + return $this->_whereExists($subquery, true, 'OR '); + } + /** * @used-by whereColumn() * @used-by orWhereColumn() @@ -828,6 +876,35 @@ private function parseWhereColumnFirst(string $first): array return [$first, '=']; } + /** + * @used-by whereExists() + * @used-by orWhereExists() + * @used-by whereNotExists() + * @used-by orWhereNotExists() + * + * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery + * + * @return $this + * + * @throws InvalidArgumentException + */ + protected function _whereExists($subquery, bool $not = false, string $type = 'AND '): static + { + if (! $this->isSubquery($subquery)) { + throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function'])); + } + + $prefix = $this->QBWhere === [] ? $this->groupGetType('') : $this->groupGetType($type); + $operator = $not ? 'NOT EXISTS' : 'EXISTS'; + + $this->QBWhere[] = [ + 'condition' => "{$prefix}{$operator} {$this->buildSubquery($subquery, true)}", + 'escape' => false, + ]; + + return $this; + } + /** * @used-by where() * @used-by orWhere() diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index af0847072317..398f40008745 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\RawSql; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; @@ -489,6 +490,141 @@ public static function provideWhereColumnInvalidColumnThrowInvalidArgumentExcept ]; } + public function testWhereExistsSubQuery(): void + { + $expectedSQL = 'SELECT * FROM "users" WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")'; + + // Closure + $builder = $this->db->table('users'); + + $builder->whereExists(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('orders') + ->whereColumn('orders.user_id', 'users.id')); + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + + // Builder + $builder = $this->db->table('users'); + + $subQuery = $this->db->table('orders') + ->select('1', false) + ->whereColumn('orders.user_id', 'users.id'); + + $builder->whereExists($subQuery); + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + #[DataProvider('provideWhereExistsVariants')] + public function testWhereExistsVariants(string $method, string $expectedSQL): void + { + $builder = $this->db->table('users'); + + $builder->where('active', 1); + + $builder->{$method}(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('orders') + ->whereColumn('orders.user_id', 'users.id')); + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + /** + * @return iterable + */ + public static function provideWhereExistsVariants(): iterable + { + $exists = '(SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")'; + $baseQuery = 'SELECT * FROM "users" WHERE "active" = 1'; + + return [ + 'whereExists' => ['whereExists', "{$baseQuery} AND EXISTS {$exists}"], + 'orWhereExists' => ['orWhereExists', "{$baseQuery} OR EXISTS {$exists}"], + 'whereNotExists' => ['whereNotExists', "{$baseQuery} AND NOT EXISTS {$exists}"], + 'orWhereNotExists' => ['orWhereNotExists', "{$baseQuery} OR NOT EXISTS {$exists}"], + ]; + } + + public function testWhereExistsWithGroupedConditions(): void + { + $builder = $this->db->table('users'); + + $builder->groupStart() + ->whereExists(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('orders') + ->whereColumn('orders.user_id', 'users.id')) + ->orWhereNotExists(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('jobs') + ->whereColumn('jobs.user_id', 'users.id')) + ->groupEnd() + ->where('active', 1); + + $expectedSQL = 'SELECT * FROM "users" WHERE ( EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id") OR NOT EXISTS (SELECT 1 FROM "jobs" WHERE "jobs"."user_id" = "users"."id") ) AND "active" = 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testWhereExistsWithOuterAndInnerBinds(): void + { + $builder = $this->db->table('users'); + + $builder->where('active', 1) + ->whereExists(static fn (BaseBuilder $builder) => $builder + ->select('1', false) + ->from('orders') + ->where('orders.status', 'paid') + ->whereColumn('orders.user_id', 'users.id')); + + $expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 AND EXISTS (SELECT 1 FROM "orders" WHERE "orders"."status" = \'paid\' AND "orders"."user_id" = "users"."id")'; + $expectedBinds = [ + 'active' => [ + 1, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @param mixed $subquery + */ + #[DataProvider('provideWhereExistsInvalidSubqueryThrowInvalidArgumentException')] + public function testWhereExistsInvalidSubqueryThrowInvalidArgumentException($subquery): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('users'); + $builder->whereExists($subquery); + } + + /** + * @return iterable + */ + public static function provideWhereExistsInvalidSubqueryThrowInvalidArgumentException(): iterable + { + return [ + 'null' => [null], + 'array' => [[]], + 'stdClass' => [new stdClass()], + 'raw string' => ['SELECT 1'], + ]; + } + + public function testWhereExistsSameBaseBuilderObject(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('The subquery cannot be the same object as the main query object.'); + + $builder = $this->db->table('users'); + $builder->whereExists($builder); + } + public function testWhereIn(): void { $builder = $this->db->table('jobs'); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 9efb8fd61ea8..c918e688c1b3 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -211,6 +211,7 @@ Query Builder ------------- - Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`. +- Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`. - Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations. Forge diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 8b6787947eda..42d5437c70dc 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -395,6 +395,40 @@ $builder->orWhereColumn() This method is identical to ``whereColumn()``, except that multiple instances are joined by **OR**. +.. _query-builder-where-exists: + +$builder->whereExists() +----------------------- + +.. versionadded:: 4.8.0 + +Generates a ``WHERE EXISTS`` subquery. This method accepts either a Closure or +a ``BaseBuilder`` instance: + +.. literalinclude:: query_builder/124.php + +.. warning:: Raw SQL strings are not accepted. If you need to write the + ``EXISTS`` clause yourself, use ``where()`` with a manually escaped + condition. + +$builder->orWhereExists() +------------------------- + +This method is identical to ``whereExists()``, except that multiple instances +are joined by **OR**. + +$builder->whereNotExists() +-------------------------- + +This method is identical to ``whereExists()``, except that it generates a +``WHERE NOT EXISTS`` subquery. + +$builder->orWhereNotExists() +---------------------------- + +This method is identical to ``whereNotExists()``, except that multiple +instances are joined by **OR**. + $builder->whereIn() ------------------- @@ -1588,6 +1622,38 @@ Class Reference If ``$first`` does not end with a supported operator, ``=`` is used as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. + .. php:method:: whereExists($subquery) + + :param BaseBuilder|Closure $subquery: The subquery to check for matching rows + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE EXISTS`` subquery, joined with ``AND`` if appropriate. + + .. php:method:: orWhereExists($subquery) + + :param BaseBuilder|Closure $subquery: The subquery to check for matching rows + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE EXISTS`` subquery, joined with ``OR`` if appropriate. + + .. php:method:: whereNotExists($subquery) + + :param BaseBuilder|Closure $subquery: The subquery to check for matching rows + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE NOT EXISTS`` subquery, joined with ``AND`` if appropriate. + + .. php:method:: orWhereNotExists($subquery) + + :param BaseBuilder|Closure $subquery: The subquery to check for matching rows + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE NOT EXISTS`` subquery, joined with ``OR`` if appropriate. + .. php:method:: orWhereIn([$key = null[, $values = null[, $escape = null]]]) :param string $key: The field to search diff --git a/user_guide_src/source/database/query_builder/124.php b/user_guide_src/source/database/query_builder/124.php new file mode 100644 index 000000000000..5e2146eec715 --- /dev/null +++ b/user_guide_src/source/database/query_builder/124.php @@ -0,0 +1,15 @@ +whereExists(static function (BaseBuilder $builder) { + $builder->select('1', false) + ->from('orders') + ->whereColumn('orders.user_id', 'users.id'); +}); +// Produces: WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id") + +// With builder directly +$subQuery = $db->table('orders')->select('1', false)->whereColumn('orders.user_id', 'users.id'); +$builder->whereNotExists($subQuery);