Skip to content

Commit 170b89a

Browse files
authored
feat: add Query Builder whereExists methods (#10185)
* feat(database): add Query Builder exists conditions - Add whereExists() and whereNotExists() condition helpers - Support Closure and BaseBuilder subqueries - Document usage and raw SQL limitations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * refactor(database): address whereExists review feedbacks Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * Trigger CI * docs(database): add Model whereExists method annotations Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --------- Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 7dcbeb8 commit 170b89a

6 files changed

Lines changed: 299 additions & 0 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,54 @@ public function orWhereColumn(string $first, string $second, ?bool $escape = nul
780780
return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape);
781781
}
782782

783+
/**
784+
* Generates a WHERE EXISTS subquery.
785+
*
786+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
787+
*
788+
* @return $this
789+
*/
790+
public function whereExists($subquery): static
791+
{
792+
return $this->whereExistsSubquery($subquery);
793+
}
794+
795+
/**
796+
* Generates an OR WHERE EXISTS subquery.
797+
*
798+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
799+
*
800+
* @return $this
801+
*/
802+
public function orWhereExists($subquery): static
803+
{
804+
return $this->whereExistsSubquery($subquery, false, 'OR ');
805+
}
806+
807+
/**
808+
* Generates a WHERE NOT EXISTS subquery.
809+
*
810+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
811+
*
812+
* @return $this
813+
*/
814+
public function whereNotExists($subquery): static
815+
{
816+
return $this->whereExistsSubquery($subquery, true);
817+
}
818+
819+
/**
820+
* Generates an OR WHERE NOT EXISTS subquery.
821+
*
822+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
823+
*
824+
* @return $this
825+
*/
826+
public function orWhereNotExists($subquery): static
827+
{
828+
return $this->whereExistsSubquery($subquery, true, 'OR ');
829+
}
830+
783831
/**
784832
* @used-by whereColumn()
785833
* @used-by orWhereColumn()
@@ -839,6 +887,35 @@ private function parseWhereColumnFirst(string $first): array
839887
return [$first, '='];
840888
}
841889

890+
/**
891+
* @used-by whereExists()
892+
* @used-by orWhereExists()
893+
* @used-by whereNotExists()
894+
* @used-by orWhereNotExists()
895+
*
896+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
897+
*
898+
* @return $this
899+
*
900+
* @throws InvalidArgumentException
901+
*/
902+
protected function whereExistsSubquery($subquery, bool $not = false, string $type = 'AND '): static
903+
{
904+
if (! $this->isSubquery($subquery)) {
905+
throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function']));
906+
}
907+
908+
$prefix = $this->QBWhere === [] ? $this->groupGetType('') : $this->groupGetType($type);
909+
$operator = $not ? 'NOT EXISTS' : 'EXISTS';
910+
911+
$this->QBWhere[] = [
912+
'condition' => "{$prefix}{$operator} {$this->buildSubquery($subquery, true)}",
913+
'escape' => false,
914+
];
915+
916+
return $this;
917+
}
918+
842919
/**
843920
* @used-by where()
844921
* @used-by orWhere()

system/Model.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@
7373
* @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
7474
* @method $this orWhere($key, $value = null, ?bool $escape = null)
7575
* @method $this orWhereColumn(string $first, string $second, ?bool $escape = null)
76+
* @method $this orWhereExists($subquery)
7677
* @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
78+
* @method $this orWhereNotExists($subquery)
7779
* @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
7880
* @method $this select($select = '*', ?bool $escape = null)
7981
* @method $this selectAvg(string $select = '', string $alias = '')
@@ -85,7 +87,9 @@
8587
* @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
8688
* @method $this where($key, $value = null, ?bool $escape = null)
8789
* @method $this whereColumn(string $first, string $second, ?bool $escape = null)
90+
* @method $this whereExists($subquery)
8891
* @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
92+
* @method $this whereNotExists($subquery)
8993
* @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
9094
*
9195
* @phpstan-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null)

tests/system/Database/Builder/WhereTest.php

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace CodeIgniter\Database\Builder;
1515

1616
use CodeIgniter\Database\BaseBuilder;
17+
use CodeIgniter\Database\Exceptions\DatabaseException;
1718
use CodeIgniter\Database\RawSql;
1819
use CodeIgniter\Exceptions\InvalidArgumentException;
1920
use CodeIgniter\Test\CIUnitTestCase;
@@ -489,6 +490,141 @@ public static function provideWhereColumnInvalidColumnThrowInvalidArgumentExcept
489490
];
490491
}
491492

493+
public function testWhereExistsSubQuery(): void
494+
{
495+
$expectedSQL = 'SELECT * FROM "users" WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")';
496+
497+
// Closure
498+
$builder = $this->db->table('users');
499+
500+
$builder->whereExists(static fn (BaseBuilder $builder) => $builder
501+
->select('1', false)
502+
->from('orders')
503+
->whereColumn('orders.user_id', 'users.id'));
504+
505+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
506+
507+
// Builder
508+
$builder = $this->db->table('users');
509+
510+
$subQuery = $this->db->table('orders')
511+
->select('1', false)
512+
->whereColumn('orders.user_id', 'users.id');
513+
514+
$builder->whereExists($subQuery);
515+
516+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
517+
}
518+
519+
#[DataProvider('provideWhereExistsVariants')]
520+
public function testWhereExistsVariants(string $method, string $expectedSQL): void
521+
{
522+
$builder = $this->db->table('users');
523+
524+
$builder->where('active', 1);
525+
526+
$builder->{$method}(static fn (BaseBuilder $builder) => $builder
527+
->select('1', false)
528+
->from('orders')
529+
->whereColumn('orders.user_id', 'users.id'));
530+
531+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
532+
}
533+
534+
/**
535+
* @return iterable<string, array{string, string}>
536+
*/
537+
public static function provideWhereExistsVariants(): iterable
538+
{
539+
$exists = '(SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")';
540+
$baseQuery = 'SELECT * FROM "users" WHERE "active" = 1';
541+
542+
return [
543+
'whereExists' => ['whereExists', "{$baseQuery} AND EXISTS {$exists}"],
544+
'orWhereExists' => ['orWhereExists', "{$baseQuery} OR EXISTS {$exists}"],
545+
'whereNotExists' => ['whereNotExists', "{$baseQuery} AND NOT EXISTS {$exists}"],
546+
'orWhereNotExists' => ['orWhereNotExists', "{$baseQuery} OR NOT EXISTS {$exists}"],
547+
];
548+
}
549+
550+
public function testWhereExistsWithGroupedConditions(): void
551+
{
552+
$builder = $this->db->table('users');
553+
554+
$builder->groupStart()
555+
->whereExists(static fn (BaseBuilder $builder) => $builder
556+
->select('1', false)
557+
->from('orders')
558+
->whereColumn('orders.user_id', 'users.id'))
559+
->orWhereNotExists(static fn (BaseBuilder $builder) => $builder
560+
->select('1', false)
561+
->from('jobs')
562+
->whereColumn('jobs.user_id', 'users.id'))
563+
->groupEnd()
564+
->where('active', 1);
565+
566+
$expectedSQL = 'SELECT * FROM "users" WHERE ( EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id") OR NOT EXISTS (SELECT 1 FROM "jobs" WHERE "jobs"."user_id" = "users"."id") ) AND "active" = 1';
567+
568+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
569+
}
570+
571+
public function testWhereExistsWithOuterAndInnerBinds(): void
572+
{
573+
$builder = $this->db->table('users');
574+
575+
$builder->where('active', 1)
576+
->whereExists(static fn (BaseBuilder $builder) => $builder
577+
->select('1', false)
578+
->from('orders')
579+
->where('orders.status', 'paid')
580+
->whereColumn('orders.user_id', 'users.id'));
581+
582+
$expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 AND EXISTS (SELECT 1 FROM "orders" WHERE "orders"."status" = \'paid\' AND "orders"."user_id" = "users"."id")';
583+
$expectedBinds = [
584+
'active' => [
585+
1,
586+
true,
587+
],
588+
];
589+
590+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
591+
$this->assertSame($expectedBinds, $builder->getBinds());
592+
}
593+
594+
/**
595+
* @param mixed $subquery
596+
*/
597+
#[DataProvider('provideWhereExistsInvalidSubqueryThrowInvalidArgumentException')]
598+
public function testWhereExistsInvalidSubqueryThrowInvalidArgumentException($subquery): void
599+
{
600+
$this->expectException(InvalidArgumentException::class);
601+
602+
$builder = $this->db->table('users');
603+
$builder->whereExists($subquery);
604+
}
605+
606+
/**
607+
* @return iterable<string, array{mixed}>
608+
*/
609+
public static function provideWhereExistsInvalidSubqueryThrowInvalidArgumentException(): iterable
610+
{
611+
return [
612+
'null' => [null],
613+
'array' => [[]],
614+
'stdClass' => [new stdClass()],
615+
'raw string' => ['SELECT 1'],
616+
];
617+
}
618+
619+
public function testWhereExistsSameBaseBuilderObject(): void
620+
{
621+
$this->expectException(DatabaseException::class);
622+
$this->expectExceptionMessage('The subquery cannot be the same object as the main query object.');
623+
624+
$builder = $this->db->table('users');
625+
$builder->whereExists($builder);
626+
}
627+
492628
public function testWhereIn(): void
493629
{
494630
$builder = $this->db->table('jobs');

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ Query Builder
218218
-------------
219219

220220
- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`.
221+
- Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`.
221222
- Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations.
222223
- Added ``lockForUpdate()`` to add pessimistic write locks to ``SELECT`` queries on supported drivers. See :ref:`query-builder-lock-for-update`.
223224

user_guide_src/source/database/query_builder.rst

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,40 @@ $builder->orWhereColumn()
395395
This method is identical to ``whereColumn()``, except that multiple instances
396396
are joined by **OR**.
397397

398+
.. _query-builder-where-exists:
399+
400+
$builder->whereExists()
401+
-----------------------
402+
403+
.. versionadded:: 4.8.0
404+
405+
Generates a ``WHERE EXISTS`` subquery. This method accepts either a Closure or
406+
a ``BaseBuilder`` instance:
407+
408+
.. literalinclude:: query_builder/125.php
409+
410+
.. warning:: Raw SQL strings are not accepted. If you need to write the
411+
``EXISTS`` clause yourself, use ``where()`` with a manually escaped
412+
condition. See :ref:`query-builder-where-rawsql`.
413+
414+
$builder->orWhereExists()
415+
-------------------------
416+
417+
This method is identical to ``whereExists()``, except that multiple instances
418+
are joined by **OR**.
419+
420+
$builder->whereNotExists()
421+
--------------------------
422+
423+
This method is identical to ``whereExists()``, except that it generates a
424+
``WHERE NOT EXISTS`` subquery.
425+
426+
$builder->orWhereNotExists()
427+
----------------------------
428+
429+
This method is identical to ``whereNotExists()``, except that multiple
430+
instances are joined by **OR**.
431+
398432
$builder->whereIn()
399433
-------------------
400434

@@ -1645,6 +1679,38 @@ Class Reference
16451679
If ``$first`` does not end with a supported operator, ``=`` is used as the comparison operator.
16461680
Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``.
16471681

1682+
.. php:method:: whereExists($subquery)
1683+
1684+
:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
1685+
:returns: ``BaseBuilder`` instance (method chaining)
1686+
:rtype: ``BaseBuilder``
1687+
1688+
Generates a ``WHERE EXISTS`` subquery, joined with ``AND`` if appropriate.
1689+
1690+
.. php:method:: orWhereExists($subquery)
1691+
1692+
:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
1693+
:returns: ``BaseBuilder`` instance (method chaining)
1694+
:rtype: ``BaseBuilder``
1695+
1696+
Generates a ``WHERE EXISTS`` subquery, joined with ``OR`` if appropriate.
1697+
1698+
.. php:method:: whereNotExists($subquery)
1699+
1700+
:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
1701+
:returns: ``BaseBuilder`` instance (method chaining)
1702+
:rtype: ``BaseBuilder``
1703+
1704+
Generates a ``WHERE NOT EXISTS`` subquery, joined with ``AND`` if appropriate.
1705+
1706+
.. php:method:: orWhereNotExists($subquery)
1707+
1708+
:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
1709+
:returns: ``BaseBuilder`` instance (method chaining)
1710+
:rtype: ``BaseBuilder``
1711+
1712+
Generates a ``WHERE NOT EXISTS`` subquery, joined with ``OR`` if appropriate.
1713+
16481714
.. php:method:: orWhereIn([$key = null[, $values = null[, $escape = null]]])
16491715
16501716
:param string $key: The field to search
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
use CodeIgniter\Database\BaseBuilder;
4+
5+
// With closure
6+
$builder->whereExists(static function (BaseBuilder $builder) {
7+
$builder->select('1', false)
8+
->from('orders')
9+
->whereColumn('orders.user_id', 'users.id');
10+
});
11+
// Produces: WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")
12+
13+
// With builder directly
14+
$subQuery = $db->table('orders')->select('1', false)->whereColumn('orders.user_id', 'users.id');
15+
$builder->whereNotExists($subQuery);

0 commit comments

Comments
 (0)