Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Process expressions inside param values when build a query #806

Merged
merged 34 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e1f142f
Build expressions if they inside `Expression::$params`
Tigrov Feb 5, 2024
c60f3a1
Improve
Tigrov Feb 5, 2024
f15c515
Fix errors
Tigrov Feb 5, 2024
17eb937
Fix errors
Tigrov Feb 6, 2024
b325bab
Update tests
Tigrov Feb 6, 2024
54dedfb
Apply fixes from StyleCI
StyleCIBot Feb 6, 2024
02708bb
Add tests
Tigrov Feb 11, 2024
5984d34
Resolve BC, make `$queryBuilder` optional in the constructor.
Tigrov Feb 11, 2024
7803afb
Apply fixes from StyleCI
StyleCIBot Feb 11, 2024
0845c13
Merge branch 'master' into fix-update-with-expressions
Tigrov Feb 11, 2024
8f76d49
Fix psalm issue
Tigrov Feb 11, 2024
b922159
Improve
Tigrov Feb 13, 2024
a5bff4a
Add test for indexed params
Tigrov Feb 13, 2024
be292a7
Merge branch 'master' into fix-update-with-expressions
vjik Mar 13, 2024
766fc54
Merge branch 'master' into fix-update-with-expressions
Tigrov Mar 25, 2024
e9660dc
Fix using regex when string value containing a placeholder name
Tigrov Mar 28, 2024
5daa211
Add SqlParser to fix case when string value containing a placeholder …
Tigrov Mar 30, 2024
27ad79f
Apply fixes from StyleCI
StyleCIBot Mar 30, 2024
79395aa
Fix psalm issue
Tigrov Mar 30, 2024
6f2cbb5
Cover tests
Tigrov Mar 30, 2024
04696b9
Cover tests
Tigrov Mar 30, 2024
026a51d
Cover tests
Tigrov Mar 30, 2024
011e68c
Optimization
Tigrov Apr 1, 2024
6d1de52
Make `SqlParser` abstract. Add test for `Param` and fix
Tigrov Apr 1, 2024
f8f226a
Fix array merge for php8.0
Tigrov Apr 1, 2024
42c263e
Update comments and remove `null` for `$queryBuilder`
Tigrov Apr 2, 2024
4c22f58
Add lines to CHANGELOG.md and UPGRADE.md [skip ci]
Tigrov Apr 6, 2024
76bac2c
Update CHANGELOG.md
Tigrov Apr 6, 2024
2731b3a
Apply Rector changes (CI)
Tigrov Apr 6, 2024
3c3f944
Apply fixes from StyleCI
StyleCIBot Apr 6, 2024
6dd4791
Apply suggestions from code review [skip ci]
Tigrov Apr 9, 2024
03fe6dc
Merge branch 'master' into fix-update-with-expressions
Tigrov Apr 9, 2024
5885a6f
Improve UPGRADE.md
Tigrov Apr 9, 2024
fe50046
Add test with UTF-8 multibyte symbols
Tigrov Apr 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 175 additions & 2 deletions src/Expression/ExpressionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@

namespace Yiisoft\Db\Expression;

use Yiisoft\Db\Connection\ConnectionInterface;
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
use Yiisoft\Db\Syntax\SqlParser;

use function array_merge;
use function strlen;
use function substr;
use function substr_replace;

/**
* It's used to build expressions for use in database queries.
Expand All @@ -14,12 +21,178 @@
*
* 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)
vjik marked this conversation as resolved.
Show resolved Hide resolved
{
}

/**
* 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
{
$params = array_merge($params, $expression->getParams());
return $expression->__toString();
$sql = $expression->__toString();
$expressionParams = $expression->getParams();

if (empty($expressionParams)) {
return $sql;
}

if ($this->queryBuilder === null || isset($expressionParams[0])) {
$params = array_merge($params, $expressionParams);
vjik marked this conversation as resolved.
Show resolved Hide resolved
return $sql;
}

$sql = $this->appendParams($sql, $expressionParams, $params);

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 = [];

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

/** @var non-empty-string $name */
foreach ($nonUniqueParams as $name => $value) {
$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 $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
{
/** @var non-empty-string $name */
foreach ($expressionParams as $name => $value) {
if (!$value instanceof ExpressionInterface) {
continue;
}

$placeholder = $name[0] !== ':' ? ":$name" : $name;
/** @psalm-suppress PossiblyNullReference */
$replacement = $this->queryBuilder->buildExpression($value, $params);

$sql = $this->replacePlaceholder($sql, $placeholder, $replacement);

/** @psalm-var ParamsType $params */
unset($params[$name]);
}

return $sql;
}

/**
* 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 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
{
$parser = $this->createSqlParser($sql);

while (null !== $parsedPlaceholder = $parser->getNextPlaceholder($position)) {
if ($parsedPlaceholder === $placeholder) {
/** @var int $position */
return substr_replace($sql, $replacement, $position, strlen($placeholder));
}
}

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);
}
}
47 changes: 13 additions & 34 deletions src/QueryBuilder/AbstractDQLQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,25 +105,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<string, ExpressionInterface|string> */
foreach ($query->getOrderBy() as $expression) {
if ($expression instanceof ExpressionInterface) {
$this->buildExpression($expression, $params);
}
}
}

if (!empty($query->getGroupBy())) {
/** @psalm-var array<string, ExpressionInterface|string> */
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);

Expand Down Expand Up @@ -165,19 +147,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
Expand Down Expand Up @@ -208,10 +193,7 @@ public function buildGroupBy(array $columns, array &$params = []): string
/** @psalm-var array<string, ExpressionInterface|string> $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);
}
Expand Down Expand Up @@ -299,10 +281,7 @@ public function buildOrderBy(array $columns, array &$params = []): string
/** @psalm-var array<string, ExpressionInterface|int|string> $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' : '');
}
Expand Down
Loading
Loading