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
72 changes: 72 additions & 0 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,20 @@ public function like($field, string $match = '', string $side = 'both', ?bool $e
return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch);
}

/**
* Generates grouped LIKE portions of the query joined with OR.
*
* @param list<non-empty-string|RawSql> $fields
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function likeAny(array $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false): static
{
return $this->likeAnyGroup($fields, $match, 'AND ', $side, $escape, $insensitiveSearch, __FUNCTION__);
}

/**
* Generates a NOT LIKE portion of the query.
* Separates multiple calls with 'AND'.
Expand All @@ -1422,6 +1436,20 @@ public function orLike($field, string $match = '', string $side = 'both', ?bool
return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch);
}

/**
* Generates grouped LIKE portions of the query joined with OR.
*
* @param list<non-empty-string|RawSql> $fields
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function orLikeAny(array $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false): static
{
return $this->likeAnyGroup($fields, $match, 'OR ', $side, $escape, $insensitiveSearch, __FUNCTION__);
}

/**
* Generates a NOT LIKE portion of the query.
* Separates multiple calls with 'OR'.
Expand Down Expand Up @@ -1487,9 +1515,53 @@ public function orNotHavingLike($field, string $match = '', string $side = 'both
return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
}

/**
* @param list<non-empty-string|RawSql> $fields
*
* @return $this
*
* @throws InvalidArgumentException
*/
private function likeAnyGroup(array $fields, string $match, string $type, string $side, ?bool $escape, bool $insensitiveSearch, string $caller): static
{
$this->validateLikeAnyFields($fields, $caller);

$this->groupStartPrepare('', $type);

foreach ($fields as $index => $field) {
$this->_like($field, $match, $index === 0 ? 'AND ' : 'OR ', $side, '', $escape, $insensitiveSearch);
}

return $this->groupEndPrepare();
}

/**
* @param list<non-empty-string|RawSql> $fields
*
* @throws InvalidArgumentException
*/
private function validateLikeAnyFields(array $fields, string $caller): void
{
if ($fields === [] || ! array_is_list($fields)) {
throw new InvalidArgumentException(sprintf('%s() expects $fields to be a non-empty list of field names', $caller));
}

foreach ($fields as $field) {
if ($field instanceof RawSql) {
continue;
}

if (! is_string($field) || trim($field) === '') {
throw new InvalidArgumentException(sprintf('%s() expects $fields to contain only non-empty strings or RawSql instances', $caller));
}
}
}

/**
* @used-by like()
* @used-by likeAny()
* @used-by orLike()
* @used-by orLikeAny()
* @used-by notLike()
* @used-by orNotLike()
* @used-by havingLike()
Expand Down
3 changes: 3 additions & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use CodeIgniter\Database\Exceptions\DataException;
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
use CodeIgniter\Database\Query;
use CodeIgniter\Database\RawSql;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Exceptions\BadMethodCallException;
use CodeIgniter\Exceptions\InvalidArgumentException;
Expand Down Expand Up @@ -57,6 +58,7 @@
* @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
* @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null)
* @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
* @method $this likeAny(list<RawSql|string> $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
* @method $this limit(?int $value = null, ?int $offset = 0)
* @method $this notGroupStart()
* @method $this notHavingGroupStart()
Expand All @@ -73,6 +75,7 @@
* @method $this orHavingNotBetween(?string $key = null, $values = null, ?bool $escape = null)
* @method $this orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
* @method $this orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
* @method $this orLikeAny(list<RawSql|string> $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
* @method $this orNotGroupStart()
* @method $this orNotHavingGroupStart()
* @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
Expand Down
120 changes: 120 additions & 0 deletions tests/system/Database/Builder/LikeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@

use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\RawSql;
use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Mock\MockConnection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;

/**
Expand Down Expand Up @@ -52,6 +54,124 @@ public function testSimpleLike(): void
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testLikeAny(): void
{
$builder = new BaseBuilder('job', $this->db);

$builder->likeAny(['name', 'description'], 'veloper');

$expectedSQL = 'SELECT * FROM "job" WHERE ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )';
$expectedBinds = [
'name' => [
'%veloper%',
true,
],
'description' => [
'%veloper%',
true,
],
];

$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testLikeAnyAfterWhere(): void
{
$builder = new BaseBuilder('job', $this->db);

$builder->where('active', 1)
->likeAny(['name', 'description'], 'veloper');

$expectedSQL = 'SELECT * FROM "job" WHERE "active" = 1 AND ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )';

$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
}

public function testOrLikeAnyAfterWhere(): void
{
$builder = new BaseBuilder('job', $this->db);

$builder->where('active', 1)
->orLikeAny(['name', 'description'], 'veloper');

$expectedSQL = 'SELECT * FROM "job" WHERE "active" = 1 OR ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )';

$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
}

public function testLikeAnyCaseInsensitiveSearch(): void
{
$builder = new BaseBuilder('job', $this->db);

$builder->likeAny(['name', 'description'], 'VELOPER', 'both', null, true);

$expectedSQL = 'SELECT * FROM "job" WHERE ( LOWER("name") LIKE \'%veloper%\' ESCAPE \'!\' OR LOWER("description") LIKE \'%veloper%\' ESCAPE \'!\' )';
$expectedBinds = [
'name' => [
'%veloper%',
true,
],
'description' => [
'%veloper%',
true,
],
];

$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testLikeAnyWithRawSqlField(): void
{
$builder = new BaseBuilder('job', $this->db);
$rawSql = new RawSql('LOWER(description)');

$builder->likeAny(['name', $rawSql], 'veloper', 'after');

$expectedSQL = 'SELECT * FROM "job" WHERE ( "name" LIKE \'veloper%\' ESCAPE \'!\' OR LOWER(description) LIKE \'veloper%\' ESCAPE \'!\' )';
$expectedBinds = [
'name' => [
'veloper%',
true,
],
$rawSql->getBindingKey() => [
'veloper%',
true,
],
];

$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
$this->assertSame($expectedBinds, $builder->getBinds());
}

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

$builder = new BaseBuilder('job', $this->db);

$builder->likeAny($fields, 'veloper');
}

/**
* @return iterable<string, array{mixed}>
*/
public static function provideLikeAnyInvalidFieldsThrowInvalidArgumentException(): iterable
{
return [
'empty list' => [[]],
'assoc array' => [['name' => 'description']],
'empty field' => [['name', '']],
'blank field' => [['name', ' ']],
'int field' => [['name', 1]],
];
}

