Skip to content

Commit be9ab43

Browse files
committed
feat(database): add Query Builder likeAny helpers
- add likeAny() and orLikeAny() for grouped OR LIKE searches - reuse existing LIKE handling for binds, escaping, RawSql, and insensitive search - document usage and add builder/live coverage Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 2ef1571 commit be9ab43

7 files changed

Lines changed: 262 additions & 1 deletion

File tree

system/Database/BaseBuilder.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,20 @@ public function like($field, string $match = '', string $side = 'both', ?bool $e
13961396
return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch);
13971397
}
13981398

1399+
/**
1400+
* Generates grouped LIKE portions of the query joined with OR.
1401+
*
1402+
* @param list<non-empty-string|RawSql> $fields
1403+
*
1404+
* @return $this
1405+
*
1406+
* @throws InvalidArgumentException
1407+
*/
1408+
public function likeAny(array $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false): static
1409+
{
1410+
return $this->likeAnyGroup($fields, $match, 'AND ', $side, $escape, $insensitiveSearch, __FUNCTION__);
1411+
}
1412+
13991413
/**
14001414
* Generates a NOT LIKE portion of the query.
14011415
* Separates multiple calls with 'AND'.
@@ -1422,6 +1436,20 @@ public function orLike($field, string $match = '', string $side = 'both', ?bool
14221436
return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch);
14231437
}
14241438

1439+
/**
1440+
* Generates grouped LIKE portions of the query joined with OR.
1441+
*
1442+
* @param list<non-empty-string|RawSql> $fields
1443+
*
1444+
* @return $this
1445+
*
1446+
* @throws InvalidArgumentException
1447+
*/
1448+
public function orLikeAny(array $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false): static
1449+
{
1450+
return $this->likeAnyGroup($fields, $match, 'OR ', $side, $escape, $insensitiveSearch, __FUNCTION__);
1451+
}
1452+
14251453
/**
14261454
* Generates a NOT LIKE portion of the query.
14271455
* Separates multiple calls with 'OR'.
@@ -1487,9 +1515,53 @@ public function orNotHavingLike($field, string $match = '', string $side = 'both
14871515
return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
14881516
}
14891517

1518+
/**
1519+
* @param list<non-empty-string|RawSql> $fields
1520+
*
1521+
* @return $this
1522+
*
1523+
* @throws InvalidArgumentException
1524+
*/
1525+
private function likeAnyGroup(array $fields, string $match, string $type, string $side, ?bool $escape, bool $insensitiveSearch, string $caller): static
1526+
{
1527+
$this->validateLikeAnyFields($fields, $caller);
1528+
1529+
$this->groupStartPrepare('', $type);
1530+
1531+
foreach ($fields as $index => $field) {
1532+
$this->_like($field, $match, $index === 0 ? 'AND ' : 'OR ', $side, '', $escape, $insensitiveSearch);
1533+
}
1534+
1535+
return $this->groupEndPrepare();
1536+
}
1537+
1538+
/**
1539+
* @param list<non-empty-string|RawSql> $fields
1540+
*
1541+
* @throws InvalidArgumentException
1542+
*/
1543+
private function validateLikeAnyFields(array $fields, string $caller): void
1544+
{
1545+
if ($fields === [] || ! array_is_list($fields)) {
1546+
throw new InvalidArgumentException(sprintf('%s() expects $fields to be a non-empty list of field names', $caller));
1547+
}
1548+
1549+
foreach ($fields as $field) {
1550+
if ($field instanceof RawSql) {
1551+
continue;
1552+
}
1553+
1554+
if (! is_string($field) || trim($field) === '') {
1555+
throw new InvalidArgumentException(sprintf('%s() expects $fields to contain only non-empty strings or RawSql instances', $caller));
1556+
}
1557+
}
1558+
}
1559+
14901560
/**
14911561
* @used-by like()
1562+
* @used-by likeAny()
14921563
* @used-by orLike()
1564+
* @used-by orLikeAny()
14931565
* @used-by notLike()
14941566
* @used-by orNotLike()
14951567
* @used-by havingLike()

system/Model.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use CodeIgniter\Database\Exceptions\DataException;
2323
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
2424
use CodeIgniter\Database\Query;
25+
use CodeIgniter\Database\RawSql;
2526
use CodeIgniter\Entity\Entity;
2627
use CodeIgniter\Exceptions\BadMethodCallException;
2728
use CodeIgniter\Exceptions\InvalidArgumentException;
@@ -57,6 +58,7 @@
5758
* @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
5859
* @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null)
5960
* @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
61+
* @method $this likeAny(list<RawSql|string> $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
6062
* @method $this limit(?int $value = null, ?int $offset = 0)
6163
* @method $this notGroupStart()
6264
* @method $this notHavingGroupStart()
@@ -73,6 +75,7 @@
7375
* @method $this orHavingNotBetween(?string $key = null, $values = null, ?bool $escape = null)
7476
* @method $this orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
7577
* @method $this orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
78+
* @method $this orLikeAny(list<RawSql|string> $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
7679
* @method $this orNotGroupStart()
7780
* @method $this orNotHavingGroupStart()
7881
* @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)

tests/system/Database/Builder/LikeTest.php

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515

1616
use CodeIgniter\Database\BaseBuilder;
1717
use CodeIgniter\Database\RawSql;
18+
use CodeIgniter\Exceptions\InvalidArgumentException;
1819
use CodeIgniter\Test\CIUnitTestCase;
1920
use CodeIgniter\Test\Mock\MockConnection;
21+
use PHPUnit\Framework\Attributes\DataProvider;
2022
use PHPUnit\Framework\Attributes\Group;
2123

2224
/**
@@ -52,6 +54,124 @@ public function testSimpleLike(): void
5254
$this->assertSame($expectedBinds, $builder->getBinds());
5355
}
5456

57+
public function testLikeAny(): void
58+
{
59+
$builder = new BaseBuilder('job', $this->db);
60+
61+
$builder->likeAny(['name', 'description'], 'veloper');
62+
63+
$expectedSQL = 'SELECT * FROM "job" WHERE ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )';
64+
$expectedBinds = [
65+
'name' => [
66+
'%veloper%',
67+
true,
68+
],
69+
'description' => [
70+
'%veloper%',
71+
true,
72+
],
73+
];
74+
75+
$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
76+
$this->assertSame($expectedBinds, $builder->getBinds());
77+
}
78+
79+
public function testLikeAnyAfterWhere(): void
80+
{
81+
$builder = new BaseBuilder('job', $this->db);
82+
83+
$builder->where('active', 1)
84+
->likeAny(['name', 'description'], 'veloper');
85+
86+
$expectedSQL = 'SELECT * FROM "job" WHERE "active" = 1 AND ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )';
87+
88+
$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
89+
}
90+
91+
public function testOrLikeAnyAfterWhere(): void
92+
{
93+
$builder = new BaseBuilder('job', $this->db);
94+
95+
$builder->where('active', 1)
96+
->orLikeAny(['name', 'description'], 'veloper');
97+
98+
$expectedSQL = 'SELECT * FROM "job" WHERE "active" = 1 OR ( "name" LIKE \'%veloper%\' ESCAPE \'!\' OR "description" LIKE \'%veloper%\' ESCAPE \'!\' )';
99+
100+
$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
101+
}
102+
103+
public function testLikeAnyCaseInsensitiveSearch(): void
104+
{
105+
$builder = new BaseBuilder('job', $this->db);
106+
107+
$builder->likeAny(['name', 'description'], 'VELOPER', 'both', null, true);
108+
109+
$expectedSQL = 'SELECT * FROM "job" WHERE ( LOWER("name") LIKE \'%veloper%\' ESCAPE \'!\' OR LOWER("description") LIKE \'%veloper%\' ESCAPE \'!\' )';
110+
$expectedBinds = [
111+
'name' => [
112+
'%veloper%',
113+
true,
114+
],
115+
'description' => [
116+
'%veloper%',
117+
true,
118+
],
119+
];
120+
121+
$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
122+
$this->assertSame($expectedBinds, $builder->getBinds());
123+
}
124+
125+
public function testLikeAnyWithRawSqlField(): void
126+
{
127+
$builder = new BaseBuilder('job', $this->db);
128+
$rawSql = new RawSql('LOWER(description)');
129+
130+
$builder->likeAny(['name', $rawSql], 'veloper', 'after');
131+
132+
$expectedSQL = 'SELECT * FROM "job" WHERE ( "name" LIKE \'veloper%\' ESCAPE \'!\' OR LOWER(description) LIKE \'veloper%\' ESCAPE \'!\' )';
133+
$expectedBinds = [
134+
'name' => [
135+
'veloper%',
136+
true,
137+
],
138+
$rawSql->getBindingKey() => [
139+
'veloper%',
140+
true,
141+
],
142+
];
143+
144+
$this->assertSame($expectedSQL, (string) preg_replace('/\s+/', ' ', trim($builder->getCompiledSelect())));
145+
$this->assertSame($expectedBinds, $builder->getBinds());
146+
}
147+
148+
/**
149+
* @param mixed $fields
150+
*/
151+
#[DataProvider('provideLikeAnyInvalidFieldsThrowInvalidArgumentException')]
152+
public function testLikeAnyInvalidFieldsThrowInvalidArgumentException($fields): void
153+
{
154+
$this->expectException(InvalidArgumentException::class);
155+
156+
$builder = new BaseBuilder('job', $this->db);
157+
158+
$builder->likeAny($fields, 'veloper');
159+
}
160+
161+
/**
162+
* @return iterable<string, array{mixed}>
163+
*/
164+
public static function provideLikeAnyInvalidFieldsThrowInvalidArgumentException(): iterable
165+
{
166+
return [
167+
'empty list' => [[]],
168+
'assoc array' => [['name' => 'description']],
169+
'empty field' => [['name', '']],
170+
'blank field' => [['name', ' ']],
171+
'int field' => [['name', 1]],
172+
];
173+
}
174+
55175
/**
56176
* @see https://github.com/codeigniter4/CodeIgniter4/issues/3970
57177
*/

