Skip to content

Commit

Permalink
Support composite types (#303)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tigrov authored Jan 9, 2024
1 parent c4f1271 commit 730a00f
Show file tree
Hide file tree
Showing 16 changed files with 1,088 additions and 12 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Bug #316, #6: Support table view constraints (@Tigrov)
- Enh #324: Change property `Schema::$typeMap` to constant `Schema::TYPE_MAP` (@Tigrov)
- Enh #303: Support composite types (@Tigrov)

## 1.2.0 November 12, 2023

Expand All @@ -26,7 +27,7 @@
- Enh #294: Refactoring of `Schema::normalizeDefaultValue()` method (@Tigrov)
- Bug #287: Fix `bit` type (@Tigrov)
- Bug #295: Fix multiline and single quote in default string value, add support for PostgreSQL 9.4 parentheses around negative numeric default values (@Tigrov)
- Bug #296: Prevent posible issues with array default values `('{one,two}'::text[])::varchar[]`, remove `ArrayParser::parseString()` (@Tigrov)
- Bug #296: Prevent possible issues with array default values `('{one,two}'::text[])::varchar[]`, remove `ArrayParser::parseString()` (@Tigrov)

## 1.0.0 April 12, 2023

Expand Down
3 changes: 3 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
<issueHandlers>
<MixedAssignment errorLevel="suppress" />
</issueHandlers>
</psalm>
114 changes: 114 additions & 0 deletions src/Builder/CompositeExpressionBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

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\Expression\ExpressionBuilderInterface;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Pgsql\Composite\CompositeExpression;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;

use function implode;

/**
* Builds expressions for {@see CompositeExpression} for PostgreSQL Server.
*/
final class CompositeExpressionBuilder implements ExpressionBuilderInterface
{
public function __construct(private QueryBuilderInterface $queryBuilder)
{
}

/**
* The method builds the raw SQL from the expression that won't be additionally escaped or quoted.
*
* @param CompositeExpression $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
{
$value = $expression->getValue();

if (empty($value)) {
return 'NULL';
}

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

/** @psalm-var string[] $placeholders */
$placeholders = $this->buildPlaceholders($expression, $params);

if (empty($placeholders)) {
return 'NULL';
}

return 'ROW(' . implode(', ', $placeholders) . ')' . $this->getTypeHint($expression);
}

/**
* Builds a placeholder array out of $expression values.
*
* @param array $params The binding parameters.
*
* @throws Exception
* @throws InvalidArgumentException
* @throws InvalidConfigException
* @throws NotSupportedException
*/
private function buildPlaceholders(CompositeExpression $expression, array &$params): array
{
$value = $expression->getNormalizedValue();

if (!is_iterable($value)) {
return [];
}

$placeholders = [];
$columns = $expression->getColumns();

/** @psalm-var int|string $columnName */
foreach ($value as $columnName => $item) {
if (isset($columns[$columnName])) {
$item = $columns[$columnName]->dbTypecast($item);
}

if ($item instanceof ExpressionInterface) {
$placeholders[] = $this->queryBuilder->buildExpression($item, $params);
} else {
$placeholders[] = $this->queryBuilder->bindParam($item, $params);
}
}

return $placeholders;
}

/**
* @return string The typecast expression based on {@see type}.
*/
private function getTypeHint(CompositeExpression $expression): string
{
$type = $expression->getType();

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

return '::' . $type;
}
}
122 changes: 117 additions & 5 deletions src/ColumnSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
use Yiisoft\Db\Expression\ArrayExpression;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Pgsql\Composite\CompositeExpression;
use Yiisoft\Db\Pgsql\Composite\CompositeParser;
use Yiisoft\Db\Schema\AbstractColumnSchema;
use Yiisoft\Db\Schema\ColumnSchemaInterface;
use Yiisoft\Db\Schema\SchemaInterface;

use function array_walk_recursive;
Expand Down Expand Up @@ -58,6 +61,12 @@ final class ColumnSchema extends AbstractColumnSchema
*/
private string|null $sequenceName = null;

