Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -1984,6 +1984,105 @@ public function countAll(bool $reset = true)
return (int) $query->numrows;
}

/**
* Determines whether the current Query Builder query would return at least one row.
*
* @return bool|string SQL string when test mode is enabled.
*/
public function exists(bool $reset = true)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming-wise, I wonder whether hasRows() / hasNoRows() would fit this behavior better than exists() / doesntExist(). Since the method checks whether the current Query Builder would return a row, and we already have whereExists() for SQL EXISTS predicates, hasRows() feels less ambiguous to me. Eventually hasResults() / hasNoResults()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this a bit more, and I still slightly lean toward exists() / doesntExist() from a DX point of view.

The main reason is familiarity. Exists is already common vocabulary in SQL, but it's also used across many ecosystems and frameworks. Laravel has exists(), Rails has exists?, Django has exists(), and many developers coming from ORM/query builder backgrounds are already used to that wording.

So if I were asking "does this query return anything?", exists() would probably be one of the first method names I'd try or search for.

hasRows() / hasNoRows() is clear too, so I don't think it's a bad option. It just feels a bit more CI-specific to me, while exists() feels closer to the common vocabulary developers already bring from SQL and other ecosystems. I also like that it sits naturally next to whereExists(), even though one executes the query and the other only adds a predicate.

That said, this is just my preference. I'd be happy to follow whatever the team feels is the better fit.

{
$exists = $this->doExists($reset);

return $exists ?? false;
}

/**
* Determines whether the current Query Builder query would not return any rows.
*
* @return bool|string SQL string when test mode is enabled.
*/
public function doesntExist(bool $reset = true)
Comment thread
memleakd marked this conversation as resolved.
{
$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 SQL string when test mode is enabled, or null when the query fails.
*/
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->QBSelectUsesAggregate || $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.
Expand Down
36 changes: 34 additions & 2 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -522,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);
Expand All @@ -533,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);
}

/**
Expand Down
229 changes: 229 additions & 0 deletions tests/system/Database/Builder/ExistsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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 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);
$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());
}
}
Loading
Loading