tests/system/Database/Live/LikeTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ public function testLikeCaseInsensitive(): void
7676
$this->assertSame('Developer', $job->name);
7777
}
7878

79+
public function testLikeAny(): void
80+
{
81+
$jobs = $this->db->table('job')
82+
->likeAny(['name', 'description'], 'bor', 'both', null, true)
83+
->get()
84+
->getResult();
85+
86+
$this->assertCount(2, $jobs);
87+
$this->assertSame('Developer', $jobs[0]->name);
88+
$this->assertSame('Accountant', $jobs[1]->name);
89+
}
90+
7991
#[DataProvider('provideLikeCaseInsensitiveWithMultibyteCharacter')]
8092
public function testLikeCaseInsensitiveWithMultibyteCharacter(string $match, string $result): void
8193
{

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ Query Builder
221221
- 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`.
222222
- Added ``explain()`` to Query Builder to run execution-plan queries for the current ``SELECT`` query. See :ref:`query-builder-explain`.
223223
- Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`.
224+
- 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`.
224225
- Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`.
225226
- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`.
226227
- Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`.

user_guide_src/source/database/query_builder.rst

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,10 +594,28 @@ searches.
594594

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

597+
.. _query-builder-like-any:
598+
599+
$builder->likeAny()
600+
-------------------
601+
602+
.. versionadded:: 4.8.0
603+
604+
This method generates a grouped set of **LIKE** clauses joined by **OR**.
605+
Use it when you want to search for one value across multiple fields:
606+
607+
.. literalinclude:: query_builder/130.php
608+
609+
Unlike the associative array form of ``like()``, the field list must be a
610+
non-empty list of field names. The same match value is used for every field.
611+
The field list may also contain ``RawSql`` instances; see :ref:`query-builder-like-rawsql`.
612+
Use ``orLikeAny()`` when the grouped search should be separated from previous
613+
conditions with **OR**.
614+
597615
$builder->orLike()
598616
------------------
599617

600-
This method is identical to the one above, except that multiple
618+
This method is identical to ``like()``, except that multiple
601619
instances are joined by **OR**:
602620

603621
.. literalinclude:: query_builder/042.php
@@ -1995,6 +2013,18 @@ Class Reference
19952013

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

2016+
.. php:method:: likeAny($fields[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])
2017+
2018+
:param array $fields: List of field names or RawSql instances
2019+
:param string $match: Text portion to match
2020+
:param string $side: Which side of the expression to put the '%' wildcard on
2021+
:param bool $escape: Whether to escape values and identifiers
2022+
:param bool $insensitiveSearch: Whether to force a case-insensitive search
2023+
:returns: ``BaseBuilder`` instance (method chaining)
2024+
:rtype: ``BaseBuilder``
2025+
2026+
Adds grouped ``LIKE`` clauses joined with ``OR``.
2027+
19982028
.. php:method:: orLike($field[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])
19992029
20002030
:param string $field: Field name
@@ -2007,6 +2037,18 @@ Class Reference
20072037

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

2040+
.. php:method:: orLikeAny($fields[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])
2041+
2042+
:param array $fields: List of field names or RawSql instances
2043+
:param string $match: Text portion to match
2044+
:param string $side: Which side of the expression to put the '%' wildcard on
2045+
:param bool $escape: Whether to escape values and identifiers
2046+
:param bool $insensitiveSearch: Whether to force a case-insensitive search
2047+
:returns: ``BaseBuilder`` instance (method chaining)
2048+
:rtype: ``BaseBuilder``
2049+
2050+
Adds grouped ``LIKE`` clauses joined with ``OR``, separating the group from previous conditions with ``OR``.
2051+
20102052
.. php:method:: notLike($field[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])
20112053
20122054
:param string $field: Field name
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
$builder->likeAny(['title', 'body', 'summary'], $match);
4+
5+
/*
6+
* WHERE (
7+
* `title` LIKE '%match%' ESCAPE '!'
8+
* OR `body` LIKE '%match%' ESCAPE '!'
9+
* OR `summary` LIKE '%match%' ESCAPE '!'
10+
* )
11+
*/

0 commit comments

Comments
 (0)