Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- Enh #382: Replace `DbArrayHelper::getColumn()` with `array_column()` (@Tigrov)
- New #384: Add `IndexMethod` class (@Tigrov)
- Bug #387: Explicitly mark nullable parameters (@vjik)
- Enh #386: Refactor array, structured and JSON expression builders (@Tigrov)

## 1.3.0 March 21, 2024

Expand Down
222 changes: 124 additions & 98 deletions src/Builder/ArrayExpressionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,162 +4,188 @@

namespace Yiisoft\Db\Pgsql\Builder;

use Yiisoft\Db\Exception\Exception;
use Yiisoft\Db\Exception\InvalidArgumentException;
use Yiisoft\Db\Exception\InvalidConfigException;
use Yiisoft\Db\Exception\NotSupportedException;
use Yiisoft\Db\Command\Param;
use Yiisoft\Db\Constant\ColumnType;
use Yiisoft\Db\Constant\DataType;
use Yiisoft\Db\Expression\AbstractArrayExpressionBuilder;
use Yiisoft\Db\Expression\ArrayExpression;
use Yiisoft\Db\Expression\ExpressionBuilderInterface;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Pgsql\Data\LazyArray;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
use Yiisoft\Db\Schema\Column\AbstractArrayColumn;
use Yiisoft\Db\Schema\Column\ColumnInterface;
use Yiisoft\Db\Schema\Data\LazyArrayInterface;

use function array_map;
use function implode;
use function in_array;
use function is_iterable;
use function is_array;
use function iterator_to_array;
use function str_repeat;

/**
* Builds expressions for {@see ArrayExpression} for PostgreSQL Server.
*/
final class ArrayExpressionBuilder implements ExpressionBuilderInterface
final class ArrayExpressionBuilder extends AbstractArrayExpressionBuilder
{
public function __construct(private QueryBuilderInterface $queryBuilder)
protected function buildStringValue(string $value, ArrayExpression $expression, array &$params): string
{
$param = new Param($value, DataType::STRING);

$column = $this->getColumn($expression);
$dbType = $this->getColumnDbType($column);

$typeHint = $this->getTypeHint($dbType, $column?->getDimension() ?? 1);

Check warning on line 37 in src/Builder/ArrayExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "IncrementInteger": --- Original +++ New @@ @@ $param = new Param($value, DataType::STRING); $column = $this->getColumn($expression); $dbType = $this->getColumnDbType($column); - $typeHint = $this->getTypeHint($dbType, $column?->getDimension() ?? 1); + $typeHint = $this->getTypeHint($dbType, $column?->getDimension() ?? 2); return $this->queryBuilder->bindParam($param, $params) . $typeHint; } protected function buildSubquery(QueryInterface $query, ArrayExpression $expression, array &$params) : string

return $this->queryBuilder->bindParam($param, $params) . $typeHint;
}

