Skip to content

Commit 310086a

Browse files
committed
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>
1 parent df9f137 commit 310086a

5 files changed

Lines changed: 295 additions & 0 deletions

File tree

system/Database/BaseBuilder.php

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

772+
/**
773+
* Generates a WHERE EXISTS subquery.
774+
*
775+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
776+
*
777+
* @return $this
778+
*/
779+
public function whereExists($subquery): static
780+
{
781+
return $this->_whereExists($subquery);
782+
}
783+
784+
/**
785+
* Generates an OR WHERE EXISTS subquery.
786+
*
787+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
788+
*
789+
* @return $this
790+
*/
791+
public function orWhereExists($subquery): static
792+
{
793+
return $this->_whereExists($subquery, false, 'OR ');
794+
}
795+
796+
/**
797+
* Generates a WHERE NOT EXISTS subquery.
798+
*
799+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
800+
*
801+
* @return $this
802+
*/
803+
public function whereNotExists($subquery): static
804+
{
805+
return $this->_whereExists($subquery, true);
806+
}
807+
808+
/**
809+
* Generates an OR WHERE NOT EXISTS subquery.
810+
*
811+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
812+
*
813+
* @return $this
814+
*/
815+
public function orWhereNotExists($subquery): static
816+
{
817+
return $this->_whereExists($subquery, true, 'OR ');
818+
}
819+
772820
/**
773821
* @used-by whereColumn()
774822
* @used-by orWhereColumn()
@@ -828,6 +876,35 @@ private function parseWhereColumnFirst(string $first): array
828876
return [$first, '='];
829877
}
830878

879+
/**
880+
* @used-by whereExists()
881+
* @used-by orWhereExists()
882+
* @used-by whereNotExists()
883+
* @used-by orWhereNotExists()
884+
*
885+
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
886+
*
887+
* @return $this
888+
*
889+
* @throws InvalidArgumentException
890+
*/
891+
protected function _whereExists($subquery, bool $not = false, string $type = 'AND '): static
892+
{
893+
if (! $this->isSubquery($subquery)) {
894+
throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function']));
895+
}
896+
897+
$prefix = $this->QBWhere === [] ? $this->groupGetType('') : $this->groupGetType($type);
898+
$operator = $not ? 'NOT EXISTS' : 'EXISTS';
899+
900+
$this->QBWhere[] = [
901+
'condition' => "{$prefix}{$operator} {$this->buildSubquery($subquery, true)}",
902+
'escape' => false,
903+
];
904+
905+
return $this;
906+
}
907+
831908
/**
832909
* @used-by where()
833910
* @used-by orWhere()

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
@@ -211,6 +211,7 @@ Query Builder
211211
-------------
212212

213213
- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`.
214+
- Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`.
214215
- Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations.
215216

216217
Forge

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/124.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.
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

@@ -1588,6 +1622,38 @@ Class Reference
15881622
If ``$first`` does not end with a supported operator, ``=`` is used as the comparison operator.
15891623
Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``.
15901624

1625+
.. php:method:: whereExists($subquery)
1626+
1627+
:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
1628+
:returns: ``BaseBuilder`` instance (method chaining)
1629+
:rtype: ``BaseBuilder``
1630+
1631+
Generates a ``WHERE EXISTS`` subquery, joined with ``AND`` if appropriate.
1632+
1633+
.. php:method:: orWhereExists($subquery)
1634+
1635+
:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
1636+
:returns: ``BaseBuilder`` instance (method chaining)
1637+
:rtype: ``BaseBuilder``
1638+
1639+
Generates a ``WHERE EXISTS`` subquery, joined with ``OR`` if appropriate.
1640+
1641+
.. php:method:: whereNotExists($subquery)
1642+
1643+
:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
1644+
:returns: ``BaseBuilder`` instance (method chaining)
1645+
:rtype: ``BaseBuilder``
1646+
1647+
Generates a ``WHERE NOT EXISTS`` subquery, joined with ``AND`` if appropriate.
1648+
1649+
.. php:method:: orWhereNotExists($subquery)
1650+
1651+
:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
1652+
:returns: ``BaseBuilder`` instance (method chaining)
1653+
:rtype: ``BaseBuilder``
1654+
1655+
Generates a ``WHERE NOT EXISTS`` subquery, joined with ``OR`` if appropriate.
1656+
15911657
.. php:method:: orWhereIn([$key = null[, $values = null[, $escape = null]]])
15921658
15931659
: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)