/**
* @see https://github.com/codeigniter4/CodeIgniter4/issues/3970
*/
Expand Down
12 changes: 12 additions & 0 deletions tests/system/Database/Live/LikeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ public function testLikeCaseInsensitive(): void
$this->assertSame('Developer', $job->name);
}

public function testLikeAny(): void
{
$jobs = $this->db->table('job')
->likeAny(['name', 'description'], 'bor', 'both', null, true)
->get()
->getResult();

$this->assertCount(2, $jobs);
$this->assertSame('Developer', $jobs[0]->name);
$this->assertSame('Accountant', $jobs[1]->name);
}

#[DataProvider('provideLikeCaseInsensitiveWithMultibyteCharacter')]
public function testLikeCaseInsensitiveWithMultibyteCharacter(string $match, string $result): void
{
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 @@ -221,6 +221,7 @@ Query Builder
- Added ``exists()`` and ``doesntExist()`` to Query Builder to check whether the current Query Builder query would return at least one row. See :ref:`query-builder-exists`.
- Added ``explain()`` to Query Builder to run execution-plan queries for the current ``SELECT`` query. See :ref:`query-builder-explain`.
- Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`.
- Added ``likeAny()`` and ``orLikeAny()`` to Query Builder to search one value across multiple fields with grouped ``OR`` ``LIKE`` conditions. See :ref:`query-builder-like-any`.
- Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`.
- 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`.
Expand Down
44 changes: 43 additions & 1 deletion user_guide_src/source/database/query_builder.rst
Original file line number Diff line number Diff line change
Expand Up @@ -594,10 +594,28 @@ searches.

.. warning:: When you use ``RawSql``, you MUST escape the values and protect the identifiers manually. Failure to do so could result in SQL injections.

.. _query-builder-like-any:

$builder->likeAny()
-------------------

.. versionadded:: 4.8.0

This method generates a grouped set of **LIKE** clauses joined by **OR**.
Use it when you want to search for one value across multiple fields:

.. literalinclude:: query_builder/130.php

Unlike the associative array form of ``like()``, the field list must be a
non-empty list of field names. The same match value is used for every field.
The field list may also contain ``RawSql`` instances; see :ref:`query-builder-like-rawsql`.
Use ``orLikeAny()`` when the grouped search should be separated from previous
conditions with **OR**.

$builder->orLike()
------------------

This method is identical to the one above, except that multiple
This method is identical to ``like()``, except that multiple
instances are joined by **OR**:

.. literalinclude:: query_builder/042.php
Expand Down Expand Up @@ -1995,6 +2013,18 @@ Class Reference

Adds a ``LIKE`` clause to a query, separating multiple calls with ``AND``.

.. php:method:: likeAny($fields[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])

:param array $fields: List of field names or RawSql instances
:param string $match: Text portion to match
:param string $side: Which side of the expression to put the '%' wildcard on
:param bool $escape: Whether to escape values and identifiers
:param bool $insensitiveSearch: Whether to force a case-insensitive search
:returns: ``BaseBuilder`` instance (method chaining)
:rtype: ``BaseBuilder``

Adds grouped ``LIKE`` clauses joined with ``OR``.

.. php:method:: orLike($field[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])

:param string $field: Field name
Expand All @@ -2007,6 +2037,18 @@ Class Reference

Adds a ``LIKE`` clause to a query, separating multiple class with ``OR``.

.. php:method:: orLikeAny($fields[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])

:param array $fields: List of field names or RawSql instances
:param string $match: Text portion to match
:param string $side: Which side of the expression to put the '%' wildcard on
:param bool $escape: Whether to escape values and identifiers
:param bool $insensitiveSearch: Whether to force a case-insensitive search
:returns: ``BaseBuilder`` instance (method chaining)
:rtype: ``BaseBuilder``

Adds grouped ``LIKE`` clauses joined with ``OR``, separating the group from previous conditions with ``OR``.

.. php:method:: notLike($field[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])

:param string $field: Field name
Expand Down
11 changes: 11 additions & 0 deletions user_guide_src/source/database/query_builder/130.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

$builder->likeAny(['title', 'body', 'summary'], $match);

/*
* WHERE (
* `title` LIKE '%match%' ESCAPE '!'
* OR `body` LIKE '%match%' ESCAPE '!'
* OR `summary` LIKE '%match%' ESCAPE '!'
* )
*/
Loading