From 4ca9a14ae84f691b67f99501b0e11676bfdfb0fe Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sun, 10 May 2026 16:17:30 +0200 Subject: [PATCH 1/4] feat(database): add Query Builder exists conditions - Add whereExists() and whereNotExists() condition helpers - Support Closure and BaseBuilder subqueries - Document usage and raw SQL limitations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 77 ++++++++++ tests/system/Database/Builder/WhereTest.php | 136 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + .../source/database/query_builder.rst | 66 +++++++++ .../source/database/query_builder/124.php | 15 ++ 5 files changed, 295 insertions(+) create mode 100644 user_guide_src/source/database/query_builder/124.php 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 d8d6ed9b2776..3da26b39504c 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -212,6 +212,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); From 129fae76f12d3266626576082777a402ca1fdf4b Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 11 May 2026 18:28:14 +0200 Subject: [PATCH 2/4] refactor(database): address whereExists review feedbacks Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 10 +++++----- user_guide_src/source/database/query_builder.rst | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 2dd310f1cb86..df510fc5ad2b 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -778,7 +778,7 @@ public function orWhereColumn(string $first, string $second, ?bool $escape = nul */ public function whereExists($subquery): static { - return $this->_whereExists($subquery); + return $this->whereExistsSubquery($subquery); } /** @@ -790,7 +790,7 @@ public function whereExists($subquery): static */ public function orWhereExists($subquery): static { - return $this->_whereExists($subquery, false, 'OR '); + return $this->whereExistsSubquery($subquery, false, 'OR '); } /** @@ -802,7 +802,7 @@ public function orWhereExists($subquery): static */ public function whereNotExists($subquery): static { - return $this->_whereExists($subquery, true); + return $this->whereExistsSubquery($subquery, true); } /** @@ -814,7 +814,7 @@ public function whereNotExists($subquery): static */ public function orWhereNotExists($subquery): static { - return $this->_whereExists($subquery, true, 'OR '); + return $this->whereExistsSubquery($subquery, true, 'OR '); } /** @@ -888,7 +888,7 @@ private function parseWhereColumnFirst(string $first): array * * @throws InvalidArgumentException */ - protected function _whereExists($subquery, bool $not = false, string $type = 'AND '): static + protected function whereExistsSubquery($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'])); diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 42d5437c70dc..454cb310b09f 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -409,7 +409,7 @@ a ``BaseBuilder`` instance: .. warning:: Raw SQL strings are not accepted. If you need to write the ``EXISTS`` clause yourself, use ``where()`` with a manually escaped - condition. + condition. See :ref:`query-builder-where-rawsql`. $builder->orWhereExists() ------------------------- From 66a17ef47039ed42a2d11c85f0ab30008c64f3c1 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 11 May 2026 19:54:24 +0200 Subject: [PATCH 3/4] Trigger CI From c922eb21edeec972cb83f25a449025793284e356 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 11 May 2026 22:37:07 +0200 Subject: [PATCH 4/4] docs(database): add Model whereExists method annotations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Model.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/system/Model.php b/system/Model.php index 6952cbc86e5d..ffa4889900a3 100644 --- a/system/Model.php +++ b/system/Model.php @@ -73,7 +73,9 @@ * @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) * @method $this orWhere($key, $value = null, ?bool $escape = null) * @method $this orWhereColumn(string $first, string $second, ?bool $escape = null) + * @method $this orWhereExists($subquery) * @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null) + * @method $this orWhereNotExists($subquery) * @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this select($select = '*', ?bool $escape = null) * @method $this selectAvg(string $select = '', string $alias = '') @@ -85,7 +87,9 @@ * @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null) * @method $this where($key, $value = null, ?bool $escape = null) * @method $this whereColumn(string $first, string $second, ?bool $escape = null) + * @method $this whereExists($subquery) * @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null) + * @method $this whereNotExists($subquery) * @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null) * * @phpstan-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)