/**
* @var ColumnSchemaInterface[] Columns metadata of the composite type.
* @psalm-var array<string, ColumnSchemaInterface>
*/
private array $columns = [];

/**
* Converts the input value according to {@see type} and {@see dbType} for use in a db query.
*
Expand All @@ -69,14 +78,63 @@ final class ColumnSchema extends AbstractColumnSchema
*/
public function dbTypecast(mixed $value): mixed
{
if ($value === null || $value instanceof ExpressionInterface) {
return $value;
}

if ($this->dimension > 0) {
if ($value === null || $value instanceof ExpressionInterface) {
return $value;
}

if ($this->getType() === Schema::TYPE_COMPOSITE) {
$value = $this->dbTypecastArray($value, $this->dimension);
}

return new ArrayExpression($value, $this->getDbType(), $this->dimension);
}

return $this->dbTypecastValue($value);
}

/**
* Recursively converts array values for use in a db query.
*
* @param mixed $value The array or iterable object.
* @param int $dimension The array dimension. Should be more than 0.
*
* @return array|null Converted values.
*/
private function dbTypecastArray(mixed $value, int $dimension): array|null
{
if ($value === null) {
return null;
}

if (!is_iterable($value)) {
return [];
}

$items = [];

if ($dimension > 1) {
foreach ($value as $val) {
$items[] = $this->dbTypecastArray($val, $dimension - 1);
}
} else {
foreach ($value as $val) {
$items[] = $this->dbTypecastValue($val);
}
}

return $items;
}

/**
* Converts the input value for use in a db query.
*/
private function dbTypecastValue(mixed $value): mixed
{
if ($value === null || $value instanceof ExpressionInterface) {
return $value;
}

return match ($this->getType()) {
SchemaInterface::TYPE_JSON => new JsonExpression($value, $this->getDbType()),

Expand All @@ -88,6 +146,8 @@ public function dbTypecast(mixed $value): mixed
? str_pad(decbin($value), (int) $this->getSize(), '0', STR_PAD_LEFT)
: (string) $value,

Schema::TYPE_COMPOSITE => new CompositeExpression($value, $this->getDbType(), $this->columns),

default => $this->typecast($value),
};
}
Expand Down Expand Up @@ -115,7 +175,6 @@ public function phpTypecast(mixed $value): mixed
}

array_walk_recursive($value, function (mixed &$val) {
/** @psalm-var mixed $val */
$val = $this->phpTypecastValue($val);
});

Expand Down Expand Up @@ -144,10 +203,42 @@ private function phpTypecastValue(mixed $value): mixed
SchemaInterface::TYPE_JSON
=> json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR),

Schema::TYPE_COMPOSITE => $this->phpTypecastComposite($value),

default => parent::phpTypecast($value),
};
}

/**
* Converts the input value according to the composite type after retrieval from the database.
*/
private function phpTypecastComposite(mixed $value): array|null
{
if (is_string($value)) {
$value = (new CompositeParser())->parse($value);
}

if (!is_iterable($value)) {
return null;
}

$fields = [];
$columnNames = array_keys($this->columns);

/** @psalm-var int|string $columnName */
foreach ($value as $columnName => $item) {
$columnName = $columnNames[$columnName] ?? $columnName;

if (isset($this->columns[$columnName])) {
$item = $this->columns[$columnName]->phpTypecast($item);
}

$fields[$columnName] = $item;
}

return $fields;
}

/**
* Creates instance of ArrayParser.
*/
Expand Down Expand Up @@ -191,4 +282,25 @@ public function sequenceName(string|null $sequenceName): void
{
$this->sequenceName = $sequenceName;
}

/**
* Set columns of the composite type.
*
* @param ColumnSchemaInterface[] $columns The metadata of the composite type columns.
* @psalm-param array<string, ColumnSchemaInterface> $columns
*/
public function columns(array $columns): void
{
$this->columns = $columns;
}

/**
* Get the metadata of the composite type columns.
*
* @return ColumnSchemaInterface[]
*/
public function getColumns(): array
{
return $this->columns;
}
}
Loading

0 comments on commit 730a00f

Please sign in to comment.