diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 1d0c241903e8..be1aee7e1a60 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -356,7 +356,7 @@ public function ignore(bool $ignore = true) /** * Generates the SELECT portion of the query * - * @param array|string $select + * @param array|RawSql|string $select * * @return $this */ @@ -371,6 +371,12 @@ public function select($select = '*', ?bool $escape = null) $escape = $this->db->protectIdentifiers; } + if ($select instanceof RawSql) { + $this->QBSelect[] = $select; + + return $this; + } + foreach ($select as $val) { $val = trim($val); @@ -643,8 +649,8 @@ public function join(string $table, string $cond, string $type = '', ?bool $esca * Generates the WHERE portion of the query. * Separates multiple calls with 'AND'. * - * @param mixed $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ @@ -659,9 +665,9 @@ public function where($key, $value = null, ?bool $escape = null) * Generates the WHERE portion of the query. * Separates multiple calls with 'OR'. * - * @param mixed $key - * @param mixed $value - * @param bool $escape + * @param array|RawSql|string $key + * @param mixed $value + * @param bool $escape * * @return $this */ @@ -676,15 +682,20 @@ public function orWhere($key, $value = null, ?bool $escape = null) * @used-by having() * @used-by orHaving() * - * @param mixed $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null) { - if (! is_array($key)) { - $key = [$key => $value]; + if ($key instanceof RawSql) { + $keyValue = [(string) $key => $key]; + $escape = false; + } elseif (! is_array($key)) { + $keyValue = [$key => $value]; + } else { + $keyValue = $key; } // If the escape value was not set will base it on the global setting @@ -692,10 +703,13 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type $escape = $this->db->protectIdentifiers; } - foreach ($key as $k => $v) { + foreach ($keyValue as $k => $v) { $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type); - if ($v !== null) { + if ($v instanceof RawSql) { + $k = ''; + $op = ''; + } elseif ($v !== null) { $op = $this->getOperator($k, true); if (! empty($op)) { @@ -731,10 +745,17 @@ protected function whereHaving(string $qbKey, $key, $value = null, string $type $op = ''; } - $this->{$qbKey}[] = [ - 'condition' => $prefix . $k . $op . $v, - 'escape' => $escape, - ]; + if ($v instanceof RawSql) { + $this->{$qbKey}[] = [ + 'condition' => $v->with($prefix . $k . $op . $v), + 'escape' => $escape, + ]; + } else { + $this->{$qbKey}[] = [ + 'condition' => $prefix . $k . $op . $v, + 'escape' => $escape, + ]; + } } return $this; @@ -911,7 +932,7 @@ protected function _whereIn(?string $key = null, $values = null, bool $not = fal * Generates a %LIKE% portion of the query. * Separates multiple calls with 'AND'. * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ @@ -924,7 +945,7 @@ public function like($field, string $match = '', string $side = 'both', ?bool $e * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'AND'. * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ @@ -937,7 +958,7 @@ public function notLike($field, string $match = '', string $side = 'both', ?bool * Generates a %LIKE% portion of the query. * Separates multiple calls with 'OR'. * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ @@ -950,7 +971,7 @@ public function orLike($field, string $match = '', string $side = 'both', ?bool * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'OR'. * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ @@ -963,7 +984,7 @@ public function orNotLike($field, string $match = '', string $side = 'both', ?bo * Generates a %LIKE% portion of the query. * Separates multiple calls with 'AND'. * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ @@ -976,7 +997,7 @@ public function havingLike($field, string $match = '', string $side = 'both', ?b * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'AND'. * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ @@ -989,7 +1010,7 @@ public function notHavingLike($field, string $match = '', string $side = 'both', * Generates a %LIKE% portion of the query. * Separates multiple calls with 'OR'. * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ @@ -1002,7 +1023,7 @@ public function orHavingLike($field, string $match = '', string $side = 'both', * Generates a NOT LIKE portion of the query. * Separates multiple calls with 'OR'. * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ @@ -1021,20 +1042,50 @@ public function orNotHavingLike($field, string $match = '', string $side = 'both * @used-by notHavingLike() * @used-by orNotHavingLike() * - * @param mixed $field + * @param array|RawSql|string $field * * @return $this */ protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere') { - if (! is_array($field)) { - $field = [$field => $match]; - } - $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers; $side = strtolower($side); - foreach ($field as $k => $v) { + if ($field instanceof RawSql) { + $k = (string) $field; + $v = $match; + $insensitiveSearch = false; + + $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type); + + if ($side === 'none') { + $bind = $this->setBind($field->getBindingKey(), $v, $escape); + } elseif ($side === 'before') { + $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape); + } elseif ($side === 'after') { + $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape); + } else { + $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape); + } + + $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch); + + // some platforms require an escape sequence definition for LIKE wildcards + if ($escape === true && $this->db->likeEscapeStr !== '') { + $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar); + } + + $this->{$clause}[] = [ + 'condition' => $field->with($likeStatement), + 'escape' => $escape, + ]; + + return $this; + } + + $keyValue = ! is_array($field) ? [$field => $match] : $field; + + foreach ($keyValue as $k => $v) { if ($insensitiveSearch === true) { $v = strtolower($v); } @@ -1269,8 +1320,8 @@ public function groupBy($by, ?bool $escape = null) /** * Separates multiple calls with 'AND'. * - * @param array|string $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ @@ -1282,8 +1333,8 @@ public function having($key, $value = null, ?bool $escape = null) /** * Separates multiple calls with 'OR'. * - * @param array|string $key - * @param mixed $value + * @param array|RawSql|string $key + * @param mixed $value * * @return $this */ @@ -2339,6 +2390,8 @@ protected function compileSelect($selectOverride = false): string if (empty($this->QBSelect)) { $sql .= '*'; + } elseif ($this->QBSelect[0] instanceof RawSql) { + $sql .= (string) $this->QBSelect[0]; } else { // Cycle through the "select" portion of the query and prep each column name. // The reason we protect identifiers here rather than in the select() function @@ -2407,6 +2460,12 @@ protected function compileWhereHaving(string $qbKey): string continue; } + if ($qbkey['condition'] instanceof RawSql) { + $qbkey = $qbkey['condition']; + + continue; + } + if ($qbkey['escape'] === false) { $qbkey = $qbkey['condition']; diff --git a/system/Database/RawSql.php b/system/Database/RawSql.php new file mode 100644 index 000000000000..7ecb7fd378ae --- /dev/null +++ b/system/Database/RawSql.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +class RawSql +{ + /** + * @var string Raw SQL string + */ + private string $string; + + public function __construct(string $sqlString) + { + $this->string = $sqlString; + } + + public function __toString(): string + { + return $this->string; + } + + /** + * Create new instance with new SQL string + */ + public function with(string $newSqlString): self + { + $new = clone $this; + $new->string = $newSqlString; + + return $new; + } + + /** + * Returns unique id for binding key + */ + public function getBindingKey(): string + { + return 'RawSql' . spl_object_id($this); + } +} diff --git a/tests/system/Database/Builder/LikeTest.php b/tests/system/Database/Builder/LikeTest.php index 2d81db2b90fa..22afc5c7549c 100644 --- a/tests/system/Database/Builder/LikeTest.php +++ b/tests/system/Database/Builder/LikeTest.php @@ -12,6 +12,7 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\RawSql; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; @@ -47,6 +48,29 @@ public function testSimpleLike() $this->assertSame($expectedBinds, $builder->getBinds()); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/3970 + */ + public function testLikeWithRawSql() + { + $builder = new BaseBuilder('users', $this->db); + + $sql = "concat(users.name, ' ', IF(users.surname IS NULL or users.surname = '', '', users.surname))"; + $rawSql = new RawSql($sql); + $builder->like($rawSql, 'value', 'both'); + + $expectedSQL = "SELECT * FROM \"users\" WHERE {$sql} LIKE '%value%' ESCAPE '!' "; + $expectedBinds = [ + $rawSql->getBindingKey() => [ + '%value%', + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + public function testLikeNoSide() { $builder = new BaseBuilder('job', $this->db); diff --git a/tests/system/Database/Builder/SelectTest.php b/tests/system/Database/Builder/SelectTest.php index 97460f0010da..808e581d6ce5 100644 --- a/tests/system/Database/Builder/SelectTest.php +++ b/tests/system/Database/Builder/SelectTest.php @@ -13,6 +13,7 @@ use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\RawSql; use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; @@ -95,6 +96,20 @@ public function testSelectWorksWithComplexSelects() $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); } + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues/4355 + */ + public function testSelectWorksWithRawSql() + { + $builder = new BaseBuilder('users', $this->db); + + $sql = 'REGEXP_SUBSTR(ral_anno,"[0-9]{1,2}([,.][0-9]{1,3})([,.][0-9]{1,3})") AS ral'; + $builder->select(new RawSql($sql)); + + $expected = 'SELECT ' . $sql . ' FROM "users"'; + $this->assertSame($expected, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + public function testSelectMinWithNoAlias() { $builder = new BaseBuilder('invoices', $this->db); diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index ace628f6bc38..a10f20e8545d 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -12,6 +12,7 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\RawSql; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use stdClass; @@ -140,6 +141,20 @@ public function testWhereCustomString() $this->assertSame($expectedBinds, $builder->getBinds()); } + public function testWhereRawSql() + { + $builder = $this->db->table('jobs'); + + $sql = "id > 2 AND name != 'Accountant'"; + $builder->where(new RawSql($sql)); + + $expectedSQL = "SELECT * FROM \"jobs\" WHERE id > 2 AND name != 'Accountant'"; + $expectedBinds = []; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + public function testWhereValueSubQuery() { $expectedSQL = 'SELECT * FROM "neworder" WHERE "advance_amount" < (SELECT MAX(advance_amount) FROM "orders" WHERE "id" > 2)'; diff --git a/tests/system/Database/RawSqlTest.php b/tests/system/Database/RawSqlTest.php new file mode 100644 index 000000000000..a271486141a7 --- /dev/null +++ b/tests/system/Database/RawSqlTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + */ +final class RawSqlTest extends CIUnitTestCase +{ + public function testCanConvertToString() + { + $expected = 'REGEXP_SUBSTR(ral_anno,"[0-9]{1,2}([,.][0-9]{1,3})([,.][0-9]{1,3})") AS ral'; + $rawSql = new RawSql($expected); + + $this->assertSame($expected, (string) $rawSql); + } + + public function testCanCreateNewObject() + { + $firstSql = 'a = 1 AND b = 2'; + $rawSql = new RawSql($firstSql); + + $secondSql = 'a = 1 AND b = 2 OR c = 3'; + $newRawSQL = $rawSql->with($secondSql); + + $this->assertSame($firstSql, (string) $rawSql); + $this->assertSame($secondSql, (string) $newRawSQL); + } + + public function testGetBindingKey() + { + $firstSql = 'a = 1 AND b = 2'; + $rawSql = new RawSql($firstSql); + + $key = $rawSql->getBindingKey(); + + $this->assertMatchesRegularExpression('/\ARawSql[0-9]+\z/', $key); + } +} diff --git a/user_guide_src/source/changelogs/v4.2.0.rst b/user_guide_src/source/changelogs/v4.2.0.rst index 14e63e6990cb..04b42eff6f04 100644 --- a/user_guide_src/source/changelogs/v4.2.0.rst +++ b/user_guide_src/source/changelogs/v4.2.0.rst @@ -64,7 +64,9 @@ Others - Added 4th parameter ``$includeDir`` to ``get_filenames()``. See :php:func:`get_filenames`. - HTML helper ``script_tag()`` now uses ``null`` values to write boolean attributes in minimized form: ``