Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
77 changes: 77 additions & 0 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,54 @@ public function orWhereColumn(string $first, string $second, ?bool $escape = nul
return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape);
}

/**
* Generates a WHERE EXISTS subquery.
*
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
*
* @return $this
*/
public function whereExists($subquery): static
{
return $this->_whereExists($subquery);
}

/**
* Generates an OR WHERE EXISTS subquery.
*
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
*
* @return $this
*/
public function orWhereExists($subquery): static
{
return $this->_whereExists($subquery, false, 'OR ');
}

/**
* Generates a WHERE NOT EXISTS subquery.
*
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
*
* @return $this
*/
public function whereNotExists($subquery): static
{
return $this->_whereExists($subquery, true);
}

/**
* Generates an OR WHERE NOT EXISTS subquery.
*
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
*
* @return $this
*/
public function orWhereNotExists($subquery): static
{
return $this->_whereExists($subquery, true, 'OR ');
}

/**
* @used-by whereColumn()
* @used-by orWhereColumn()
Expand Down Expand Up @@ -828,6 +876,35 @@ private function parseWhereColumnFirst(string $first): array
return [$first, '='];
}

/**
* @used-by whereExists()
* @used-by orWhereExists()
* @used-by whereNotExists()
* @used-by orWhereNotExists()
*
* @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
*
* @return $this
*
* @throws InvalidArgumentException
*/
protected function _whereExists($subquery, bool $not = false, string $type = 'AND '): static
{
if (! $this->isSubquery($subquery)) {
throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function']));
}

$prefix = $this->QBWhere === [] ? $this->groupGetType('') : $this->groupGetType($type);
$operator = $not ? 'NOT EXISTS' : 'EXISTS';

$this->QBWhere[] = [
'condition' => "{$prefix}{$operator} {$this->buildSubquery($subquery, true)}",
'escape' => false,
];

return $this;
}

/**
* @used-by where()
* @used-by orWhere()
Expand Down
136 changes: 136 additions & 0 deletions tests/system/Database/Builder/WhereTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace CodeIgniter\Database\Builder;

use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Database\RawSql;
use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\Test\CIUnitTestCase;
Expand Down Expand Up @@ -489,6 +490,141 @@ public static function provideWhereColumnInvalidColumnThrowInvalidArgumentExcept
];
}

public function testWhereExistsSubQuery(): void
{
$expectedSQL = 'SELECT * FROM "users" WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")';

// Closure
$builder = $this->db->table('users');

$builder->whereExists(static fn (BaseBuilder $builder) => $builder
->select('1', false)
->from('orders')
->whereColumn('orders.user_id', 'users.id'));

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));

// Builder
$builder = $this->db->table('users');

$subQuery = $this->db->table('orders')
->select('1', false)
->whereColumn('orders.user_id', 'users.id');

$builder->whereExists($subQuery);

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
}

#[DataProvider('provideWhereExistsVariants')]
public function testWhereExistsVariants(string $method, string $expectedSQL): void
{
$builder = $this->db->table('users');

$builder->where('active', 1);

$builder->{$method}(static fn (BaseBuilder $builder) => $builder
->select('1', false)
->from('orders')
->whereColumn('orders.user_id', 'users.id'));

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
}

/**
* @return iterable<string, array{string, string}>
*/
public static function provideWhereExistsVariants(): iterable
{
$exists = '(SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")';
$baseQuery = 'SELECT * FROM "users" WHERE "active" = 1';

return [
'whereExists' => ['whereExists', "{$baseQuery} AND EXISTS {$exists}"],
'orWhereExists' => ['orWhereExists', "{$baseQuery} OR EXISTS {$exists}"],
'whereNotExists' => ['whereNotExists', "{$baseQuery} AND NOT EXISTS {$exists}"],
'orWhereNotExists' => ['orWhereNotExists', "{$baseQuery} OR NOT EXISTS {$exists}"],
];
}

public function testWhereExistsWithGroupedConditions(): void
{
$builder = $this->db->table('users');

$builder->groupStart()
->whereExists(static fn (BaseBuilder $builder) => $builder
->select('1', false)
->from('orders')
->whereColumn('orders.user_id', 'users.id'))
->orWhereNotExists(static fn (BaseBuilder $builder) => $builder
->select('1', false)
->from('jobs')
->whereColumn('jobs.user_id', 'users.id'))
->groupEnd()
->where('active', 1);

$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';

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
}

