diff --git a/CHANGELOG.md b/CHANGELOG.md index adb022371..c65b49b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Enh #815: Refactor `Query::column()` method (@Tigrov) - Enh #816: Allow scalar values for `$columns` parameter of `Query::select()` and `Query::addSelect()` methods (@Tigrov) - 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 #806, #964: Build `Expression` instances inside `Expression::$params` when build a query using `QueryBuilder` (@Tigrov) - Enh #766: Allow `ColumnInterface` as column type. (@Tigrov) - Bug #828: Fix `float` type when use `AbstractCommand::getRawSql()` method (@Tigrov) - New #752: Implement `ColumnSchemaInterface` classes according to the data type of database table columns @@ -84,6 +84,7 @@ - Enh #961: Added `setHaving()` as a forced method to overwrite `having()` (@lav45) - Enh #822: Refactor data readers (@Tigrov) - Enh #963: Make `Query::andHaving()` similar to `Query::andWhere()` (@Tigrov) +- New #964: Add `QueryBuilderInterface::replacePlaceholders()` method (@Tigrov) ## 1.3.0 March 21, 2024 diff --git a/UPGRADE.md b/UPGRADE.md index 13bc24522..3ecffb614 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -128,6 +128,7 @@ Each table column has its own class in the `Yiisoft\Db\Schema\Column` namespace - `QueryBuilderInterface::buildColumnDefinition()` - builds column definition for `CREATE TABLE` statement; - `QueryBuilderInterface::prepareParam()` - converts a `ParamInterface` object to its SQL representation; - `QueryBuilderInterface::prepareValue()` - converts a value to its SQL representation; +- `QueryBuilderInterface::replacePlaceholders()` - replaces placeholders in the SQL string with the corresponding values; - `QueryBuilderInterface::getColumnFactory()` - returns the column factory object for concrete DBMS; - `QueryBuilderInterface::getServerInfo()` - returns `ServerInfoInterface` instance which provides server information; - `DMLQueryBuilderInterface::withTypecasting()` - enables or disables typecasting of values when inserting or updating diff --git a/src/Command/AbstractCommand.php b/src/Command/AbstractCommand.php index 66d4c4a40..b4f57ea29 100644 --- a/src/Command/AbstractCommand.php +++ b/src/Command/AbstractCommand.php @@ -13,14 +13,13 @@ use Yiisoft\Db\QueryBuilder\QueryBuilderInterface; use Yiisoft\Db\Schema\Column\ColumnInterface; +use function array_map; use function explode; use function get_resource_type; use function is_array; use function is_int; use function is_resource; use function is_scalar; -use function is_string; -use function preg_replace_callback; use function stream_get_contents; /** @@ -348,25 +347,11 @@ public function getRawSql(): string return $this->sql; } - $params = []; $queryBuilder = $this->getQueryBuilder(); + $params = array_map($queryBuilder->prepareParam(...), $this->params); - foreach ($this->params as $name => $param) { - if (is_string($name) && $name[0] !== ':') { - $name = ':' . $name; - } - - $params[$name] = $queryBuilder->prepareParam($param); - } - - /** @var string[] $params */ if (!isset($params[0])) { - /** @var string */ - return preg_replace_callback( - '#(:\w+)#', - static fn (array $matches): string => $params[$matches[1]] ?? $matches[1], - $this->sql - ); + return $queryBuilder->replacePlaceholders($this->sql, $params); } // Support unnamed placeholders should be dropped diff --git a/src/Expression/AbstractExpressionBuilder.php b/src/Expression/ExpressionBuilder.php similarity index 78% rename from src/Expression/AbstractExpressionBuilder.php rename to src/Expression/ExpressionBuilder.php index 6cbcd9c11..9d491f9c8 100644 --- a/src/Expression/AbstractExpressionBuilder.php +++ b/src/Expression/ExpressionBuilder.php @@ -7,13 +7,9 @@ use Yiisoft\Db\Command\Param; use Yiisoft\Db\Connection\ConnectionInterface; use Yiisoft\Db\QueryBuilder\QueryBuilderInterface; -use Yiisoft\Db\Syntax\AbstractSqlParser; use function array_merge; -use function count; -use function strlen; use function substr; -use function substr_replace; /** * It's used to build expressions for use in database queries. @@ -26,9 +22,9 @@ * * @psalm-import-type ParamsType from ConnectionInterface */ -abstract class AbstractExpressionBuilder implements ExpressionBuilderInterface +final class ExpressionBuilder implements ExpressionBuilderInterface { - public function __construct(private QueryBuilderInterface $queryBuilder) + public function __construct(private readonly QueryBuilderInterface $queryBuilder) { } @@ -67,7 +63,7 @@ public function build(ExpressionInterface $expression, array &$params = []): str return $sql; } - return $this->replacePlaceholders($sql, $replacements); + return $this->queryBuilder->replacePlaceholders($sql, $replacements); } /** @@ -199,43 +195,4 @@ private function getUniqueName(string $name, array $params): string 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/QueryBuilder/AbstractDDLQueryBuilder.php b/src/QueryBuilder/AbstractDDLQueryBuilder.php index d69d3ca01..06aa69b94 100644 --- a/src/QueryBuilder/AbstractDDLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDDLQueryBuilder.php @@ -10,6 +10,7 @@ use Yiisoft\Db\Schema\QuoterInterface; use Yiisoft\Db\Schema\SchemaInterface; +use function array_map; use function implode; use function is_string; use function preg_split; @@ -193,11 +194,8 @@ public function createView(string $viewName, QueryInterface|string $subQuery): s if ($subQuery instanceof QueryInterface) { [$rawQuery, $params] = $this->queryBuilder->build($subQuery); - foreach ($params as $key => $value) { - $params[$key] = $this->queryBuilder->prepareValue($value); - } - - $subQuery = strtr($rawQuery, $params); + $params = array_map($this->queryBuilder->prepareValue(...), $params); + $subQuery = $this->queryBuilder->replacePlaceholders($rawQuery, $params); } return 'CREATE VIEW ' . $this->quoter->quoteTableName($viewName) . ' AS ' . $subQuery; diff --git a/src/QueryBuilder/AbstractDQLQueryBuilder.php b/src/QueryBuilder/AbstractDQLQueryBuilder.php index d0b2802cd..03350a9f6 100644 --- a/src/QueryBuilder/AbstractDQLQueryBuilder.php +++ b/src/QueryBuilder/AbstractDQLQueryBuilder.php @@ -13,6 +13,7 @@ use Yiisoft\Db\Expression\ArrayExpression; use Yiisoft\Db\Expression\ArrayExpressionBuilder; use Yiisoft\Db\Expression\Expression; +use Yiisoft\Db\Expression\ExpressionBuilder; use Yiisoft\Db\Expression\ExpressionBuilderInterface; use Yiisoft\Db\Expression\ExpressionInterface; use Yiisoft\Db\Expression\JsonExpression; @@ -522,6 +523,7 @@ 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/QueryBuilder/AbstractQueryBuilder.php b/src/QueryBuilder/AbstractQueryBuilder.php index 768cbfdcc..ddae43da1 100644 --- a/src/QueryBuilder/AbstractQueryBuilder.php +++ b/src/QueryBuilder/AbstractQueryBuilder.php @@ -18,6 +18,7 @@ use Yiisoft\Db\Schema\Column\ColumnFactoryInterface; use Yiisoft\Db\Schema\Column\ColumnInterface; use Yiisoft\Db\Schema\QuoterInterface; +use Yiisoft\Db\Syntax\AbstractSqlParser; use function bin2hex; use function count; @@ -25,6 +26,8 @@ use function gettype; use function is_string; use function stream_get_contents; +use function strlen; +use function substr_replace; /** * Builds a SELECT SQL statement based on the specification given as a {@see QueryInterface} object. @@ -398,7 +401,7 @@ public function prepareParam(ParamInterface $param): string public function prepareValue(mixed $value): string { - $quoter = $this->db->getQuoter(); + $params = []; /** @psalm-suppress MixedArgument */ return match (gettype($value)) { @@ -407,13 +410,16 @@ public function prepareValue(mixed $value): string GettypeResult::INTEGER => (string) $value, GettypeResult::NULL => 'NULL', GettypeResult::OBJECT => match (true) { - $value instanceof Expression => (string) $value, $value instanceof ParamInterface => $this->prepareParam($value), - default => $quoter->quoteValue((string) $value), + $value instanceof ExpressionInterface => $this->replacePlaceholders( + $this->buildExpression($value, $params), + array_map($this->prepareValue(...), $params), + ), + default => $this->db->getQuoter()->quoteValue((string) $value), }, GettypeResult::RESOURCE => $this->prepareResource($value), GettypeResult::RESOURCE_CLOSED => throw new InvalidArgumentException('Resource is closed.'), - default => $quoter->quoteValue((string) $value), + default => $this->db->getQuoter()->quoteValue((string) $value), }; } @@ -427,6 +433,42 @@ public function renameTable(string $oldName, string $newName): string return $this->ddlBuilder->renameTable($oldName, $newName); } + public function replacePlaceholders(string $sql, array $replacements): string + { + if (isset($replacements[0])) { + return $sql; + } + + /** @psalm-var array $replacements */ + foreach ($replacements as $placeholder => $replacement) { + if ($placeholder[0] !== ':') { + unset($replacements[$placeholder]); + $replacements[":$placeholder"] = $replacement; + } + } + + $offset = 0; + $parser = $this->createSqlParser($sql); + + while (null !== $placeholder = $parser->getNextPlaceholder($position)) { + if (isset($replacements[$placeholder])) { + $replacement = $replacements[$placeholder]; + + /** @var int $position */ + $sql = substr_replace($sql, $replacement, $position + $offset, strlen($placeholder)); + + if (count($replacements) === 1) { + break; + } + + $offset += strlen($replacement) - strlen($placeholder); + unset($replacements[$placeholder]); + } + } + + return $sql; + } + public function resetSequence(string $table, int|string|null $value = null): string { return $this->dmlBuilder->resetSequence($table, $value); @@ -478,6 +520,15 @@ public function withTypecasting(bool $typecasting = true): static return $new; } + /** + * 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; + /** * Converts a resource value to its SQL representation or throws an exception if conversion is not possible. * diff --git a/src/QueryBuilder/DQLQueryBuilderInterface.php b/src/QueryBuilder/DQLQueryBuilderInterface.php index ae2d998b6..a3fb7aa84 100644 --- a/src/QueryBuilder/DQLQueryBuilderInterface.php +++ b/src/QueryBuilder/DQLQueryBuilderInterface.php @@ -43,7 +43,7 @@ interface DQLQueryBuilderInterface * to the SQL statement (the second array element). The parameters returned include those provided in `$params`. * * @psalm-param ParamsType $params - * @psalm-return array{0: string, 1: array} + * @psalm-return array{0: string, 1: ParamsType} */ public function build(QueryInterface $query, array $params = []): array; diff --git a/src/QueryBuilder/QueryBuilderInterface.php b/src/QueryBuilder/QueryBuilderInterface.php index 661b52c0e..51065f648 100644 --- a/src/QueryBuilder/QueryBuilderInterface.php +++ b/src/QueryBuilder/QueryBuilderInterface.php @@ -91,4 +91,16 @@ public function prepareParam(ParamInterface $param): string; * Used when the bind parameter cannot be used in the SQL query. */ public function prepareValue(mixed $value): string; + + /** + * Replaces placeholders in the SQL string with the corresponding values. + * + * @param string $sql SQL expression where the placeholder should be replaced. + * @param string[] $replacements Replacements for placeholders with placeholder names as keys and values as follows: + * - quoted string values (name => value) use {@see prepareValue()} to prepare the values; + * - new placeholder names prefixed with colon `:` (name => new name). + * + * @return string SQL expression with replaced placeholders. + */ + public function replacePlaceholders(string $sql, array $replacements): string; } diff --git a/tests/AbstractQueryBuilderTest.php b/tests/AbstractQueryBuilderTest.php index 45c7d4b09..33ba53d65 100644 --- a/tests/AbstractQueryBuilderTest.php +++ b/tests/AbstractQueryBuilderTest.php @@ -2006,6 +2006,32 @@ public function testRenameTable(): void ); } + public function testReplacePlaceholders(): void + { + $db = $this->getConnection(); + + $qb = $db->getQueryBuilder(); + $sql = $qb->replacePlaceholders( + 'SELECT * FROM [[table]] WHERE [[id]] = :id AND [[name]] = :name AND [[is_active]] = :is_active AND [[created_at]] = :created_at', + [ + ':id' => '1', + 'name' => "'John'", + ':is_active' => ':active', + ], + ); + + $this->assertSame( + "SELECT * FROM [[table]] WHERE [[id]] = 1 AND [[name]] = 'John' AND [[is_active]] = :active AND [[created_at]] = :created_at", + $sql, + ); + + // Question mark placeholder are not replaced + $this->assertSame( + 'SELECT * FROM [[table]] WHERE [[id]] = ?', + $qb->replacePlaceholders('SELECT * FROM [[table]] WHERE [[id]] = ?', ['1']), + ); + } + /** * @throws Exception * @throws NotSupportedException diff --git a/tests/Provider/QueryBuilderProvider.php b/tests/Provider/QueryBuilderProvider.php index 72acc7012..d553c0d16 100644 --- a/tests/Provider/QueryBuilderProvider.php +++ b/tests/Provider/QueryBuilderProvider.php @@ -1752,6 +1752,9 @@ public static function prepareParam(): array 'float' => ['1.1', 1.1, DataType::STRING], 'string' => ["'string'", 'string', DataType::STRING], 'binary' => ['0x737472696e67', 'string', DataType::LOB], + 'expression' => ['(1 + 2)', new Expression('(1 + 2)'), DataType::STRING], + 'expression with params' => ['(1 + 2)', new Expression('(:a + :b)', [':a' => 1, 'b' => 2]), DataType::STRING], + 'Stringable' => ["'string'", new Stringable('string'), DataType::STRING], ]; } @@ -1769,6 +1772,7 @@ public static function prepareValue(): array 'paramString' => ["'string'", new Param('string', DataType::STRING)], 'paramInteger' => ['1', new Param(1, DataType::INTEGER)], 'expression' => ['(1 + 2)', new Expression('(1 + 2)')], + 'expression with params' => ['(1 + 2)', new Expression('(:a + :b)', [':a' => 1, 'b' => 2])], 'Stringable' => ["'string'", new Stringable('string')], ]; } diff --git a/tests/Support/Stub/DQLQueryBuilder.php b/tests/Support/Stub/DQLQueryBuilder.php index 00e8350a5..cb3d793fa 100644 --- a/tests/Support/Stub/DQLQueryBuilder.php +++ b/tests/Support/Stub/DQLQueryBuilder.php @@ -4,16 +4,8 @@ namespace Yiisoft\Db\Tests\Support\Stub; -use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\QueryBuilder\AbstractDQLQueryBuilder; final class DQLQueryBuilder extends AbstractDQLQueryBuilder { - protected function defaultExpressionBuilders(): array - { - return [ - ...parent::defaultExpressionBuilders(), - Expression::class => ExpressionBuilder::class, - ]; - } } diff --git a/tests/Support/Stub/QueryBuilder.php b/tests/Support/Stub/QueryBuilder.php index 9f928ff86..1a99eac7d 100644 --- a/tests/Support/Stub/QueryBuilder.php +++ b/tests/Support/Stub/QueryBuilder.php @@ -22,4 +22,9 @@ public function __construct(ConnectionInterface $db) new ColumnDefinitionBuilder($this), ); } + + protected function createSqlParser(string $sql): SqlParser + { + return new SqlParser($sql); + } }