From 3319517739eb646bfd6c128aab5fca709bc870da Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 18 May 2026 13:13:57 +0200 Subject: [PATCH 1/3] feat(database): add query builder existence checks - Add exists() and doesntExist() to Query Builder. - Compile lightweight existence probes while preserving builder state. - Support limit, offset, group, having, union, test mode, and reset behavior. - Document the new methods and add focused builder/live tests. Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 99 ++++++++ system/Model.php | 2 + tests/system/Database/Builder/ExistsTest.php | 211 ++++++++++++++++++ tests/system/Database/Live/ExistsTest.php | 69 ++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + .../source/database/query_builder.rst | 45 ++++ .../source/database/query_builder/128.php | 7 + 7 files changed, 434 insertions(+) create mode 100644 tests/system/Database/Builder/ExistsTest.php create mode 100644 tests/system/Database/Live/ExistsTest.php create mode 100644 user_guide_src/source/database/query_builder/128.php diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index e12ad26a466c..bf867522132a 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -1984,6 +1984,105 @@ public function countAll(bool $reset = true) return (int) $query->numrows; } + /** + * Determines whether the current Query Builder conditions match any rows. + * + * @return bool|string + */ + public function exists(bool $reset = true) + { + $exists = $this->doExists($reset); + + return $exists ?? false; + } + + /** + * Determines whether the current Query Builder conditions do not match any rows. + * + * @return bool|string + */ + public function doesntExist(bool $reset = true) + { + $exists = $this->doExists($reset); + + return is_string($exists) ? $exists : $exists === false; + } + + /** + * Runs an existence probe for the current Query Builder query. + * + * @return bool|string|null + */ + protected function doExists(bool $reset = true) + { + $sql = $this->compileExists(); + + if ($this->testMode) { + if ($reset) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $sql; + } + + $result = $this->db->query($sql, $this->binds, false); + + if ($reset) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $result instanceof ResultInterface ? $result->getRow() !== null : null; + } + + /** + * Compiles an existence probe for the current Query Builder query. + */ + protected function compileExists(): string + { + // ORDER BY and FOR UPDATE are unnecessary for checking row existence, + // and can produce invalid or surprising SQL on some drivers. + $orderBy = $this->QBOrderBy; + $limit = $this->QBLimit; + $offset = $this->QBOffset; + $lockForUpdate = $this->QBLockForUpdate; + $select = $this->QBSelect; + $noEscape = $this->QBNoEscape; + $needsSubquery = $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false; + + $this->QBOrderBy = null; + $this->QBLockForUpdate = false; + + if (! $needsSubquery && $this->QBLimit !== 0) { + $this->QBLimit = 1; + } + + try { + if ($needsSubquery) { + $sql = "SELECT 1 FROM (\n" . $this->compileSelect() . "\n) CI_exists"; + + $this->QBLimit = 1; + $this->QBOffset = false; + + return $this->_limit($sql . "\n"); + } + + return $this->compileSelect('SELECT 1'); + } finally { + $this->QBOrderBy = $orderBy; + $this->QBLimit = $limit; + $this->QBOffset = $offset; + $this->QBLockForUpdate = $lockForUpdate; + $this->QBSelect = $select; + $this->QBNoEscape = $noEscape; + } + } + /** * Generates a platform-specific query string that counts all records * returned by an Query Builder query. diff --git a/system/Model.php b/system/Model.php index 86d5da6d7095..d42673403eb1 100644 --- a/system/Model.php +++ b/system/Model.php @@ -42,6 +42,8 @@ * * @property-read BaseConnection $db * + * @method bool doesntExist(bool $reset = true) + * @method bool exists(bool $reset = true) * @method $this groupBy($by, ?bool $escape = null) * @method $this groupEnd() * @method $this groupStart() diff --git a/tests/system/Database/Builder/ExistsTest.php b/tests/system/Database/Builder/ExistsTest.php new file mode 100644 index 000000000000..a57ca360360a --- /dev/null +++ b/tests/system/Database/Builder/ExistsTest.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Builder; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use Config\Feature; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ExistsTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $this->db = new MockConnection([]); + } + + public function testExistsReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->exists(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + } + + public function testDoesntExistReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->doesntExist(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + } + + public function testExistsDoesNotUseOrderByOrLockForUpdate(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->orderBy('id', 'DESC') + ->lockForUpdate() + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame( + 'SELECT * FROM "jobs" WHERE "id" > 3 ORDER BY "id" DESC FOR UPDATE', + str_replace("\n", ' ', $builder->getCompiledSelect(false)), + ); + } + + public function testExistsWithSQLSRVDoesNotUseOrderByOrLockForUpdate(): void + { + $this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']); + + $builder = new SQLSRVBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->orderBy('id', 'DESC') + ->lockForUpdate() + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM "test"."dbo"."jobs" WHERE "id" > :id: ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY '; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame( + 'SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3 ORDER BY "id" DESC', + str_replace("\n", ' ', $builder->getCompiledSelect(false)), + ); + } + + public function testExistsHonorsExistingLimitAndOffset(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->limit(10, 20) + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM ( SELECT * FROM "jobs" WHERE "id" > :id: LIMIT 20, 10 ) CI_exists LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame( + 'SELECT * FROM "jobs" WHERE "id" > 3 LIMIT 20, 10', + str_replace("\n", ' ', $builder->getCompiledSelect(false)), + ); + } + + public function testExistsHonorsLimitZero(): void + { + $config = config(Feature::class); + $limitZeroAsAll = $config->limitZeroAsAll; + $config->limitZeroAsAll = false; + + try { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3) + ->limit(0) + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 0'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + } finally { + $config->limitZeroAsAll = $limitZeroAsAll; + } + } + + public function testExistsWithGroupByAndHaving(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->selectCount('id', 'total') + ->where('id >', 3) + ->groupBy('id') + ->having('total >', 1) + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM ( SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > :id: GROUP BY "id" HAVING "total" > :total: ) CI_exists LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame( + 'SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > 3 GROUP BY "id" HAVING "total" > 1', + str_replace("\n", ' ', $builder->getCompiledSelect(false)), + ); + } + + public function testExistsWithUnion(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->union($this->db->table('jobs'))->exists(false); + + $expectedSQL = 'SELECT 1 FROM ( SELECT * FROM (SELECT * FROM "jobs") "uwrp0" UNION SELECT * FROM (SELECT * FROM "jobs") "uwrp1" ) CI_exists LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame( + 'SELECT * FROM (SELECT * FROM "jobs") "uwrp0" UNION SELECT * FROM (SELECT * FROM "jobs") "uwrp1"', + str_replace("\n", ' ', $builder->getCompiledSelect(false)), + ); + } + + public function testExistsResetsByDefault(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $builder->where('id >', 3)->exists(); + + $this->assertSame('SELECT * FROM "jobs"', str_replace("\n", ' ', $builder->getCompiledSelect(false))); + $this->assertSame([], $builder->getBinds()); + } + + public function testExistsHonorsResetFalse(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $builder->where('id >', 3)->exists(false); + + $this->assertSame('SELECT * FROM "jobs" WHERE "id" > 3', str_replace("\n", ' ', $builder->getCompiledSelect(false))); + $this->assertSame([ + 'id' => [ + 3, + true, + ], + ], $builder->getBinds()); + } + + public function testExistsMethodsReturnFalseWhenQueryFails(): void + { + $db = new MockConnection([]); + $db->shouldReturn('execute', false); + + $this->assertFalse((new BaseBuilder('jobs', $db))->exists()); + $this->assertFalse((new BaseBuilder('jobs', $db))->doesntExist()); + } +} diff --git a/tests/system/Database/Live/ExistsTest.php b/tests/system/Database/Live/ExistsTest.php new file mode 100644 index 000000000000..b652fdbc2bc6 --- /dev/null +++ b/tests/system/Database/Live/ExistsTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExistsTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + public function testExistsReturnsTrueWithResults(): void + { + $this->assertTrue($this->db->table('job')->where('name', 'Developer')->exists()); + } + + public function testExistsReturnsFalseWithNoResults(): void + { + $this->assertFalse($this->db->table('job')->where('name', 'Superstar')->exists()); + } + + public function testDoesntExistReturnsFalseWithResults(): void + { + $this->assertFalse($this->db->table('job')->where('name', 'Developer')->doesntExist()); + } + + public function testDoesntExistReturnsTrueWithNoResults(): void + { + $this->assertTrue($this->db->table('job')->where('name', 'Superstar')->doesntExist()); + } + + public function testExistsHonorsReset(): void + { + $builder = $this->db->table('job'); + + $this->assertTrue($builder->where('name', 'Developer')->exists(false)); + $this->assertTrue($builder->exists()); + } + + public function testExistsHonorsLimitAndOffset(): void + { + $this->assertFalse( + $this->db->table('job') + ->orderBy('id') + ->limit(1, 10) + ->exists(), + ); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 7670598cde1f..776c84161e8e 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -217,6 +217,7 @@ Database Query Builder ------------- +- Added ``exists()`` and ``doesntExist()`` to Query Builder to check whether the current Query Builder conditions match any rows. See :ref:`query-builder-exists`. - Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`. - 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`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index d77deff5fbe8..4046a21dbc36 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -813,6 +813,35 @@ first parameter. .. literalinclude:: query_builder/073.php +.. _query-builder-exists: + +$builder->exists() +------------------ + +.. versionadded:: 4.8.0 + +Permits you to determine whether the current Query Builder conditions match +any rows: + +.. literalinclude:: query_builder/128.php + +This method returns ``true`` when at least one row exists and ``false`` when no +rows match. It respects any existing ``limit()`` and ``offset()`` clauses, and +resets the current Query Builder state by default. If you need to keep the +current Query Builder state, you can pass ``false`` as the first parameter. + +$builder->doesntExist() +----------------------- + +.. versionadded:: 4.8.0 + +This method is identical to ``exists()``, except that it returns ``true`` when +no rows match. + +.. note:: These methods execute the current Query Builder query to check for + rows. To add an SQL ``EXISTS`` predicate to a query, use + :ref:`query-builder-where-exists`. + $builder->countAll() -------------------- @@ -1529,6 +1558,22 @@ Class Reference Generates a platform-specific query string that counts all records in the particular table. + .. php:method:: exists([$reset = true]) + + :param bool $reset: Whether to reset values for SELECTs + :returns: Whether the query has any matching rows + :rtype: bool + + Determines whether the current Query Builder conditions match any rows. + + .. php:method:: doesntExist([$reset = true]) + + :param bool $reset: Whether to reset values for SELECTs + :returns: Whether the query has no matching rows + :rtype: bool + + Determines whether the current Query Builder conditions do not match any rows. + .. php:method:: get([$limit = null[, $offset = null[, $reset = true]]]]) :param int $limit: The LIMIT clause diff --git a/user_guide_src/source/database/query_builder/128.php b/user_guide_src/source/database/query_builder/128.php new file mode 100644 index 000000000000..45be139147c3 --- /dev/null +++ b/user_guide_src/source/database/query_builder/128.php @@ -0,0 +1,7 @@ +where('status', 'pending'); + +if ($builder->exists()) { + // At least one pending row exists. +} From 8f00d830b379ca05315280c442def7a8c09cd6eb Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 18 May 2026 13:45:08 +0200 Subject: [PATCH 2/3] docs(database): clarify exists test mode return Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index bf867522132a..60d0d0f271ed 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -1987,7 +1987,7 @@ public function countAll(bool $reset = true) /** * Determines whether the current Query Builder conditions match any rows. * - * @return bool|string + * @return bool|string SQL string when test mode is enabled. */ public function exists(bool $reset = true) { @@ -1999,7 +1999,7 @@ public function exists(bool $reset = true) /** * Determines whether the current Query Builder conditions do not match any rows. * - * @return bool|string + * @return bool|string SQL string when test mode is enabled. */ public function doesntExist(bool $reset = true) { @@ -2011,7 +2011,7 @@ public function doesntExist(bool $reset = true) /** * Runs an existence probe for the current Query Builder query. * - * @return bool|string|null + * @return bool|string|null SQL string when test mode is enabled, or null when the query fails. */ protected function doExists(bool $reset = true) { From 8213211e35900ca4cc0733d33b95f3ab63d1703d Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 18 May 2026 18:53:22 +0200 Subject: [PATCH 3/3] refactor(query-builder): address review feedbacks Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 6 +-- system/Model.php | 38 +++++++++++++++-- tests/system/Database/Builder/ExistsTest.php | 18 ++++++++ tests/system/Models/ExistsModelTest.php | 42 +++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 2 +- .../source/database/query_builder.rst | 25 ++++++----- 6 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 tests/system/Models/ExistsModelTest.php diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 60d0d0f271ed..2c9263a3d5c1 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -1985,7 +1985,7 @@ public function countAll(bool $reset = true) } /** - * Determines whether the current Query Builder conditions match any rows. + * Determines whether the current Query Builder query would return at least one row. * * @return bool|string SQL string when test mode is enabled. */ @@ -1997,7 +1997,7 @@ public function exists(bool $reset = true) } /** - * Determines whether the current Query Builder conditions do not match any rows. + * Determines whether the current Query Builder query would not return any rows. * * @return bool|string SQL string when test mode is enabled. */ @@ -2053,7 +2053,7 @@ protected function compileExists(): string $lockForUpdate = $this->QBLockForUpdate; $select = $this->QBSelect; $noEscape = $this->QBNoEscape; - $needsSubquery = $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false; + $needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false; $this->QBOrderBy = null; $this->QBLockForUpdate = false; diff --git a/system/Model.php b/system/Model.php index d42673403eb1..13f2f81753fb 100644 --- a/system/Model.php +++ b/system/Model.php @@ -42,8 +42,6 @@ * * @property-read BaseConnection $db * - * @method bool doesntExist(bool $reset = true) - * @method bool exists(bool $reset = true) * @method $this groupBy($by, ?bool $escape = null) * @method $this groupEnd() * @method $this groupStart() @@ -524,6 +522,40 @@ public function getIdValue($row) } public function countAllResults(bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->countAllResults($reset); + } + + /** + * Determines whether the current Model query would return at least one row. + * + * @return bool|string Returns a SQL string if in test mode. + */ + public function exists(bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->exists($reset); + } + + /** + * Determines whether the current Model query would not return any rows. + * + * @return bool|string Returns a SQL string if in test mode. + */ + public function doesntExist(bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->doesntExist($reset); + } + + /** + * Applies the Model soft-delete constraint before terminal Builder operations. + */ + private function prepareSoftDeleteQuery(bool $reset): void { if ($this->tempUseSoftDeletes) { $this->builder()->where($this->table . '.' . $this->deletedField, null); @@ -535,8 +567,6 @@ public function countAllResults(bool $reset = true, bool $test = false) $this->tempUseSoftDeletes = $reset ? $this->useSoftDeletes : ($this->useSoftDeletes ? false : $this->useSoftDeletes); - - return $this->builder()->testMode($test)->countAllResults($reset); } /** diff --git a/tests/system/Database/Builder/ExistsTest.php b/tests/system/Database/Builder/ExistsTest.php index a57ca360360a..8b7d145f4154 100644 --- a/tests/system/Database/Builder/ExistsTest.php +++ b/tests/system/Database/Builder/ExistsTest.php @@ -157,6 +157,24 @@ public function testExistsWithGroupByAndHaving(): void ); } + public function testExistsWithAggregateSelection(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->selectCount('id', 'total') + ->where('id >', 3) + ->exists(false); + + $expectedSQL = 'SELECT 1 FROM ( SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > :id: ) CI_exists LIMIT 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + $this->assertSame( + 'SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > 3', + str_replace("\n", ' ', $builder->getCompiledSelect(false)), + ); + } + public function testExistsWithUnion(): void { $builder = new BaseBuilder('jobs', $this->db); diff --git a/tests/system/Models/ExistsModelTest.php b/tests/system/Models/ExistsModelTest.php new file mode 100644 index 000000000000..1dc617e40bc3 --- /dev/null +++ b/tests/system/Models/ExistsModelTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExistsModelTest extends LiveModelTestCase +{ + public function testExistsRespectsSoftDeletes(): void + { + $this->createModel(UserModel::class); + $this->model->delete(1); + + $this->assertFalse($this->model->where('id', 1)->exists()); + $this->assertTrue($this->model->withDeleted()->where('id', 1)->exists()); + } + + public function testDoesntExistRespectsSoftDeletes(): void + { + $this->createModel(UserModel::class); + $this->model->delete(1); + + $this->assertTrue($this->model->where('id', 1)->doesntExist()); + $this->assertFalse($this->model->withDeleted()->where('id', 1)->doesntExist()); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 776c84161e8e..154a5fd04e84 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -217,7 +217,7 @@ Database Query Builder ------------- -- Added ``exists()`` and ``doesntExist()`` to Query Builder to check whether the current Query Builder conditions match any rows. See :ref:`query-builder-exists`. +- Added ``exists()`` and ``doesntExist()`` to Query Builder to check whether the current Query Builder query would return at least one row. See :ref:`query-builder-exists`. - Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`. - 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`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 4046a21dbc36..a2f9dcf3ce2d 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -820,15 +820,18 @@ $builder->exists() .. versionadded:: 4.8.0 -Permits you to determine whether the current Query Builder conditions match -any rows: +Permits you to determine whether the current Query Builder query would return +at least one row: .. literalinclude:: query_builder/128.php -This method returns ``true`` when at least one row exists and ``false`` when no -rows match. It respects any existing ``limit()`` and ``offset()`` clauses, and -resets the current Query Builder state by default. If you need to keep the -current Query Builder state, you can pass ``false`` as the first parameter. +This method returns ``true`` when the query would return at least one row and +``false`` when it would not. It respects any existing ``limit()`` and +``offset()`` clauses, and resets the current Query Builder state by default. If +you need to keep the current Query Builder state, you can pass ``false`` as the +first parameter. +If the existence query fails and ``DBDebug`` is ``false``, both methods return +``false``. $builder->doesntExist() ----------------------- @@ -836,7 +839,7 @@ $builder->doesntExist() .. versionadded:: 4.8.0 This method is identical to ``exists()``, except that it returns ``true`` when -no rows match. +the query would not return any rows. .. note:: These methods execute the current Query Builder query to check for rows. To add an SQL ``EXISTS`` predicate to a query, use @@ -1561,18 +1564,18 @@ Class Reference .. php:method:: exists([$reset = true]) :param bool $reset: Whether to reset values for SELECTs - :returns: Whether the query has any matching rows + :returns: Whether the query would return at least one row :rtype: bool - Determines whether the current Query Builder conditions match any rows. + Determines whether the current Query Builder query would return at least one row. .. php:method:: doesntExist([$reset = true]) :param bool $reset: Whether to reset values for SELECTs - :returns: Whether the query has no matching rows + :returns: Whether the query would not return any rows :rtype: bool - Determines whether the current Query Builder conditions do not match any rows. + Determines whether the current Query Builder query would not return any rows. .. php:method:: get([$limit = null[, $offset = null[, $reset = true]]]])