public function testWhereExistsWithOuterAndInnerBinds(): void
{
$builder = $this->db->table('users');

$builder->where('active', 1)
->whereExists(static fn (BaseBuilder $builder) => $builder
->select('1', false)
->from('orders')
->where('orders.status', 'paid')
->whereColumn('orders.user_id', 'users.id'));

$expectedSQL = 'SELECT * FROM "users" WHERE "active" = 1 AND EXISTS (SELECT 1 FROM "orders" WHERE "orders"."status" = \'paid\' AND "orders"."user_id" = "users"."id")';
$expectedBinds = [
'active' => [
1,
true,
],
];

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
$this->assertSame($expectedBinds, $builder->getBinds());
}

/**
* @param mixed $subquery
*/
#[DataProvider('provideWhereExistsInvalidSubqueryThrowInvalidArgumentException')]
public function testWhereExistsInvalidSubqueryThrowInvalidArgumentException($subquery): void
{
$this->expectException(InvalidArgumentException::class);

$builder = $this->db->table('users');
$builder->whereExists($subquery);
}

/**
* @return iterable<string, array{mixed}>
*/
public static function provideWhereExistsInvalidSubqueryThrowInvalidArgumentException(): iterable
{
return [
'null' => [null],
'array' => [[]],
'stdClass' => [new stdClass()],
'raw string' => ['SELECT 1'],
];
}

public function testWhereExistsSameBaseBuilderObject(): void
{
$this->expectException(DatabaseException::class);
$this->expectExceptionMessage('The subquery cannot be the same object as the main query object.');

$builder = $this->db->table('users');
$builder->whereExists($builder);
}

public function testWhereIn(): void
{
$builder = $this->db->table('jobs');
Expand Down
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ Query Builder
-------------

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

Forge
Expand Down
66 changes: 66 additions & 0 deletions user_guide_src/source/database/query_builder.rst
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,40 @@ $builder->orWhereColumn()
This method is identical to ``whereColumn()``, except that multiple instances
are joined by **OR**.

.. _query-builder-where-exists:

$builder->whereExists()
-----------------------

.. versionadded:: 4.8.0

Generates a ``WHERE EXISTS`` subquery. This method accepts either a Closure or
a ``BaseBuilder`` instance:

.. literalinclude:: query_builder/124.php

.. warning:: Raw SQL strings are not accepted. If you need to write the
``EXISTS`` clause yourself, use ``where()`` with a manually escaped
condition.

$builder->orWhereExists()
-------------------------

This method is identical to ``whereExists()``, except that multiple instances
are joined by **OR**.

$builder->whereNotExists()
--------------------------

This method is identical to ``whereExists()``, except that it generates a
``WHERE NOT EXISTS`` subquery.

$builder->orWhereNotExists()
----------------------------

This method is identical to ``whereNotExists()``, except that multiple
instances are joined by **OR**.

$builder->whereIn()
-------------------

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

.. php:method:: whereExists($subquery)

:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
:returns: ``BaseBuilder`` instance (method chaining)
:rtype: ``BaseBuilder``

Generates a ``WHERE EXISTS`` subquery, joined with ``AND`` if appropriate.

.. php:method:: orWhereExists($subquery)

:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
:returns: ``BaseBuilder`` instance (method chaining)
:rtype: ``BaseBuilder``

Generates a ``WHERE EXISTS`` subquery, joined with ``OR`` if appropriate.

.. php:method:: whereNotExists($subquery)

:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
:returns: ``BaseBuilder`` instance (method chaining)
:rtype: ``BaseBuilder``

Generates a ``WHERE NOT EXISTS`` subquery, joined with ``AND`` if appropriate.

.. php:method:: orWhereNotExists($subquery)

:param BaseBuilder|Closure $subquery: The subquery to check for matching rows
:returns: ``BaseBuilder`` instance (method chaining)
:rtype: ``BaseBuilder``

Generates a ``WHERE NOT EXISTS`` subquery, joined with ``OR`` if appropriate.

.. php:method:: orWhereIn([$key = null[, $values = null[, $escape = null]]])

:param string $key: The field to search
Expand Down
15 changes: 15 additions & 0 deletions user_guide_src/source/database/query_builder/124.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use CodeIgniter\Database\BaseBuilder;

// With closure
$builder->whereExists(static function (BaseBuilder $builder) {
$builder->select('1', false)
->from('orders')
->whereColumn('orders.user_id', 'users.id');
});
// Produces: WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")

// With builder directly
$subQuery = $db->table('orders')->select('1', false)->whereColumn('orders.user_id', 'users.id');
$builder->whereNotExists($subQuery);
Loading