/**
* The Method builds the raw SQL from the expression that won't be additionally escaped or quoted.
*
* @param ArrayExpression $expression The expression build.
* @param array $params The binding parameters.
*
* @throws Exception
* @throws InvalidArgumentException
* @throws InvalidConfigException
* @throws NotSupportedException
*
* @return string The raw SQL that won't be additionally escaped or quoted.
*/
public function build(ExpressionInterface $expression, array &$params = []): string
protected function buildSubquery(QueryInterface $query, ArrayExpression $expression, array &$params): string
{
/** @psalm-var array|mixed|QueryInterface $value */
$value = $expression->getValue();
$column = $this->getColumn($expression);
$dbType = $this->getColumnDbType($column);

if ($value === null) {
return 'NULL';
}
return $this->buildNestedSubquery($query, $dbType, $column?->getDimension() ?? 1, $params);

Check warning on line 47 in src/Builder/ArrayExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "DecrementInteger": --- Original +++ New @@ @@ { $column = $this->getColumn($expression); $dbType = $this->getColumnDbType($column); - return $this->buildNestedSubquery($query, $dbType, $column?->getDimension() ?? 1, $params); + return $this->buildNestedSubquery($query, $dbType, $column?->getDimension() ?? 0, $params); } protected function buildValue(iterable $value, ArrayExpression $expression, array &$params) : string {

Check warning on line 47 in src/Builder/ArrayExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "IncrementInteger": --- Original +++ New @@ @@ { $column = $this->getColumn($expression); $dbType = $this->getColumnDbType($column); - return $this->buildNestedSubquery($query, $dbType, $column?->getDimension() ?? 1, $params); + return $this->buildNestedSubquery($query, $dbType, $column?->getDimension() ?? 2, $params); } protected function buildValue(iterable $value, ArrayExpression $expression, array &$params) : string {
}

if ($value instanceof QueryInterface) {
[$sql, $params] = $this->queryBuilder->build($value, $params);
return $this->buildSubqueryArray($sql, $expression);
}
protected function buildValue(iterable $value, ArrayExpression $expression, array &$params): string
{
$column = $this->getColumn($expression);
$dbType = $this->getColumnDbType($column);

/** @psalm-var string[] $placeholders */
$placeholders = $this->buildPlaceholders($expression, $params);
return $this->buildNestedValue($value, $dbType, $column?->getColumn(), $column?->getDimension() ?? 1, $params);
}

return 'ARRAY[' . implode(', ', $placeholders) . ']' . $this->getTypeHint($expression);
protected function getLazyArrayValue(LazyArrayInterface $value): array|string
{
if ($value instanceof LazyArray) {
return $value->getRawValue();
}

return $value->getValue();
}

/**
* Builds a placeholder array out of $expression values.
*
* @param array $params The binding parameters.
*
* @throws Exception
* @throws InvalidArgumentException
* @throws InvalidConfigException
* @throws NotSupportedException
* @param string[] $placeholders
*/
private function buildPlaceholders(ArrayExpression $expression, array &$params): array
private function buildNestedArray(array $placeholders, string $dbType, int $dimension): string
{
$placeholders = [];
$typeHint = $this->getTypeHint($dbType, $dimension);

/** @psalm-var mixed $value */
$value = $expression->getValue();
return 'ARRAY[' . implode(',', $placeholders) . ']' . $typeHint;
}

if (!is_iterable($value)) {
return $placeholders;
}
private function buildNestedSubquery(QueryInterface $query, string $dbType, int $dimension, array &$params): string
{
[$sql, $params] = $this->queryBuilder->build($query, $params);

if ($expression->getDimension() > 1) {
/** @psalm-var mixed $item */
return "ARRAY($sql)" . $this->getTypeHint($dbType, $dimension);
}

private function buildNestedValue(iterable $value, string $dbType, ColumnInterface|null $column, int $dimension, array &$params): string
{
$placeholders = [];

if ($dimension > 1) {
/** @var iterable|null $item */
foreach ($value as $item) {
$placeholders[] = $this->build($this->unnestArrayExpression($expression, $item), $params);
if ($item === null) {
$placeholders[] = 'NULL';
} elseif ($item instanceof ExpressionInterface) {
$placeholders[] = $item instanceof QueryInterface
? $this->buildNestedSubquery($item, $dbType, $dimension - 1, $params)
: $this->queryBuilder->buildExpression($item, $params);

Check warning on line 96 in src/Builder/ArrayExpressionBuilder.php

View check run for this annotation

Codecov / codecov/patch

src/Builder/ArrayExpressionBuilder.php#L96

Added line #L96 was not covered by tests
} else {
$placeholders[] = $this->buildNestedValue($item, $dbType, $column, $dimension - 1, $params);
}
}
return $placeholders;
}
} else {
$value = $this->dbTypecast($value, $column);

/** @psalm-var ExpressionInterface|int $item */
foreach ($value as $item) {
if ($item instanceof QueryInterface) {
[$sql, $params] = $this->queryBuilder->build($item, $params);
$placeholders[] = $this->buildSubqueryArray($sql, $expression);
continue;
foreach ($value as $item) {
if ($item instanceof ExpressionInterface) {
$placeholders[] = $this->queryBuilder->buildExpression($item, $params);
} else {
$placeholders[] = $this->queryBuilder->bindParam($item, $params);
}
}
}

return $this->buildNestedArray($placeholders, $dbType, $dimension);
}

private function getColumn(ArrayExpression $expression): AbstractArrayColumn|null
{
$type = $expression->getType();

if ($type === null || $type instanceof AbstractArrayColumn) {
return $type;
}

$item = $this->typecastValue($expression, $item);
$info = [];

if ($item instanceof ExpressionInterface) {
$placeholders[] = $this->queryBuilder->buildExpression($item, $params);
} else {
$placeholders[] = $this->queryBuilder->bindParam($item, $params);
if ($type instanceof ColumnInterface) {
$info['column'] = $type;
} elseif ($type !== ColumnType::ARRAY) {
$column = $this
->queryBuilder
->getSchema()
->getColumnFactory()
->fromDefinition($type);

if ($column instanceof AbstractArrayColumn) {
return $column;
}

$info['column'] = $column;
}

return $placeholders;
/** @var AbstractArrayColumn */
return $this
->queryBuilder
->getSchema()
->getColumnFactory()
->fromType(ColumnType::ARRAY, $info);
}

private function unnestArrayExpression(ArrayExpression $expression, mixed $value): ArrayExpression
private function getColumnDbType(AbstractArrayColumn|null $column): string
{
return new ArrayExpression($value, $expression->getType(), $expression->getDimension() - 1);
if ($column === null) {
return '';
}

return rtrim($this->queryBuilder->getColumnDefinitionBuilder()->buildType($column), '[]');
}

/**
* @return string The typecast expression based on {@see type}.
* Return the type hint expression based on type and dimension.
*/
private function getTypeHint(ArrayExpression $expression): string
private function getTypeHint(string $dbType, int $dimension): string
{
$type = $expression->getType();

if ($type === null) {
if (empty($dbType)) {
return '';
}

$dimension = $expression->getDimension();

return '::' . $type . str_repeat('[]', $dimension);
return '::' . $dbType . str_repeat('[]', $dimension);
}

/**
* Build an array expression from a sub-query SQL.
* Converts array values for use in a db query.
*
* @param string $sql The sub-query SQL.
* @param ArrayExpression $expression The array expression.
* @param iterable $value The array or iterable object.
* @param ColumnInterface|null $column The column instance to typecast values.
*
* @return string The sub-query array expression.
* @return iterable Converted values.
*/
private function buildSubqueryArray(string $sql, ArrayExpression $expression): string
private function dbTypecast(iterable $value, ColumnInterface|null $column): iterable
{
return 'ARRAY(' . $sql . ')' . $this->getTypeHint($expression);
}

/**
* @return array|bool|ExpressionInterface|float|int|JsonExpression|string|null The cast value or expression.
*/
private function typecastValue(
ArrayExpression $expression,
array|bool|float|int|string|ExpressionInterface|null $value
): array|bool|float|int|string|JsonExpression|ExpressionInterface|null {
if ($value instanceof ExpressionInterface) {
if ($column === null) {
return $value;
}

if (in_array($expression->getType(), ['json', 'jsonb'], true)) {
return new JsonExpression($value);
if (!is_array($value)) {
$value = iterator_to_array($value, false);

Check warning on line 186 in src/Builder/ArrayExpressionBuilder.php

View workflow job for this annotation

GitHub Actions / PHP 8.3-ubuntu-latest

Escaped Mutant for Mutator "FalseValue": --- Original +++ New @@ @@ return $value; } if (!is_array($value)) { - $value = iterator_to_array($value, false); + $value = iterator_to_array($value, true); } return array_map($column->dbTypecast(...), $value); } }
}

return $value;
return array_map($column->dbTypecast(...), $value);
}
}
1 change: 1 addition & 0 deletions src/Builder/ArrayOverlapsConditionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function build(ExpressionInterface $expression, array &$params = []): str
$values = $expression->getValues();

if ($values instanceof JsonExpression) {
/** @psalm-suppress MixedArgument */
$values = new ArrayExpression($values->getValue());
} elseif (!$values instanceof ExpressionInterface) {
$values = new ArrayExpression($values);
Expand Down
24 changes: 9 additions & 15 deletions src/Builder/JsonExpressionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@
use Yiisoft\Db\Expression\ExpressionBuilderInterface;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Expression\JsonExpressionBuilder as BaseJsonExpressionBuilder;
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Json\Json;

/**
* Builds expressions for {@see `Yiisoft\Db\Expression\JsonExpression`} for PostgreSQL Server.
*/
final class JsonExpressionBuilder implements ExpressionBuilderInterface
{
public function __construct(private QueryBuilderInterface $queryBuilder)
private BaseJsonExpressionBuilder $baseExpressionBuilder;

public function __construct(QueryBuilderInterface $queryBuilder)
{
$this->baseExpressionBuilder = new BaseJsonExpressionBuilder($queryBuilder);
}

/**
Expand All @@ -42,21 +44,13 @@ public function __construct(private QueryBuilderInterface $queryBuilder)
*/
public function build(ExpressionInterface $expression, array &$params = []): string
{
/** @psalm-var mixed $value */
$value = $expression->getValue();

if ($value instanceof QueryInterface) {
[$sql, $params] = $this->queryBuilder->build($value, $params);
return "($sql)" . $this->getTypeHint($expression);
}
$statement = $this->baseExpressionBuilder->build($expression, $params);

if ($value instanceof ArrayExpression) {
$placeholder = 'array_to_json(' . $this->queryBuilder->buildExpression($value, $params) . ')';
} else {
$placeholder = $this->queryBuilder->bindParam(Json::encode($value), $params);
if ($expression->getValue() instanceof ArrayExpression) {
$statement = 'array_to_json(' . $statement . ')';
}

return $placeholder . $this->getTypeHint($expression);
return $statement . $this->getTypeHint($expression);
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/Builder/JsonOverlapsConditionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function build(ExpressionInterface $expression, array &$params = []): str
$values = $expression->getValues();

if ($values instanceof JsonExpression) {
/** @psalm-suppress MixedArgument */
$values = new ArrayExpression($values->getValue());
} elseif (!$values instanceof ExpressionInterface) {
$values = new ArrayExpression($values);
Expand Down
Loading
Loading