Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 conditions match any rows.
*
* @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 conditions do not match 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->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
2 changes: 2 additions & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
*
* @property-read BaseConnection $db
*
* @method bool doesntExist(bool $reset = true)
* @method bool exists(bool $reset = true)
Comment thread
memleakd marked this conversation as resolved.
Outdated
* @method $this groupBy($by, ?bool $escape = null)
* @method $this groupEnd()
* @method $this groupStart()
Expand Down
211 changes: 211 additions & 0 deletions tests/system/Database/Builder/ExistsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<?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 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());
}
}
69 changes: 69 additions & 0 deletions tests/system/Database/Live/ExistsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?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\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(),
);
}
}
Loading
Loading