diff --git a/CHANGELOG.md b/CHANGELOG.md index e6765a0ef..d34d06330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.0.0 under development +- Enh #806: Non-unique placeholder names inside `Expression::$params` will be replaced with unique names (@Tigrov) +- Enh #806: Build `Expression` instances inside `Expression::$params` when build a query using `QueryBuilder` (@Tigrov) - Enh #766: Allow `ColumnInterface` as column type. (@Tigrov) ## 1.3.0 March 21, 2024 diff --git a/UPGRADE.md b/UPGRADE.md index c7c4accb1..dde268f37 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -5,6 +5,8 @@ there is version B between A and C, you need to following the instructions for b ## Upgrade from 1.x to 2.x +### `ColumnInterface` as column type + Add `ColumnInterface` support and change type of parameter `$type` from `string` to `ColumnInterface|string` in `addColumn()` method of your classes that implement the following interfaces: @@ -16,3 +18,12 @@ in `addColumn()` method of your classes that implement the following interfaces: - `Yiisoft\Db\Command\AbstractCommand`; - `Yiisoft\Db\QueryBuilder\AbstractDDLQueryBuilder`; - `Yiisoft\Db\QueryBuilder\AbstractQueryBuilder`. + +### Build `Expression` instances inside `Expression::$params` + +`ExpressionBuilder` is replaced by an abstract class `AbstractExpressionBuilder` with an instance of the +`QueryBuilderInterface` parameter in the constructor. Each DBMS driver should implement its own expression builder. + +`Expression::$params` can contain: +- non-unique placeholder names, they will be replaced with unique names. +- `Expression` instances, they will be built when building a query using `QueryBuilder`. diff --git a/src/Expression/AbstractExpressionBuilder.php b/src/Expression/AbstractExpressionBuilder.php new file mode 100644 index 000000000..b75775492 --- /dev/null +++ b/src/Expression/AbstractExpressionBuilder.php @@ -0,0 +1,242 @@ +__toString(); + $expressionParams = $expression->getParams(); + + if (empty($expressionParams)) { + return $sql; + } + + if (isset($expressionParams[0])) { + $params = array_merge($params, $expressionParams); + return $sql; + } + + $nonUniqueReplacements = $this->appendParams($expressionParams, $params); + $expressionReplacements = $this->buildParamExpressions($expressionParams, $params); + + $replacements = $this->mergeReplacements($nonUniqueReplacements, $expressionReplacements); + + if (empty($replacements)) { + return $sql; + } + + return $this->replacePlaceholders($sql, $replacements); + } + + /** + * Appends parameters to the list of query parameters replacing non-unique parameters with unique ones. + * + * @param array $expressionParams Parameters to be appended. + * @param array $params Parameters to be bound to the query. + * + * @psalm-param ParamsType $expressionParams + * @psalm-param ParamsType $params + * + * @return string[] Replacements for non-unique parameters. + */ + private function appendParams(array &$expressionParams, array &$params): array + { + $nonUniqueParams = []; + + /** @var non-empty-string $name */ + foreach ($expressionParams as $name => $value) { + $paramName = $name[0] === ':' ? substr($name, 1) : $name; + + if (!isset($params[$paramName]) && !isset($params[":$paramName"])) { + $params[$name] = $value; + continue; + } + + $nonUniqueParams[$name] = $value; + } + + $replacements = []; + + /** @var non-empty-string $name */ + foreach ($nonUniqueParams as $name => $value) { + $paramName = $name[0] === ':' ? substr($name, 1) : $name; + $uniqueName = $this->getUniqueName($paramName, $params); + + $replacements[":$paramName"] = ":$uniqueName"; + + if ($name[0] === ':') { + $uniqueName = ":$uniqueName"; + } + + $params[$uniqueName] = $value; + $expressionParams[$uniqueName] = $value; + unset($expressionParams[$name]); + } + + return $replacements; + } + + /** + * Build expression values of parameters. + * + * @param array $expressionParams Parameters from the expression. + * @param array $params Parameters to be bound to the query. + * + * @psalm-param ParamsType $expressionParams + * @psalm-param ParamsType $params + * + * @return string[] Replacements for parameters. + */ + private function buildParamExpressions(array $expressionParams, array &$params): array + { + $replacements = []; + + /** @var non-empty-string $name */ + foreach ($expressionParams as $name => $value) { + if (!$value instanceof ExpressionInterface || $value instanceof Param) { + continue; + } + + $placeholder = $name[0] !== ':' ? ":$name" : $name; + $replacements[$placeholder] = $this->queryBuilder->buildExpression($value, $params); + + /** @psalm-var ParamsType $params */ + unset($params[$name]); + } + + return $replacements; + } + + /** + * Merges replacements for non-unique parameters with replacements for expression parameters. + * + * @param string[] $replacements Replacements for non-unique parameters. + * @param string[] $expressionReplacements Replacements for expression parameters. + * + * @return string[] Merged replacements. + */ + private function mergeReplacements(array $replacements, array $expressionReplacements): array + { + if (empty($replacements)) { + return $expressionReplacements; + } + + if (empty($expressionReplacements)) { + return $replacements; + } + + /** @var non-empty-string $value */ + foreach ($replacements as $name => $value) { + if (isset($expressionReplacements[$value])) { + $replacements[$name] = $expressionReplacements[$value]; + unset($expressionReplacements[$value]); + } + } + + return $replacements + $expressionReplacements; + } + + /** + * 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 + { + $uniqueName = $name . '_0'; + + for ($i = 1; isset($params[$uniqueName]) || isset($params[":$uniqueName"]); ++$i) { + $uniqueName = $name . '_' . $i; + } + + return $uniqueName; + } + + /** + * Replaces placeholders with replacements in a SQL expression. + * + * @param string $sql SQL expression where the placeholder should be replaced. + * @param string[] $replacements Replacements for placeholders. + * + * @return string SQL expression with replaced placeholders. + */ + private function replacePlaceholders(string $sql, array $replacements): string + { + $parser = $this->createSqlParser($sql); + $offset = 0; + + while (null !== $placeholder = $parser->getNextPlaceholder($position)) { + if (isset($replacements[$placeholder])) { + /** @var int $position */ + $sql = substr_replace($sql, $replacements[$placeholder], $position + $offset, strlen($placeholder)); + + if (count($replacements) === 1) { + break; + } + + $offset += strlen($replacements[$placeholder]) - strlen($placeholder); + unset($replacements[$placeholder]); + } + } + + return $sql; + } + + /** + * Creates an instance of {@see AbstractSqlParser} for the given SQL expression. + * + * @param string $sql SQL expression to be parsed. + * + * @return AbstractSqlParser SQL parser instance. + */ + abstract protected function createSqlParser(string $sql): AbstractSqlParser; +} diff --git a/src/Expression/ExpressionBuilder.php b/src/Expression/ExpressionBuilder.php deleted file mode 100644 index fac480612..000000000 --- a/src/Expression/ExpressionBuilder.php +++ /dev/null @@ -1,25 +0,0 @@ -getParams()); - return $expression->__toString(); - } -} diff --git a/src/QueryBuilder/AbstractDQLQueryBuilder.php b/src/QueryBuilder/AbstractDQLQueryBuilder.php index 9f857328f..fd3951540 100644 --- a/src/QueryBuilder/AbstractDQLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDQLQueryBuilder.php @@ -11,7 +11,6 @@ use Yiisoft\Db\Exception\InvalidConfigException; use Yiisoft\Db\Exception\NotSupportedException; use Yiisoft\Db\Expression\Expression; -use Yiisoft\Db\Expression\ExpressionBuilder; use Yiisoft\Db\Expression\ExpressionBuilderInterface; use Yiisoft\Db\Expression\ExpressionInterface; use Yiisoft\Db\QueryBuilder\Condition\HashCondition; @@ -105,25 +104,7 @@ public function build(QueryInterface $query, array $params = []): array $this->buildHaving($query->getHaving(), $params), ]; $sql = implode($this->separator, array_filter($clauses)); - $sql = $this->buildOrderByAndLimit($sql, $query->getOrderBy(), $query->getLimit(), $query->getOffset()); - - if (!empty($query->getOrderBy())) { - /** @psalm-var array */ - foreach ($query->getOrderBy() as $expression) { - if ($expression instanceof ExpressionInterface) { - $this->buildExpression($expression, $params); - } - } - } - - if (!empty($query->getGroupBy())) { - /** @psalm-var array */ - foreach ($query->getGroupBy() as $expression) { - if ($expression instanceof ExpressionInterface) { - $this->buildExpression($expression, $params); - } - } - } + $sql = $this->buildOrderByAndLimit($sql, $query->getOrderBy(), $query->getLimit(), $query->getOffset(), $params); $union = $this->buildUnion($query->getUnions(), $params); @@ -165,19 +146,22 @@ public function buildColumns(array|string $columns): string public function buildCondition(array|string|ExpressionInterface|null $condition, array &$params = []): string { - if (is_array($condition)) { - if (empty($condition)) { - return ''; + if (empty($condition)) { + if ($condition === '0') { + return '0'; } - $condition = $this->createConditionFromArray($condition); + return ''; } - if ($condition instanceof ExpressionInterface) { - return $this->buildExpression($condition, $params); + if (is_array($condition)) { + $condition = $this->createConditionFromArray($condition); + } elseif (is_string($condition)) { + $condition = new Expression($condition, $params); + $params = []; } - return $condition ?? ''; + return $this->buildExpression($condition, $params); } public function buildExpression(ExpressionInterface $expression, array &$params = []): string @@ -208,10 +192,7 @@ public function buildGroupBy(array $columns, array &$params = []): string /** @psalm-var array $columns */ foreach ($columns as $i => $column) { if ($column instanceof ExpressionInterface) { - $columns[$i] = $this->buildExpression($column); - if ($column instanceof Expression || $column instanceof QueryInterface) { - $params = array_merge($params, $column->getParams()); - } + $columns[$i] = $this->buildExpression($column, $params); } elseif (!str_contains($column, '(')) { $columns[$i] = $this->quoter->quoteColumnName($column); } @@ -299,10 +280,7 @@ public function buildOrderBy(array $columns, array &$params = []): string /** @psalm-var array $columns */ foreach ($columns as $name => $direction) { if ($direction instanceof ExpressionInterface) { - $orders[] = $this->buildExpression($direction); - if ($direction instanceof Expression || $direction instanceof QueryInterface) { - $params = array_merge($params, $direction->getParams()); - } + $orders[] = $this->buildExpression($direction, $params); } else { $orders[] = $this->quoter->quoteColumnName($name) . ($direction === SORT_DESC ? ' DESC' : ''); } @@ -524,7 +502,6 @@ protected function defaultExpressionBuilders(): array return [ Query::class => QueryExpressionBuilder::class, Param::class => ParamBuilder::class, - Expression::class => ExpressionBuilder::class, Condition\AbstractConjunctionCondition::class => Condition\Builder\ConjunctionConditionBuilder::class, Condition\NotCondition::class => Condition\Builder\NotConditionBuilder::class, Condition\AndCondition::class => Condition\Builder\ConjunctionConditionBuilder::class, diff --git a/src/Syntax/AbstractSqlParser.php b/src/Syntax/AbstractSqlParser.php new file mode 100644 index 000000000..ae6d6b250 --- /dev/null +++ b/src/Syntax/AbstractSqlParser.php @@ -0,0 +1,153 @@ +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. + */ + abstract public function getNextPlaceholder(int|null &$position = null): string|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] ?? null) === $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/AbstractQueryBuilderTest.php b/tests/AbstractQueryBuilderTest.php index abcd543da..4ab4f287f 100644 --- a/tests/AbstractQueryBuilderTest.php +++ b/tests/AbstractQueryBuilderTest.php @@ -2196,16 +2196,18 @@ public function testUpdate( string $table, array $columns, array|string $condition, - string $expectedSQL, + array $params, + string $expectedSql, array $expectedParams ): void { $db = $this->getConnection(); - $qb = $db->getQueryBuilder(); - $actualParams = []; - $this->assertSame($expectedSQL, $qb->update($table, $columns, $condition, $actualParams)); - $this->assertSame($expectedParams, $actualParams); + $sql = $qb->update($table, $columns, $condition, $params); + $sql = $qb->quoter()->quoteSql($sql); + + $this->assertSame($expectedSql, $sql); + $this->assertEquals($expectedParams, $params); } /** @@ -2276,7 +2278,7 @@ public function testOverrideParameters1(): void { $db = $this->getConnection(); - $params = [':id' => 1, ':pv2' => new Expression('(select type from {{%animal}}) where id=1')]; + $params = [':id' => 1, ':pv2' => 'test']; $expression = new Expression('id = :id AND type = :pv2', $params); $query = new Query($db); @@ -2291,7 +2293,7 @@ public function testOverrideParameters1(): void $this->assertEquals([':id', ':pv2', ':pv2_0',], array_keys($command->getParams())); $this->assertEquals( DbHelper::replaceQuotes( - 'SELECT * FROM [[animal]] WHERE (id = 1 AND type = (select type from {{%animal}}) where id=1) AND ([[type]]=\'test1\')', + 'SELECT * FROM [[animal]] WHERE (id = 1 AND type = \'test\') AND ([[type]]=\'test1\')', $db->getDriverName() ), $command->getRawSql() diff --git a/tests/AbstractSqlParserTest.php b/tests/AbstractSqlParserTest.php new file mode 100644 index 000000000..af466d309 --- /dev/null +++ b/tests/AbstractSqlParserTest.php @@ -0,0 +1,42 @@ +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 = $this->createSqlParser($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/Common/CommonCommandTest.php b/tests/Common/CommonCommandTest.php index 305e74a64..924a94800 100644 --- a/tests/Common/CommonCommandTest.php +++ b/tests/Common/CommonCommandTest.php @@ -1896,14 +1896,25 @@ public function testUpdate( array $columns, array|string $conditions, array $params, - string $expected + array $expectedValues, + int $expectedCount, ): void { - $db = $this->getConnection(); + $db = $this->getConnection(true); $command = $db->createCommand(); - $sql = $command->update($table, $columns, $conditions, $params)->getSql(); + $count = $command->update($table, $columns, $conditions, $params)->execute(); + + $this->assertSame($expectedCount, $count); - $this->assertSame($expected, $sql); + $values = (new Query($db)) + ->from($table) + ->where($conditions, $params) + ->limit(1) + ->one(); + + foreach ($expectedValues as $name => $expectedValue) { + $this->assertEquals($expectedValue, $values[$name]); + } $db->close(); } diff --git a/tests/Db/QueryBuilder/QueryBuilderTest.php b/tests/Db/QueryBuilder/QueryBuilderTest.php index 7743142ab..bf646ad00 100644 --- a/tests/Db/QueryBuilder/QueryBuilderTest.php +++ b/tests/Db/QueryBuilder/QueryBuilderTest.php @@ -254,17 +254,20 @@ public function testUpdate( string $table, array $columns, array|string $condition, - string $expectedSQL, + array $params, + string $expectedSql, array $expectedParams ): void { $db = $this->getConnection(); $schemaMock = $this->createMock(Schema::class); $qb = new QueryBuilder($db->getQuoter(), $schemaMock); - $actualParams = []; - $this->assertSame($expectedSQL, $qb->update($table, $columns, $condition, $actualParams)); - $this->assertSame($expectedParams, $actualParams); + $sql = $qb->update($table, $columns, $condition, $params); + $sql = $qb->quoter()->quoteSql($sql); + + $this->assertSame($expectedSql, $sql); + $this->assertEquals($expectedParams, $params); } /** diff --git a/tests/Db/Syntax/SqlParserTest.php b/tests/Db/Syntax/SqlParserTest.php new file mode 100644 index 000000000..c44abdecf --- /dev/null +++ b/tests/Db/Syntax/SqlParserTest.php @@ -0,0 +1,16 @@ + '{{test}}'], [], [], - DbHelper::replaceQuotes( - << '{{test}}'], + 3, ], [ - '{{table}}', + '{{customer}}', ['name' => '{{test}}'], ['id' => 1], [], - DbHelper::replaceQuotes( - << '{{test}}'], - ['id' => 1], - ['id' => 'integer'], - DbHelper::replaceQuotes( - << '{{test}}'], + '{{customer}}', + ['{{customer}}.name' => '{{test}}'], ['id' => 1], - ['id' => 'string'], - DbHelper::replaceQuotes( - << '{{test}}'], - ['id' => 1], - ['id' => 'boolean'], - DbHelper::replaceQuotes( - << '{{test}}'], - ['id' => 1], - ['id' => 'boolean'], - DbHelper::replaceQuotes( - << '{{test}}'], - ['id' => 1], - ['id' => 'float'], - DbHelper::replaceQuotes( - << new Expression('1 + 2')], + ['id' => 2], + [], + ['status' => 3], + 1, + ], + [ + '{{customer}}', + ['status' => new Expression( + '1 + :val', + ['val' => new Expression('2 + :val', ['val' => 3])] + )], + '[[name]] != :val', + ['val' => new Expression('LOWER(:val)', ['val' => 'USER1'])], + ['name' => 'user2', 'status' => 6], + 2, ], ]; } diff --git a/tests/Provider/QueryBuilderProvider.php b/tests/Provider/QueryBuilderProvider.php index 79e7694c2..77122b9c9 100644 --- a/tests/Provider/QueryBuilderProvider.php +++ b/tests/Provider/QueryBuilderProvider.php @@ -4,6 +4,8 @@ namespace Yiisoft\Db\Tests\Provider; +use Yiisoft\Db\Command\DataType; +use Yiisoft\Db\Command\Param; use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Query\Query; use Yiisoft\Db\QueryBuilder\Condition\BetweenColumnsCondition; @@ -267,6 +269,7 @@ public static function buildCondition(): array /* not */ [['not', ''], '', []], + [['not', '0'], 'NOT (0)', []], [['not', 'name'], 'NOT (name)', []], [[ 'not', @@ -1112,10 +1115,59 @@ public static function selectExist(): array public static function update(): array { return [ + [ + '{{table}}', + ['name' => '{{test}}'], + [], + [], + DbHelper::replaceQuotes( + << '{{test}}', + ], + ], + [ + '{{table}}', + ['name' => '{{test}}'], + ['id' => 1], + [], + DbHelper::replaceQuotes( + << '{{test}}', + ':qp1' => 1, + ], + ], + [ + '{{table}}', + ['{{table}}.name' => '{{test}}'], + ['id' => 1], + ['id' => 'boolean'], + DbHelper::replaceQuotes( + << 'boolean', + ':qp1' => '{{test}}', + ':qp2' => 1, + ], + ], [ 'customer', ['status' => 1, 'updated_at' => new Expression('now()')], ['id' => 100], + [], DbHelper::replaceQuotes( << 1, ':qp1' => 100], ], + 'Expressions without params' => [ + '{{product}}', + ['name' => new Expression('UPPER([[name]])')], + '[[name]] = :name', + ['name' => new Expression('LOWER([[name]])')], + DbHelper::replaceQuotes( + << [ + '{{product}}', + ['price' => new Expression('[[price]] + :val', [':val' => 1])], + '[[start_at]] < :date', + ['date' => new Expression('NOW()')], + DbHelper::replaceQuotes( + << 1], + ], + 'Expression without params and with params' => [ + '{{product}}', + ['name' => new Expression('UPPER([[name]])')], + '[[name]] = :name', + ['name' => new Expression('LOWER(:val)', [':val' => 'Apple'])], + DbHelper::replaceQuotes( + << 'Apple'], + ], + 'Expressions with the same params' => [ + '{{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 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])], + '[[name]] IN :values', + ['values' => new Expression('(:val, :val2)', ['val' => 'Banana', 'val2' => 'Cherry'])], + DbHelper::replaceQuotes( + << 1.2, + 'val1' => 2, + 'val_0' => 'Banana', + 'val2' => 'Cherry', + ], + ], + 'Expressions with the different params' => [ + '{{product}}', + ['name' => new Expression('LOWER(:val)', ['val' => 'Apple'])], + '[[name]] != :name', + ['name' => new Expression('UPPER(:val1)', ['val1' => 'Banana'])], + DbHelper::replaceQuotes( + << 'Apple', + 'val1' => 'Banana', + ], + ], + 'Expressions with nested Expressions' => [ + '{{table}}', + ['name' => new Expression( + ':val || :val_0', + [ + 'val' => new Expression('LOWER(:val || :val_0)', ['val' => 'A', 'val_0' => 'B']), + 'val_0' => new Param('C', DataType::STRING), + ], + )], + '[[name]] != :val || :val_0', + [ + 'val_0' => new Param('F', DataType::STRING), + 'val' => new Expression('UPPER(:val || :val_0)', ['val' => 'D', 'val_0' => 'E']), + ], + DbHelper::replaceQuotes( + << 'A', + 'val_0_1' => 'B', + 'val_0_0' => new Param('C', DataType::STRING), + 'val_1' => 'D', + 'val_0_2' => 'E', + 'val_0' => new Param('F', DataType::STRING), + ], + ], + 'Expressions with indexed params' => [ + '{{product}}', + ['name' => new Expression('LOWER(?)', ['Apple'])], + '[[name]] != ?', + ['Banana'], + DbHelper::replaceQuotes( + << [ + '{{product}}', + ['price' => 10], + ':val', + [':val' => new Expression("label=':val' AND name=:val", [':val' => 'Apple'])], + DbHelper::replaceQuotes( + << 10, + ':val_0' => 'Apple', + ], + ], + 'Expressions without placeholders in SQL statement' => [ + '{{product}}', + ['price' => 10], + ':val', + [':val' => new Expression("label=':val'", [':val' => 'Apple'])], + DbHelper::replaceQuotes( + << 10, + ':val_0' => 'Apple', + ], + ], ]; } diff --git a/tests/Provider/SqlParserProvider.php b/tests/Provider/SqlParserProvider.php new file mode 100644 index 000000000..7a8e21eae --- /dev/null +++ b/tests/Provider/SqlParserProvider.php @@ -0,0 +1,134 @@ + ExpressionBuilder::class, + ]); + } } diff --git a/tests/Support/Stub/ExpressionBuilder.php b/tests/Support/Stub/ExpressionBuilder.php new file mode 100644 index 000000000..add541b6b --- /dev/null +++ b/tests/Support/Stub/ExpressionBuilder.php @@ -0,0 +1,15 @@ +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; + } +}