Skip to content

Commit

Permalink
Add SqlParser to fix case when string value containing a placeholder …
Browse files Browse the repository at this point in the history
…name. Fix unique param name w/ and w/o colon
  • Loading branch information
Tigrov committed Mar 30, 2024
1 parent e9660dc commit 5daa211
Show file tree
Hide file tree
Showing 6 changed files with 484 additions and 40 deletions.
152 changes: 114 additions & 38 deletions src/Expression/ExpressionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,13 +21,25 @@
*
* 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
{
public function __construct(private QueryBuilderInterface|null $queryBuilder = null)
{
}

/**
* 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();
Expand All @@ -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));

Check failure on line 180 in src/Expression/ExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / psalm / PHP 8.1-ubuntu-latest

PossiblyNullArgument

src/Expression/ExpressionBuilder.php:180:59: PossiblyNullArgument: Argument 3 of substr_replace cannot be null, possibly null value provided (see https://psalm.dev/078)

Check failure on line 180 in src/Expression/ExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / psalm / PHP 8.3-ubuntu-latest

PossiblyNullArgument

src/Expression/ExpressionBuilder.php:180:59: PossiblyNullArgument: Argument 3 of substr_replace cannot be null, possibly null value provided (see https://psalm.dev/078)

Check failure on line 180 in src/Expression/ExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / psalm / PHP 8.2-ubuntu-latest

PossiblyNullArgument

src/Expression/ExpressionBuilder.php:180:59: PossiblyNullArgument: Argument 3 of substr_replace cannot be null, possibly null value provided (see https://psalm.dev/078)

Check failure on line 180 in src/Expression/ExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / psalm / PHP 8.1-ubuntu-latest

PossiblyNullArgument

src/Expression/ExpressionBuilder.php:180:59: PossiblyNullArgument: Argument 3 of substr_replace cannot be null, possibly null value provided (see https://psalm.dev/078)

Check failure on line 180 in src/Expression/ExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / psalm / PHP 8.2-ubuntu-latest

PossiblyNullArgument

src/Expression/ExpressionBuilder.php:180:59: PossiblyNullArgument: Argument 3 of substr_replace cannot be null, possibly null value provided (see https://psalm.dev/078)
}
}

return $uniqueName;
return $sql;

Check warning on line 184 in src/Expression/ExpressionBuilder.php

View check run for this annotation

Codecov / codecov/patch

src/Expression/ExpressionBuilder.php#L184

Added line #L184 was not covered by tests
}

/**
* 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);
}
}
Loading

0 comments on commit 5daa211

Please sign in to comment.