From 2822a19eb4cf371a6b9a079126d9e94688be4e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Mon, 15 Apr 2024 18:20:23 +0200 Subject: [PATCH] Make LIKE operator escapable --- src/Persistence/Sql/Oracle/Query.php | 12 ++++++++++++ src/Persistence/Sql/Query.php | 10 ++++++++++ tests/ConditionSqlTest.php | 19 ++++++++++++++++--- tests/Persistence/Sql/QueryTest.php | 17 ++++++++++++++--- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/Persistence/Sql/Oracle/Query.php b/src/Persistence/Sql/Oracle/Query.php index 7f2e696cf..6881e3002 100644 --- a/src/Persistence/Sql/Oracle/Query.php +++ b/src/Persistence/Sql/Oracle/Query.php @@ -17,6 +17,18 @@ class Query extends BaseQuery protected string $identifierEscapeChar = '"'; protected string $expressionClass = Expression::class; + #[\Override] + protected function _renderConditionLikeOperator(bool $negated, string $sqlLeft, string $sqlRight): string + { + $sqlRight = 'regexp_replace(regexp_replace(' . $sqlRight + . ', ' . $this->escapeStringLiteral('((\\\\\\\)*)(\\\([^_%]))?') + . ', ' . $this->escapeStringLiteral('\1\4') + . '), ' . $this->escapeStringLiteral('((^|[^\\\])(\\\\\\\)*\\\)$') + . ', ' . $this->escapeStringLiteral('\1\\\\') . ')'; + + return parent::_renderConditionLikeOperator($negated, $sqlLeft, $sqlRight); + } + #[\Override] public function render(): array { diff --git a/src/Persistence/Sql/Query.php b/src/Persistence/Sql/Query.php index 52022d52e..6a920eb2b 100644 --- a/src/Persistence/Sql/Query.php +++ b/src/Persistence/Sql/Query.php @@ -518,6 +518,12 @@ protected function _renderConditionInOperator(bool $negated, string $sqlLeft, ar return $sqlLeft . ($negated ? ' not' : '') . ' in (' . implode(', ', $sqlValues) . ')'; } + protected function _renderConditionLikeOperator(bool $negated, string $sqlLeft, string $sqlRight): string + { + return $sqlLeft . ($negated ? ' not' : '') . ' like ' . $sqlRight + . ' escape ' . $this->escapeStringLiteral('\\'); + } + /** * @param array{mixed}|array{mixed, string|null, mixed} $row */ @@ -605,6 +611,10 @@ protected function _subrenderCondition(array $row): string // otherwise just escape value $value = $this->consume($value, self::ESCAPE_PARAM); + if (in_array($operator, ['like', 'not like'], true)) { + return $this->_renderConditionLikeOperator($operator === 'not like', $field, $value); + } + return $this->_renderConditionBinary($operator, $field, $value); } diff --git a/tests/ConditionSqlTest.php b/tests/ConditionSqlTest.php index 923087b7e..ba3edb90b 100644 --- a/tests/ConditionSqlTest.php +++ b/tests/ConditionSqlTest.php @@ -510,7 +510,7 @@ public function testLikeCondition(): void ['id' => 2, 'name' => 'Peter', 'c' => 2000], ['id' => 3, 'name' => 'Joe', 'c' => 50], ['id' => 4, 'name' => 'Ca%ro_li\ne', 'c' => null], - ['id' => 5, 'name' => 'Ca.ro.li.ne', 'c' => null], + ['id' => 5, 'name' => 'Ca.ro.li\\\ne', 'c' => null], ], ]); @@ -541,8 +541,21 @@ public function testLikeCondition(): void self::assertSame([1], $findIdsLikeFx('c', '%0%', true)); self::assertSame([4, 5], $findIdsLikeFx('name', '%Ca%ro%')); + self::assertSame([4], $findIdsLikeFx('name', '%Ca\%ro%')); + self::assertSame([4, 5], $findIdsLikeFx('name', '%ro_li%')); - // self::assertSame([4, 5], $findIdsLikeFx('name', '%li_\ne%')); - // self::assertSame([4], $findIdsLikeFx('name', '%li\\ne%')); + self::assertSame([4], $findIdsLikeFx('name', '%ro\_li%')); + + self::assertSame([], $findIdsLikeFx('name', '%li\ne%')); + self::assertSame([4, 5], $findIdsLikeFx('name', '%li%\ne%')); + self::assertSame([4], $findIdsLikeFx('name', '%li\\\ne%')); + self::assertSame([4], $findIdsLikeFx('name', '%li\\\\\ne%')); + self::assertSame([5], $findIdsLikeFx('name', '%li\\\\\\\ne%')); + self::assertSame([5], $findIdsLikeFx('name', '%li\\\\\\\\\ne%')); + self::assertSame([], $findIdsLikeFx('name', '%li\\\\\\\\\\\ne%')); + self::assertSame([5], $findIdsLikeFx('name', '%.li%ne')); + self::assertSame([], $findIdsLikeFx('name', '%.li%ne\\')); + self::assertSame([], $findIdsLikeFx('name', '%.li%ne\\\\')); + self::assertSame([], $findIdsLikeFx('name', '%*li%ne')); } } diff --git a/tests/Persistence/Sql/QueryTest.php b/tests/Persistence/Sql/QueryTest.php index 3b3abe110..b0e25cc5f 100644 --- a/tests/Persistence/Sql/QueryTest.php +++ b/tests/Persistence/Sql/QueryTest.php @@ -11,6 +11,7 @@ use Atk4\Data\Persistence\Sql\Mysql\Connection as MysqlConnection; use Atk4\Data\Persistence\Sql\Mysql\Expression as MysqlExpression; use Atk4\Data\Persistence\Sql\Mysql\Query as MysqlQuery; +use Atk4\Data\Persistence\Sql\Oracle\Query as OracleQuery; use Atk4\Data\Persistence\Sql\Query; use Atk4\Data\Persistence\Sql\Sqlite\Connection as SqliteConnection; use PHPUnit\Framework\Attributes\DataProvider; @@ -49,6 +50,10 @@ protected function escapeStringLiteral(string $value): string #[\Override] protected function escapeStringLiteral(string $value): string { + if ($value === '\\') { + return '\'\\\''; + } + return null; // @phpstan-ignore-line } }; @@ -772,13 +777,19 @@ public function testWhereSpecialValues(): void // like | not like self::assertSame( - 'where "name" like :a', + 'where "name" like :a escape \'\\\'', $this->q('[where]')->where('name', 'like', 'foo')->render()[0] ); self::assertSame( - 'where "name" not like :a', + 'where "name" not like :a escape \'\\\'', $this->q('[where]')->where('name', 'not like', 'foo')->render()[0] ); + self::assertSame( + <<<'EOF' + where "name" like regexp_replace(regexp_replace(:xxaaaa, '((\\\\)*)(\\([^_%]))?', '\1\4'), '((^|[^\\])(\\\\)*\\)$', concat('\1', rpad(chr(92), 2, chr(92)))) escape chr(92) + EOF, + (new OracleQuery('[where]'))->where('name', 'like', 'foo')->render()[0] + ); } public function testWhereInWithNullException(): void @@ -1328,7 +1339,7 @@ public function testCaseExprNormal(): void ->caseWhen(['status', 'like', '%Used%'], 't2.expose_used') ->caseElse(null) ->render()[0]; - self::assertSame('case when "status" = :a then :b when "status" like :c then :d else :e end', $s); + self::assertSame('case when "status" = :a then :b when "status" like :c escape \'\\\' then :d else :e end', $s); // with subqueries $age = $this->e('year(now()) - year(birth_date)');