diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 29af09d0f2f6..36145a731e52 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -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 $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'. @@ -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 $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'. @@ -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 $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 $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() diff --git a/system/Model.php b/system/Model.php index f6b125260398..bc43b1db4bc7 100644 --- a/system/Model.php +++ b/system/Model.php @@ -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; @@ -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 $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() @@ -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 $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) diff --git a/tests/system/Database/Builder/LikeTest.php b/tests/system/Database/Builder/LikeTest.php index d2a534b5bf27..15b2f7f45b85 100644 --- a/tests/system/Database/Builder/LikeTest.php +++ b/tests/system/Database/Builder/LikeTest.php @@ -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; /** @@ -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 + */ + 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 */ diff --git a/tests/system/Database/Live/LikeTest.php b/tests/system/Database/Live/LikeTest.php index c03dcd8d096c..9ecd8f9bef80 100644 --- a/tests/system/Database/Live/LikeTest.php +++ b/tests/system/Database/Live/LikeTest.php @@ -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 { diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 359448aa83b5..9fc05e55d9b5 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -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`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 0144c28eeb65..6844bdb31dc0 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -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 @@ -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 @@ -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 diff --git a/user_guide_src/source/database/query_builder/130.php b/user_guide_src/source/database/query_builder/130.php new file mode 100644 index 000000000000..545a34a067b5 --- /dev/null +++ b/user_guide_src/source/database/query_builder/130.php @@ -0,0 +1,11 @@ +likeAny(['title', 'body', 'summary'], $match); + +/* + * WHERE ( + * `title` LIKE '%match%' ESCAPE '!' + * OR `body` LIKE '%match%' ESCAPE '!' + * OR `summary` LIKE '%match%' ESCAPE '!' + * ) + */