From 5daa211f5bf5d555be846396ca4ece2a2f3b0366 Mon Sep 17 00:00:00 2001 From: Tigrov Date: Sat, 30 Mar 2024 08:57:26 +0700 Subject: [PATCH] Add SqlParser to fix case when string value containing a placeholder name. Fix unique param name w/ and w/o colon --- src/Expression/ExpressionBuilder.php | 152 +++++++++++++++----- src/Syntax/SqlParser.php | 183 ++++++++++++++++++++++++ tests/AbstractSqlParserTest.php | 45 ++++++ tests/Db/Syntax/SqlParserTest.php | 14 ++ tests/Provider/QueryBuilderProvider.php | 20 ++- tests/Provider/SqlParserProvider.php | 110 ++++++++++++++ 6 files changed, 484 insertions(+), 40 deletions(-) create mode 100644 src/Syntax/SqlParser.php create mode 100644 tests/AbstractSqlParserTest.php create mode 100644 tests/Db/Syntax/SqlParserTest.php create mode 100644 tests/Provider/SqlParserProvider.php diff --git a/src/Expression/ExpressionBuilder.php b/src/Expression/ExpressionBuilder.php index 82a5d8809..af4998357 100644 --- a/src/Expression/ExpressionBuilder.php +++ b/src/Expression/ExpressionBuilder.php @@ -4,12 +4,14 @@ namespace Yiisoft\Db\Expression; +use Yiisoft\Db\Connection\ConnectionInterface; use Yiisoft\Db\QueryBuilder\QueryBuilderInterface; +use Yiisoft\Db\Syntax\SqlParser; -use function array_intersect_key; use function array_merge; -use function preg_quote; -use function preg_replace; +use function strlen; +use function substr; +use function substr_replace; /** * It's used to build expressions for use in database queries. @@ -19,6 +21,8 @@ * * These expressions can be used with the query builder to build complex and customizable database queries * {@see Expression} class. + * + * @psalm-import-type ParamsType from ConnectionInterface */ class ExpressionBuilder implements ExpressionBuilderInterface { @@ -26,6 +30,16 @@ public function __construct(private QueryBuilderInterface|null $queryBuilder = n { } + /** + * Builds SQL statement from the given expression. + * + * @param Expression $expression The expression to be built. + * @param array $params The parameters to be bound to the query. + * + * @psalm-param ParamsType $params + * + * @return string SQL statement. + */ public function build(Expression $expression, array &$params = []): string { $sql = $expression->__toString(); @@ -45,78 +59,140 @@ public function build(Expression $expression, array &$params = []): string return $this->replaceParamExpressions($sql, $expressionParams, $params); } + /** + * Appends parameters to the list of query parameters replacing non-unique parameters with unique ones. + * + * @param string $sql SQL statement of the expression. + * @param array $expressionParams Parameters to be appended. + * @param array $params Parameters to be bound to the query. + * + * @psalm-param ParamsType $params + * + * @return string SQL statement with unique parameters. + */ private function appendParams(string $sql, array &$expressionParams, array &$params): string { - $nonUniqueParams = array_intersect_key($expressionParams, $params); - $params += $expressionParams; + $nonUniqueParams = []; - if (empty($nonUniqueParams)) { - return $sql; - } + /** @var non-empty-string $name */ + foreach ($expressionParams as $name => $value) { + $paramName = $name[0] === ':' ? substr($name, 1) : $name; - $patterns = []; - $replacements = []; + if (!isset($params[$paramName]) && !isset($params[":$paramName"])) { + $params[$name] = $value; + continue; + } + + $nonUniqueParams[$name] = $value; + } - /** @var string $name */ + /** @var non-empty-string $name */ foreach ($nonUniqueParams as $name => $value) { - $patterns[] = $this->getPattern($name); - $uniqueName = $this->getUniqueName($name, $params); - $replacements[] = '$1' . ($uniqueName[0] !== ':' ? ":$uniqueName" : $uniqueName); + $paramName = $name[0] === ':' ? substr($name, 1) : $name; + $uniqueName = $this->getUniqueName($paramName, $params); + + $sql = $this->replacePlaceholder($sql, ":$paramName", ":$uniqueName"); + + if ($name[0] === ':') { + $uniqueName = ":$uniqueName"; + } $params[$uniqueName] = $value; $expressionParams[$uniqueName] = $value; unset($expressionParams[$name]); } - return preg_replace($patterns, $replacements, $sql, 1); + return $sql; } + /** + * Replaces parameters with expression values in SQL statement. + * + * @param string $sql SQL statement where parameters should be replaced. + * @param array $expressionParams Parameters to be replaced. + * @param array $params Parameters to be bound to the query. + * + * @psalm-param ParamsType $expressionParams + * @psalm-param ParamsType $params + * + * @return string SQL statement with replaced parameters. + */ private function replaceParamExpressions(string $sql, array $expressionParams, array &$params): string { - $patterns = []; - $replacements = []; - - /** @var string $name */ + /** @var non-empty-string $name */ foreach ($expressionParams as $name => $value) { if (!$value instanceof ExpressionInterface) { continue; } - $patterns[] = $this->getPattern($name); + $placeholder = $name[0] !== ':' ? ":$name" : $name; /** @psalm-suppress PossiblyNullReference */ - $replacements[] = $this->queryBuilder->buildExpression($value, $params); + $replacement = $this->queryBuilder->buildExpression($value, $params); - unset($params[$name]); - } + $sql = $this->replacePlaceholder($sql, $placeholder, $replacement); - if (empty($patterns)) { - return $sql; + /** @psalm-var ParamsType $params */ + unset($params[$name]); } - return preg_replace($patterns, $replacements, $sql, 1); + return $sql; } - /** @psalm-return non-empty-string */ - private function getPattern(string $name): string + /** + * Returns a unique name for the parameter without colon at the beginning. + * + * @param string $name Name of the parameter without colon at the beginning. + * @param array $params Parameters to be bound to the query. + * + * @psalm-param ParamsType $params + * + * @return string Unique name of the parameter with colon at the beginning. + * + * @psalm-return non-empty-string + */ + private function getUniqueName(string $name, array $params): string { - if ($name[0] !== ':') { - $name = ":$name"; - } + $uniqueName = $name . '_0'; - $skipQuotedStrings = '((?:([\'"`])(?:.*?(?:\\\\)*(?:\\\2)?)*\2.*?)*)'; + for ($i = 1; isset($params[$uniqueName]) || isset($params[":$uniqueName"]); ++$i) { + $uniqueName = $name . '_' . $i; + } - return '/' . $skipQuotedStrings . preg_quote($name, '/') . '\b/'; + return $uniqueName; } - private function getUniqueName(string $name, array $params): string + /** + * Replaces the placeholder with the replacement in SQL statement. + * + * @param string $sql SQL statement where the placeholder should be replaced. + * @param string $placeholder Placeholder to be replaced. + * @param string $replacement Replacement for the placeholder. + * + * @return string SQL with the replaced placeholder. + */ + private function replacePlaceholder(string $sql, string $placeholder, string $replacement): string { - $uniqueName = $name . '_0'; + $parser = $this->createSqlParser($sql); - for ($i = 1; isset($params[$uniqueName]); ++$i) { - $uniqueName = $name . '_' . $i; + while (null !== $parsedPlaceholder = $parser->getNextPlaceholder($position)) { + if ($parsedPlaceholder === $placeholder) { + return substr_replace($sql, $replacement, $position, strlen($placeholder)); + } } - return $uniqueName; + return $sql; + } + + /** + * Creates an instance of {@see SqlParser} for the given SQL statement. + * + * @param string $sql SQL statement to be parsed. + * + * @return SqlParser SQL parser instance. + */ + protected function createSqlParser(string $sql): SqlParser + { + return new SqlParser($sql); } } diff --git a/src/Syntax/SqlParser.php b/src/Syntax/SqlParser.php new file mode 100644 index 000000000..c28e8fdb0 --- /dev/null +++ b/src/Syntax/SqlParser.php @@ -0,0 +1,183 @@ +length = strlen($sql); + } + + /** + * Returns the next placeholder from the current position in SQL statement. + * + * @param int|null $position Position of the placeholder in SQL statement. + * + * @return string|null The next placeholder or null if it is not found. + */ + public function getNextPlaceholder(int|null &$position = null): string|null + { + $result = null; + $length = $this->length - 1; + + while ($this->position < $length) { + $pos = $this->position++; + + match ($this->sql[$pos]) { + ':' => ($word = $this->parseWord()) === '' + ? $this->skipChars(':') + : $result = ':' . $word, + '"', "'" => $this->skipQuotedWithoutEscape($this->sql[$pos]), + '-' => $this->sql[$this->position] === '-' + ? ++$this->position && $this->skipToAfterChar("\n") + : null, + '/' => $this->sql[$this->position] === '*' + ? ++$this->position && $this->skipToAfterString('*/') + : null, + default => null, + }; + + if ($result !== null) { + $position = $pos; + + return $result; + } + } + + return null; + } + + /** + * Parses and returns word symbols. Equals to `\w+` in regular expressions. + * + * @return string Parsed word symbols. + */ + final protected function parseWord(): string + { + $word = ''; + $continue = true; + + while ($continue && $this->position < $this->length) { + match ($this->sql[$this->position]) { + '_', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', + 'v', 'w', 'x', 'y', 'z', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', + 'V', 'W', 'X', 'Y', 'Z' => $word .= $this->sql[$this->position++], + default => $continue = false, + }; + } + + return $word; + } + + /** + * Parses and returns identifier. Equals to `[_a-zA-Z]\w+` in regular expressions. + * + * @return string Parsed identifier. + */ + protected function parseIdentifier(): string + { + return match ($this->sql[$this->position]) { + '_', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', + 'v', 'w', 'x', 'y', 'z', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', + 'V', 'W', 'X', 'Y', 'Z' => $this->sql[$this->position++] . $this->parseWord(), + default => '', + }; + } + + /** + * Skips quoted string without escape characters. + */ + final protected function skipQuotedWithoutEscape(string $endChar): void + { + do { + $this->skipToAfterChar($endChar); + } while ($this->sql[$this->position] === $endChar && ++$this->position); + } + + /** + * Skips quoted string with escape characters. + */ + final protected function skipQuotedWithEscape(string $endChar): void + { + for (; $this->position < $this->length; ++$this->position) { + if ($this->sql[$this->position] === $endChar) { + ++$this->position; + return; + } + + if ($this->sql[$this->position] === '\\') { + ++$this->position; + } + } + } + + /** + * Skips all specified characters. + */ + final protected function skipChars(string $char): void + { + while ($this->position < $this->length && $this->sql[$this->position] === $char) { + ++$this->position; + } + } + + /** + * Skips to the character after the specified character. + */ + final protected function skipToAfterChar(string $char): void + { + for (; $this->position < $this->length; ++$this->position) { + if ($this->sql[$this->position] === $char) { + ++$this->position; + return; + } + } + } + + /** + * Skips to the character after the specified string. + */ + final protected function skipToAfterString(string $string): void + { + $firstChar = $string[0]; + $subString = substr($string, 1); + $length = strlen($subString); + + do { + $this->skipToAfterChar($firstChar); + + if (substr($this->sql, $this->position, $length) === $subString) { + $this->position += $length; + return; + } + } while ($this->position + $length < $this->length); + } +} diff --git a/tests/AbstractSqlParserTest.php b/tests/AbstractSqlParserTest.php new file mode 100644 index 000000000..f1c77101d --- /dev/null +++ b/tests/AbstractSqlParserTest.php @@ -0,0 +1,45 @@ +createSqlParser($sql); + + $this->assertSame($expectedPlaceholder, $parser->getNextPlaceholder($position)); + $this->assertSame($expectedPosition, $position); + } + + /** @dataProvider \Yiisoft\Db\Tests\Provider\SqlParserProvider::getAllPlaceholders */ + public function testGetAllPlaceholders(string $sql, array $expectedPlaceholders, array $expectedPositions): void + { + $parser = new SqlParser($sql); + + $placeholders = []; + $positions = []; + + while (null !== $placeholder = $parser->getNextPlaceholder($position)) { + $placeholders[] = $placeholder; + $positions[] = $position; + } + + $this->assertSame($expectedPlaceholders, $placeholders); + $this->assertSame($expectedPositions, $positions); + } +} diff --git a/tests/Db/Syntax/SqlParserTest.php b/tests/Db/Syntax/SqlParserTest.php new file mode 100644 index 000000000..d6b7284bf --- /dev/null +++ b/tests/Db/Syntax/SqlParserTest.php @@ -0,0 +1,14 @@ + 'Banana', ], ], + 'Expressions with the same params starting with and without colon' => [ + '{{product}}', + ['name' => new Expression('LOWER(:val)', [':val' => 'Apple'])], + '[[name]] != :name', + ['name' => new Expression('UPPER(:val)', ['val' => 'Banana'])], + DbHelper::replaceQuotes( + << 'Apple', + 'val_0' => 'Banana', + ], + ], 'Expressions with the same and different params' => [ '{{product}}', ['price' => new Expression('[[price]] * :val + :val1', ['val' => 1.2, 'val1' => 2])], @@ -1309,10 +1325,10 @@ public static function update(): array '{{product}}', ['price' => 10], ':val', - [':val' => new Expression("label=':val\\\'a\\\'' label1=':val\\\'a\\\'' AND name=:val", [':val' => 'Apple'])], + [':val' => new Expression("label=':val' AND name=:val", [':val' => 'Apple'])], DbHelper::replaceQuotes( <<