Skip to content

Commit 3319517

Browse files
committed
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>
1 parent 37b8b37 commit 3319517

7 files changed

Lines changed: 434 additions & 0 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1984,6 +1984,105 @@ public function countAll(bool $reset = true)
19841984
return (int) $query->numrows;
19851985
}
19861986

1987+
/**
1988+
* Determines whether the current Query Builder conditions match any rows.
1989+
*
1990+
* @return bool|string
1991+
*/
1992+
public function exists(bool $reset = true)
1993+
{
1994+
$exists = $this->doExists($reset);
1995+
1996+
return $exists ?? false;
1997+
}
1998+
1999+
/**
2000+
* Determines whether the current Query Builder conditions do not match any rows.
2001+
*
2002+
* @return bool|string
2003+
*/
2004+
public function doesntExist(bool $reset = true)
2005+
{
2006+
$exists = $this->doExists($reset);
2007+
2008+
return is_string($exists) ? $exists : $exists === false;
2009+
}
2010+
2011+
/**
2012+
* Runs an existence probe for the current Query Builder query.
2013+
*
2014+
* @return bool|string|null
2015+
*/
2016+
protected function doExists(bool $reset = true)
2017+
{
2018+
$sql = $this->compileExists();
2019+
2020+
if ($this->testMode) {
2021+
if ($reset) {
2022+
$this->resetSelect();
2023+
2024+
// Clear our binds so we don't eat up memory
2025+
$this->binds = [];
2026+
}
2027+
2028+
return $sql;
2029+
}
2030+
2031+
$result = $this->db->query($sql, $this->binds, false);
2032+
2033+
if ($reset) {
2034+
$this->resetSelect();
2035+
2036+
// Clear our binds so we don't eat up memory
2037+
$this->binds = [];
2038+
}
2039+
2040+
return $result instanceof ResultInterface ? $result->getRow() !== null : null;
2041+
}
2042+
2043+
/**
2044+
* Compiles an existence probe for the current Query Builder query.
2045+
*/
2046+
protected function compileExists(): string
2047+
{
2048+
// ORDER BY and FOR UPDATE are unnecessary for checking row existence,
2049+
// and can produce invalid or surprising SQL on some drivers.
2050+
$orderBy = $this->QBOrderBy;
2051+
$limit = $this->QBLimit;
2052+
$offset = $this->QBOffset;
2053+
$lockForUpdate = $this->QBLockForUpdate;
2054+
$select = $this->QBSelect;
2055+
$noEscape = $this->QBNoEscape;
2056+
$needsSubquery = $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
2057+
2058+
$this->QBOrderBy = null;
2059+
$this->QBLockForUpdate = false;
2060+
2061+
if (! $needsSubquery && $this->QBLimit !== 0) {
2062+
$this->QBLimit = 1;
2063+
}
2064+
2065+
try {
2066+
if ($needsSubquery) {
2067+
$sql = "SELECT 1 FROM (\n" . $this->compileSelect() . "\n) CI_exists";
2068+
2069+
$this->QBLimit = 1;
2070+
$this->QBOffset = false;
2071+
2072+
return $this->_limit($sql . "\n");
2073+
}
2074+
2075+
return $this->compileSelect('SELECT 1');
2076+
} finally {
2077+
$this->QBOrderBy = $orderBy;
2078+
$this->QBLimit = $limit;
2079+
$this->QBOffset = $offset;
2080+
$this->QBLockForUpdate = $lockForUpdate;
2081+
$this->QBSelect = $select;
2082+
$this->QBNoEscape = $noEscape;
2083+
}
2084+
}
2085+
19872086
/**
19882087
* Generates a platform-specific query string that counts all records
19892088
* returned by an Query Builder query.

system/Model.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
*
4343
* @property-read BaseConnection $db
4444
*
45+
* @method bool doesntExist(bool $reset = true)
46+
* @method bool exists(bool $reset = true)
4547
* @method $this groupBy($by, ?bool $escape = null)
4648
* @method $this groupEnd()
4749
* @method $this groupStart()
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Database\Builder;
15+
16+
use CodeIgniter\Database\BaseBuilder;
17+
use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder;
18+
use CodeIgniter\Test\CIUnitTestCase;
19+
use CodeIgniter\Test\Mock\MockConnection;
20+
use Config\Feature;
21+
use PHPUnit\Framework\Attributes\Group;
22+
23+
/**
24+
* @internal
25+
*/
26+
#[Group('Others')]
27+
final class ExistsTest extends CIUnitTestCase
28+
{
29+
protected function setUp(): void
30+
{
31+
parent::setUp();
32+
33+
$this->db = new MockConnection([]);
34+
}
35+
36+
public function testExistsReturnsSqlInTestMode(): void
37+
{
38+
$builder = new BaseBuilder('jobs', $this->db);
39+
$builder->testMode();
40+
41+
$answer = $builder->where('id >', 3)->exists(false);
42+
43+
$expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1';
44+
45+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
46+
}
47+
48+
public function testDoesntExistReturnsSqlInTestMode(): void
49+
{
50+
$builder = new BaseBuilder('jobs', $this->db);
51+
$builder->testMode();
52+
53+
$answer = $builder->where('id >', 3)->doesntExist(false);
54+
55+
$expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1';
56+
57+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
58+
}
59+
60+
public function testExistsDoesNotUseOrderByOrLockForUpdate(): void
61+
{
62+
$builder = new BaseBuilder('jobs', $this->db);
63+
$builder->testMode();
64+
65+
$answer = $builder->where('id >', 3)
66+
->orderBy('id', 'DESC')
67+
->lockForUpdate()
68+
->exists(false);
69+
70+
$expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 1';
71+
72+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
73+
$this->assertSame(
74+
'SELECT * FROM "jobs" WHERE "id" > 3 ORDER BY "id" DESC FOR UPDATE',
75+
str_replace("\n", ' ', $builder->getCompiledSelect(false)),
76+
);
77+
}
78+
79+
public function testExistsWithSQLSRVDoesNotUseOrderByOrLockForUpdate(): void
80+
{
81+
$this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']);
82+
83+
$builder = new SQLSRVBuilder('jobs', $this->db);
84+
$builder->testMode();
85+
86+
$answer = $builder->where('id >', 3)
87+
->orderBy('id', 'DESC')
88+
->lockForUpdate()
89+
->exists(false);
90+
91+
$expectedSQL = 'SELECT 1 FROM "test"."dbo"."jobs" WHERE "id" > :id: ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY ';
92+
93+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
94+
$this->assertSame(
95+
'SELECT * FROM "test"."dbo"."jobs" WITH (UPDLOCK, ROWLOCK) WHERE "id" > 3 ORDER BY "id" DESC',
96+
str_replace("\n", ' ', $builder->getCompiledSelect(false)),
97+
);
98+
}
99+
100+
public function testExistsHonorsExistingLimitAndOffset(): void
101+
{
102+
$builder = new BaseBuilder('jobs', $this->db);
103+
$builder->testMode();
104+
105+
$answer = $builder->where('id >', 3)
106+
->limit(10, 20)
107+
->exists(false);
108+
109+
$expectedSQL = 'SELECT 1 FROM ( SELECT * FROM "jobs" WHERE "id" > :id: LIMIT 20, 10 ) CI_exists LIMIT 1';
110+
111+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
112+
$this->assertSame(
113+
'SELECT * FROM "jobs" WHERE "id" > 3 LIMIT 20, 10',
114+
str_replace("\n", ' ', $builder->getCompiledSelect(false)),
115+
);
116+
}
117+
118+
public function testExistsHonorsLimitZero(): void
119+
{
120+
$config = config(Feature::class);
121+
$limitZeroAsAll = $config->limitZeroAsAll;
122+
$config->limitZeroAsAll = false;
123+
124+
try {
125+
$builder = new BaseBuilder('jobs', $this->db);
126+
$builder->testMode();
127+
128+
$answer = $builder->where('id >', 3)
129+
->limit(0)
130+
->exists(false);
131+
132+
$expectedSQL = 'SELECT 1 FROM "jobs" WHERE "id" > :id: LIMIT 0';
133+
134+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
135+
} finally {
136+
$config->limitZeroAsAll = $limitZeroAsAll;
137+
}
138+
}
139+
140+
public function testExistsWithGroupByAndHaving(): void
141+
{
142+
$builder = new BaseBuilder('jobs', $this->db);
143+
$builder->testMode();
144+
145+
$answer = $builder->selectCount('id', 'total')
146+
->where('id >', 3)
147+
->groupBy('id')
148+
->having('total >', 1)
149+
->exists(false);
150+
151+
$expectedSQL = 'SELECT 1 FROM ( SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > :id: GROUP BY "id" HAVING "total" > :total: ) CI_exists LIMIT 1';
152+
153+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
154+
$this->assertSame(
155+
'SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > 3 GROUP BY "id" HAVING "total" > 1',
156+
str_replace("\n", ' ', $builder->getCompiledSelect(false)),
157+
);
158+
}
159+
160+
public function testExistsWithUnion(): void
161+
{
162+
$builder = new BaseBuilder('jobs', $this->db);
163+
$builder->testMode();
164+
165+
$answer = $builder->union($this->db->table('jobs'))->exists(false);
166+
167+
$expectedSQL = 'SELECT 1 FROM ( SELECT * FROM (SELECT * FROM "jobs") "uwrp0" UNION SELECT * FROM (SELECT * FROM "jobs") "uwrp1" ) CI_exists LIMIT 1';
168+
169+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
170+
$this->assertSame(
171+
'SELECT * FROM (SELECT * FROM "jobs") "uwrp0" UNION SELECT * FROM (SELECT * FROM "jobs") "uwrp1"',
172+
str_replace("\n", ' ', $builder->getCompiledSelect(false)),
173+
);
174+
}
175+
176+
public function testExistsResetsByDefault(): void
177+
{
178+
$builder = new BaseBuilder('jobs', $this->db);
179+
$builder->testMode();
180+
181+
$builder->where('id >', 3)->exists();
182+
183+
$this->assertSame('SELECT * FROM "jobs"', str_replace("\n", ' ', $builder->getCompiledSelect(false)));
184+
$this->assertSame([], $builder->getBinds());
185+
}
186+
187+
public function testExistsHonorsResetFalse(): void
188+
{
189+
$builder = new BaseBuilder('jobs', $this->db);
190+
$builder->testMode();
191+
192+
$builder->where('id >', 3)->exists(false);
193+
194+
$this->assertSame('SELECT * FROM "jobs" WHERE "id" > 3', str_replace("\n", ' ', $builder->getCompiledSelect(false)));
195+
$this->assertSame([
196+
'id' => [
197+
3,
198+
true,
199+
],
200+
], $builder->getBinds());
201+
}
202+
203+
public function testExistsMethodsReturnFalseWhenQueryFails(): void
204+
{
205+
$db = new MockConnection([]);
206+
$db->shouldReturn('execute', false);
207+
208+
$this->assertFalse((new BaseBuilder('jobs', $db))->exists());
209+
$this->assertFalse((new BaseBuilder('jobs', $db))->doesntExist());
210+
}
211+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Database\Live;
15+
16+
use CodeIgniter\Test\CIUnitTestCase;
17+
use CodeIgniter\Test\DatabaseTestTrait;
18+
use PHPUnit\Framework\Attributes\Group;
19+
use Tests\Support\Database\Seeds\CITestSeeder;
20+
21+
/**
22+
* @internal
23+
*/
24+
#[Group('DatabaseLive')]
25+
final class ExistsTest extends CIUnitTestCase
26+
{
27+
use DatabaseTestTrait;
28+
29+
protected $refresh = true;
30+
protected $seed = CITestSeeder::class;
31+
32+
public function testExistsReturnsTrueWithResults(): void
33+
{
34+
$this->assertTrue($this->db->table('job')->where('name', 'Developer')->exists());
35+
}
36+
37+
public function testExistsReturnsFalseWithNoResults(): void
38+
{
39+
$this->assertFalse($this->db->table('job')->where('name', 'Superstar')->exists());
40+
}
41+
42+
public function testDoesntExistReturnsFalseWithResults(): void
43+
{
44+
$this->assertFalse($this->db->table('job')->where('name', 'Developer')->doesntExist());
45+
}
46+
47+
public function testDoesntExistReturnsTrueWithNoResults(): void
48+
{
49+
$this->assertTrue($this->db->table('job')->where('name', 'Superstar')->doesntExist());
50+
}
51+
52+
public function testExistsHonorsReset(): void
53+
{
54+
$builder = $this->db->table('job');
55+
56+
$this->assertTrue($builder->where('name', 'Developer')->exists(false));
57+
$this->assertTrue($builder->exists());
58+
}
59+
60+
public function testExistsHonorsLimitAndOffset(): void
61+
{
62+
$this->assertFalse(
63+
$this->db->table('job')
64+
->orderBy('id')
65+
->limit(1, 10)
66+
->exists(),
67+
);
68+
}
69+
}

0 commit comments

Comments
 (0)