Skip to content

Commit 64c1b38

Browse files
authored
feat: add Query Builder exists() and doesntExist() methods (#10215)
* 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> * docs(database): clarify exists test mode return Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * refactor(query-builder): address review feedbacks Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --------- Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 6ea148f commit 64c1b38

8 files changed

Lines changed: 529 additions & 2 deletions

File tree

system/Database/BaseBuilder.php

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

2051+
/**
2052+
* Determines whether the current Query Builder query would return at least one row.
2053+
*
2054+
* @return bool|string SQL string when test mode is enabled.
2055+
*/
2056+
public function exists(bool $reset = true)
2057+
{
2058+
$exists = $this->doExists($reset);
2059+
2060+
return $exists ?? false;
2061+
}
2062+
2063+
/**
2064+
* Determines whether the current Query Builder query would not return any rows.
2065+
*
2066+
* @return bool|string SQL string when test mode is enabled.
2067+
*/
2068+
public function doesntExist(bool $reset = true)
2069+
{
2070+
$exists = $this->doExists($reset);
2071+
2072+
return is_string($exists) ? $exists : $exists === false;
2073+
}
2074+
2075+
/**
2076+
* Runs an existence probe for the current Query Builder query.
2077+
*
2078+
* @return bool|string|null SQL string when test mode is enabled, or null when the query fails.
2079+
*/
2080+
protected function doExists(bool $reset = true)
2081+
{
2082+
$sql = $this->compileExists();
2083+
2084+
if ($this->testMode) {
2085+
if ($reset) {
2086+
$this->resetSelect();
2087+
2088+
// Clear our binds so we don't eat up memory
2089+
$this->binds = [];
2090+
}
2091+
2092+
return $sql;
2093+
}
2094+
2095+
$result = $this->db->query($sql, $this->binds, false);
2096+
2097+
if ($reset) {
2098+
$this->resetSelect();
2099+
2100+
// Clear our binds so we don't eat up memory
2101+
$this->binds = [];
2102+
}
2103+
2104+
return $result instanceof ResultInterface ? $result->getRow() !== null : null;
2105+
}
2106+
2107+
/**
2108+
* Compiles an existence probe for the current Query Builder query.
2109+
*/
2110+
protected function compileExists(): string
2111+
{
2112+
// ORDER BY and FOR UPDATE are unnecessary for checking row existence,
2113+
// and can produce invalid or surprising SQL on some drivers.
2114+
$orderBy = $this->QBOrderBy;
2115+
$limit = $this->QBLimit;
2116+
$offset = $this->QBOffset;
2117+
$lockForUpdate = $this->QBLockForUpdate;
2118+
$select = $this->QBSelect;
2119+
$noEscape = $this->QBNoEscape;
2120+
$needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
2121+
2122+
$this->QBOrderBy = null;
2123+
$this->QBLockForUpdate = false;
2124+
2125+
if (! $needsSubquery && $this->QBLimit !== 0) {
2126+
$this->QBLimit = 1;
2127+
}
2128+
2129+
try {
2130+
if ($needsSubquery) {
2131+
$sql = "SELECT 1 FROM (\n" . $this->compileSelect() . "\n) CI_exists";
2132+
2133+
$this->QBLimit = 1;
2134+
$this->QBOffset = false;
2135+
2136+
return $this->_limit($sql . "\n");
2137+
}
2138+
2139+
return $this->compileSelect('SELECT 1');
2140+
} finally {
2141+
$this->QBOrderBy = $orderBy;
2142+
$this->QBLimit = $limit;
2143+
$this->QBOffset = $offset;
2144+
$this->QBLockForUpdate = $lockForUpdate;
2145+
$this->QBSelect = $select;
2146+
$this->QBNoEscape = $noEscape;
2147+
}
2148+
}
2149+
20512150
/**
20522151
* Generates a platform-specific query string that counts all records
20532152
* returned by an Query Builder query.

system/Model.php

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,40 @@ public function getIdValue($row)
526526
}
527527

528528
public function countAllResults(bool $reset = true, bool $test = false)
529+
{
530+
$this->prepareSoftDeleteQuery($reset);
531+
532+
return $this->builder()->testMode($test)->countAllResults($reset);
533+
}
534+
535+
/**
536+
* Determines whether the current Model query would return at least one row.
537+
*
538+
* @return bool|string Returns a SQL string if in test mode.
539+
*/
540+
public function exists(bool $reset = true, bool $test = false)
541+
{
542+
$this->prepareSoftDeleteQuery($reset);
543+
544+
return $this->builder()->testMode($test)->exists($reset);
545+
}
546+
547+
/**
548+
* Determines whether the current Model query would not return any rows.
549+
*
550+
* @return bool|string Returns a SQL string if in test mode.
551+
*/
552+
public function doesntExist(bool $reset = true, bool $test = false)
553+
{
554+
$this->prepareSoftDeleteQuery($reset);
555+
556+
return $this->builder()->testMode($test)->doesntExist($reset);
557+
}
558+
559+
/**
560+
* Applies the Model soft-delete constraint before terminal Builder operations.
561+
*/
562+
private function prepareSoftDeleteQuery(bool $reset): void
529563
{
530564
if ($this->tempUseSoftDeletes) {
531565
$this->builder()->where($this->table . '.' . $this->deletedField, null);
@@ -537,8 +571,6 @@ public function countAllResults(bool $reset = true, bool $test = false)
537571
$this->tempUseSoftDeletes = $reset
538572
? $this->useSoftDeletes
539573
: ($this->useSoftDeletes ? false : $this->useSoftDeletes);
540-
541-
return $this->builder()->testMode($test)->countAllResults($reset);
542574
}
543575

544576
/**
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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 testExistsWithAggregateSelection(): void
161+
{
162+
$builder = new BaseBuilder('jobs', $this->db);
163+
$builder->testMode();
164+
165+
$answer = $builder->selectCount('id', 'total')
166+
->where('id >', 3)
167+
->exists(false);
168+
169+
$expectedSQL = 'SELECT 1 FROM ( SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > :id: ) CI_exists LIMIT 1';
170+
171+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
172+
$this->assertSame(
173+
'SELECT COUNT("id") AS "total" FROM "jobs" WHERE "id" > 3',
174+
str_replace("\n", ' ', $builder->getCompiledSelect(false)),
175+
);
176+
}
177+
178+
public function testExistsWithUnion(): void
179+
{
180+
$builder = new BaseBuilder('jobs', $this->db);
181+
$builder->testMode();
182+
183+
$answer = $builder->union($this->db->table('jobs'))->exists(false);
184+
185+
$expectedSQL = 'SELECT 1 FROM ( SELECT * FROM (SELECT * FROM "jobs") "uwrp0" UNION SELECT * FROM (SELECT * FROM "jobs") "uwrp1" ) CI_exists LIMIT 1';
186+
187+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $answer));
188+
$this->assertSame(
189+
'SELECT * FROM (SELECT * FROM "jobs") "uwrp0" UNION SELECT * FROM (SELECT * FROM "jobs") "uwrp1"',
190+
str_replace("\n", ' ', $builder->getCompiledSelect(false)),
191+
);
192+
}
193+
194+
public function testExistsResetsByDefault(): void
195+
{
196+
$builder = new BaseBuilder('jobs', $this->db);
197+
$builder->testMode();
198+
199+
$builder->where('id >', 3)->exists();
200+
201+
$this->assertSame('SELECT * FROM "jobs"', str_replace("\n", ' ', $builder->getCompiledSelect(false)));
202+
$this->assertSame([], $builder->getBinds());
203+
}
204+
205+
public function testExistsHonorsResetFalse(): void
206+
{
207+
$builder = new BaseBuilder('jobs', $this->db);
208+
$builder->testMode();
209+
210+
$builder->where('id >', 3)->exists(false);
211+
212+
$this->assertSame('SELECT * FROM "jobs" WHERE "id" > 3', str_replace("\n", ' ', $builder->getCompiledSelect(false)));
213+
$this->assertSame([
214+
'id' => [
215+
3,
216+
true,
217+
],
218+
], $builder->getBinds());
219+
}
220+
221+
public function testExistsMethodsReturnFalseWhenQueryFails(): void
222+
{
223+
$db = new MockConnection([]);
224+
$db->shouldReturn('execute', false);
225+
226+
$this->assertFalse((new BaseBuilder('jobs', $db))->exists());
227+
$this->assertFalse((new BaseBuilder('jobs', $db))->doesntExist());
228+
}
229+
}

0 commit comments

Comments
 (0)