diff --git a/CHANGELOG.md b/CHANGELOG.md
index 791bdee18..d3c1346e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/psalm.xml b/psalm.xml
index 23bfcce17..54a52e176 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -14,4 +14,7 @@
+
+
+
diff --git a/src/Builder/CompositeExpressionBuilder.php b/src/Builder/CompositeExpressionBuilder.php
new file mode 100644
index 000000000..5a531b126
--- /dev/null
+++ b/src/Builder/CompositeExpressionBuilder.php
@@ -0,0 +1,114 @@
+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;
+ }
+}
diff --git a/src/ColumnSchema.php b/src/ColumnSchema.php
index db39d788b..374f65437 100644
--- a/src/ColumnSchema.php
+++ b/src/ColumnSchema.php
@@ -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;
@@ -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
+ */
+ private array $columns = [];
+
/**
* Converts the input value according to {@see type} and {@see dbType} for use in a db query.
*
@@ -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()),
@@ -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),
};
}
@@ -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);
});
@@ -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.
*/
@@ -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 $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;
+ }
}
diff --git a/src/Composite/CompositeExpression.php b/src/Composite/CompositeExpression.php
new file mode 100644
index 000000000..56ba9e0b8
--- /dev/null
+++ b/src/Composite/CompositeExpression.php
@@ -0,0 +1,97 @@
+ 10, 'currency_code' => 'USD']);
+ * ```
+ *
+ * Will be encoded to `ROW(10, USD)`
+ */
+class CompositeExpression implements ExpressionInterface
+{
+ /**
+ * @param ColumnSchemaInterface[] $columns
+ * @psalm-param array $columns
+ */
+ public function __construct(
+ private mixed $value,
+ private string|null $type = null,
+ private array $columns = [],
+ ) {
+ }
+
+ /**
+ * The composite type name.
+ *
+ * Defaults to `null` which means the type is not explicitly specified.
+ *
+ * Note that in the case where a type is not specified explicitly and DBMS cannot guess it from the context,
+ * SQL error will be raised.
+ */
+ public function getType(): string|null
+ {
+ return $this->type;
+ }
+
+ /**
+ * The composite type columns that are used for value normalization and type casting.
+ *
+ * @return ColumnSchemaInterface[]
+ */
+ public function getColumns(): array
+ {
+ return $this->columns;
+ }
+
+ /**
+ * The content of the composite type. It can be represented as an associative array of composite type column names
+ * and values.
+ */
+ public function getValue(): mixed
+ {
+ return $this->value;
+ }
+
+ /**
+ * Sorted values according to the order of composite type columns,
+ * indexed keys are replaced with column names,
+ * missing elements are filled in with default values,
+ * excessive elements are removed.
+ */
+ public function getNormalizedValue(): mixed
+ {
+ if (empty($this->columns) || !is_iterable($this->value)) {
+ return $this->value;
+ }
+
+ $normalized = [];
+ $value = $this->value;
+ $columnsNames = array_keys($this->columns);
+
+ if ($value instanceof Traversable) {
+ $value = iterator_to_array($value);
+ }
+
+ foreach ($columnsNames as $i => $columnsName) {
+ $normalized[$columnsName] = match (true) {
+ array_key_exists($columnsName, $value) => $value[$columnsName],
+ array_key_exists($i, $value) => $value[$i],
+ default => $this->columns[$columnsName]->getDefaultValue(),
+ };
+ }
+
+ return $normalized;
+ }
+}
diff --git a/src/Composite/CompositeParser.php b/src/Composite/CompositeParser.php
new file mode 100644
index 000000000..5e2064dff
--- /dev/null
+++ b/src/Composite/CompositeParser.php
@@ -0,0 +1,76 @@
+parseComposite($value);
+ }
+
+ /**
+ * Parses PostgreSQL composite type value encoded in string.
+ *
+ * @param string $value String to parse.
+ */
+ private function parseComposite(string $value): array
+ {
+ for ($result = [], $i = 1;; ++$i) {
+ $result[] = match ($value[$i]) {
+ ',', ')' => null,
+ '"' => $this->parseQuotedString($value, $i),
+ default => $this->parseUnquotedString($value, $i),
+ };
+
+ if ($value[$i] === ')') {
+ return $result;
+ }
+ }
+ }
+
+ /**
+ * Parses quoted string.
+ */
+ private function parseQuotedString(string $value, int &$i): string
+ {
+ for ($result = '', ++$i;; ++$i) {
+ if ($value[$i] === '\\') {
+ ++$i;
+ } elseif ($value[$i] === '"') {
+ ++$i;
+ return $result;
+ }
+
+ $result .= $value[$i];
+ }
+ }
+
+ /**
+ * Parses unquoted string.
+ */
+ private function parseUnquotedString(string $value, int &$i): string
+ {
+ for ($result = '';; ++$i) {
+ if (in_array($value[$i], [',', ')'], true)) {
+ return $result;
+ }
+
+ $result .= $value[$i];
+ }
+ }
+}
diff --git a/src/DQLQueryBuilder.php b/src/DQLQueryBuilder.php
index 604bbf370..1ed4a5771 100644
--- a/src/DQLQueryBuilder.php
+++ b/src/DQLQueryBuilder.php
@@ -8,7 +8,9 @@
use Yiisoft\Db\Expression\ExpressionBuilderInterface;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Pgsql\Builder\ArrayExpressionBuilder;
+use Yiisoft\Db\Pgsql\Builder\CompositeExpressionBuilder;
use Yiisoft\Db\Pgsql\Builder\JsonExpressionBuilder;
+use Yiisoft\Db\Pgsql\Composite\CompositeExpression;
use Yiisoft\Db\QueryBuilder\AbstractDQLQueryBuilder;
use Yiisoft\Db\QueryBuilder\Condition\LikeCondition;
@@ -50,6 +52,7 @@ protected function defaultExpressionBuilders(): array
return array_merge(parent::defaultExpressionBuilders(), [
ArrayExpression::class => ArrayExpressionBuilder::class,
JsonExpression::class => JsonExpressionBuilder::class,
+ CompositeExpression::class => CompositeExpressionBuilder::class,
]);
}
}
diff --git a/src/Schema.php b/src/Schema.php
index 1cfbe77d4..7c466186c 100644
--- a/src/Schema.php
+++ b/src/Schema.php
@@ -85,6 +85,10 @@ final class Schema extends AbstractPdoSchema
* Define the abstract column type as `bit`.
*/
public const TYPE_BIT = 'bit';
+ /**
+ * Define the abstract column type as `composite`.
+ */
+ public const TYPE_COMPOSITE = 'composite';
/**
* The mapping from physical column types (keys) to abstract column types (values).
@@ -819,10 +823,30 @@ protected function loadColumnSchema(array $info): ColumnSchemaInterface
$column->sequenceName($this->resolveTableName($info['sequence_name'])->getFullName());
}
- $column->type(self::TYPE_MAP[(string) $column->getDbType()] ?? self::TYPE_STRING);
+ if ($info['type_type'] === 'c') {
+ $column->type(self::TYPE_COMPOSITE);
+ $composite = $this->resolveTableName((string) $column->getDbType());
+
+ if ($this->findColumns($composite)) {
+ $column->columns($composite->getColumns());
+ }
+ } else {
+ $column->type(self::TYPE_MAP[(string) $column->getDbType()] ?? self::TYPE_STRING);
+ }
+
$column->phpType($this->getColumnPhpType($column));
$column->defaultValue($this->normalizeDefaultValue($defaultValue, $column));
+ if ($column->getType() === self::TYPE_COMPOSITE && $column->getDimension() === 0) {
+ /** @psalm-var array|null $defaultValue */
+ $defaultValue = $column->getDefaultValue();
+ if (is_array($defaultValue)) {
+ foreach ($column->getColumns() as $compositeColumnName => $compositeColumn) {
+ $compositeColumn->defaultValue($defaultValue[$compositeColumnName] ?? null);
+ }
+ }
+ }
+
return $column;
}
@@ -835,11 +859,11 @@ protected function loadColumnSchema(array $info): ColumnSchemaInterface
*/
protected function getColumnPhpType(ColumnSchemaInterface $column): string
{
- if ($column->getType() === self::TYPE_BIT) {
- return self::PHP_TYPE_INTEGER;
- }
-
- return parent::getColumnPhpType($column);
+ return match ($column->getType()) {
+ self::TYPE_BIT => self::PHP_TYPE_INTEGER,
+ self::TYPE_COMPOSITE => self::PHP_TYPE_ARRAY,
+ default => parent::getColumnPhpType($column),
+ };
}
/**
diff --git a/tests/ColumnSchemaTest.php b/tests/ColumnSchemaTest.php
index 8d3ec9a6f..fc037d160 100644
--- a/tests/ColumnSchemaTest.php
+++ b/tests/ColumnSchemaTest.php
@@ -211,4 +211,82 @@ public function testPrimaryKeyOfView()
$this->assertFalse($tableSchema->getColumn('C_index_2_1')->isPrimaryKey());
$this->assertFalse($tableSchema->getColumn('C_index_2_2')->isPrimaryKey());
}
+
+ public function testCompositeType(): void
+ {
+ $db = $this->getConnection(true);
+ $command = $db->createCommand();
+ $schema = $db->getSchema();
+ $tableSchema = $schema->getTableSchema('test_composite_type');
+
+ $command->insert('test_composite_type', [
+ 'price_col' => ['value' => 10.0, 'currency_code' => 'USD'],
+ 'price_array' => [
+ null,
+ ['value' => 11.11, 'currency_code' => 'USD'],
+ ['value' => null, 'currency_code' => null],
+ ],
+ 'price_array2' => [[
+ ['value' => 123.45, 'currency_code' => 'USD'],
+ ]],
+ 'range_price_col' => [
+ 'price_from' => ['value' => 1000.0, 'currency_code' => 'USD'],
+ 'price_to' => ['value' => 2000.0, 'currency_code' => 'USD'],
+ ],
+ ])->execute();
+
+ $query = (new Query($db))->from('test_composite_type')->one();
+
+ $priceColPhpType = $tableSchema->getColumn('price_col')->phpTypecast($query['price_col']);
+ $priceDefaultPhpType = $tableSchema->getColumn('price_default')->phpTypecast($query['price_default']);
+ $priceArrayPhpType = $tableSchema->getColumn('price_array')->phpTypecast($query['price_array']);
+ $priceArray2PhpType = $tableSchema->getColumn('price_array2')->phpTypecast($query['price_array2']);
+ $rangePriceColPhpType = $tableSchema->getColumn('range_price_col')->phpTypecast($query['range_price_col']);
+
+ $this->assertSame(['value' => 10.0, 'currency_code' => 'USD'], $priceColPhpType);
+ $this->assertSame(['value' => 5.0, 'currency_code' => 'USD'], $priceDefaultPhpType);
+ $this->assertSame(
+ [
+ null,
+ ['value' => 11.11, 'currency_code' => 'USD'],
+ ['value' => null, 'currency_code' => null],
+ ],
+ $priceArrayPhpType
+ );
+ $this->assertSame(
+ [[
+ ['value' => 123.45, 'currency_code' => 'USD'],
+ ]],
+ $priceArray2PhpType
+ );
+ $this->assertSame(
+ [
+ 'price_from' => ['value' => 1000.0, 'currency_code' => 'USD'],
+ 'price_to' => ['value' => 2000.0, 'currency_code' => 'USD'],
+ ],
+ $rangePriceColPhpType
+ );
+
+ $priceCol = $tableSchema->getColumn('price_col');
+ $this->assertNull($priceCol->phpTypecast(1), 'For scalar value returns `null`');
+
+ $priceCol->columns([]);
+ $this->assertSame([5, 'USD'], $priceCol->phpTypecast([5, 'USD']), 'No type casting for empty columns');
+
+ $priceArray = $tableSchema->getColumn('price_array');
+ $this->assertEquals(
+ new ArrayExpression([], 'currency_money_composite', 1),
+ $priceArray->dbTypecast(1),
+ 'For scalar value returns empty array'
+ );
+
+ $priceArray2 = $tableSchema->getColumn('price_array2');
+ $this->assertEquals(
+ new ArrayExpression([null, null], 'currency_money_composite', 2),
+ $priceArray2->dbTypecast([null, null]),
+ 'Double array of null values'
+ );
+
+ $db->close();
+ }
}
diff --git a/tests/CompositeExpressionTest.php b/tests/CompositeExpressionTest.php
new file mode 100644
index 000000000..846c42530
--- /dev/null
+++ b/tests/CompositeExpressionTest.php
@@ -0,0 +1,24 @@
+assertSame($expected, $compositeExpression->getNormalizedValue());
+ }
+}
diff --git a/tests/CompositeParserTest.php b/tests/CompositeParserTest.php
new file mode 100644
index 000000000..6e1bfb4bf
--- /dev/null
+++ b/tests/CompositeParserTest.php
@@ -0,0 +1,32 @@
+assertSame([null], $compositeParser->parse('()'));
+ $this->assertSame([0 => null, 1 => null], $compositeParser->parse('(,)'));
+ $this->assertSame([0 => '10.0', 1 => 'USD'], $compositeParser->parse('(10.0,USD)'));
+ $this->assertSame([0 => '1', 1 => '-2', 2 => null, 3 => '42'], $compositeParser->parse('(1,-2,,42)'));
+ $this->assertSame([0 => ''], $compositeParser->parse('("")'));
+ $this->assertSame(
+ [0 => '[",","null",true,"false","f"]'],
+ $compositeParser->parse('("[\",\",\"null\",true,\"false\",\"f\"]")')
+ );
+
+ // Default values can have any expressions
+ $this->assertSame(null, $compositeParser->parse("'(10.0,USD)::composite_type'"));
+ }
+}
diff --git a/tests/Provider/CompositeTypeProvider.php b/tests/Provider/CompositeTypeProvider.php
new file mode 100644
index 000000000..a19ff9861
--- /dev/null
+++ b/tests/Provider/CompositeTypeProvider.php
@@ -0,0 +1,363 @@
+ [
+ 'type' => 'integer',
+ 'dbType' => 'int4',
+ 'phpType' => 'integer',
+ 'primaryKey' => true,
+ 'allowNull' => false,
+ 'autoIncrement' => true,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => 32,
+ 'scale' => 0,
+ 'defaultValue' => null,
+ ],
+ 'price_col' => [
+ 'type' => 'composite',
+ 'dbType' => 'currency_money_composite',
+ 'phpType' => 'array',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ 'columns' => [
+ 'value' => [
+ 'type' => 'decimal',
+ 'dbType' => 'numeric',
+ 'phpType' => 'double',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => 10,
+ 'scale' => 2,
+ 'defaultValue' => null,
+ ],
+ 'currency_code' => [
+ 'type' => 'char',
+ 'dbType' => 'bpchar',
+ 'phpType' => 'string',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => 3,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ ],
+ ],
+ ],
+ 'price_default' => [
+ 'type' => 'composite',
+ 'dbType' => 'currency_money_composite',
+ 'phpType' => 'array',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => ['value' => 5.0, 'currency_code' => 'USD'],
+ 'columns' => [
+ 'value' => [
+ 'type' => 'decimal',
+ 'dbType' => 'numeric',
+ 'phpType' => 'double',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => 10,
+ 'scale' => 2,
+ 'defaultValue' => null,
+ ],
+ 'currency_code' => [
+ 'type' => 'char',
+ 'dbType' => 'bpchar',
+ 'phpType' => 'string',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => 3,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ ],
+ ],
+ ],
+ 'price_array' => [
+ 'type' => 'composite',
+ 'dbType' => 'currency_money_composite',
+ 'phpType' => 'array',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => [
+ null,
+ ['value' => 10.55, 'currency_code' => 'USD'],
+ ['value' => -1.0, 'currency_code' => null],
+ ],
+ 'dimension' => 1,
+ 'columns' => [
+ 'value' => [
+ 'type' => 'decimal',
+ 'dbType' => 'numeric',
+ 'phpType' => 'double',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => 10,
+ 'scale' => 2,
+ 'defaultValue' => null,
+ ],
+ 'currency_code' => [
+ 'type' => 'char',
+ 'dbType' => 'bpchar',
+ 'phpType' => 'string',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => 3,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ ],
+ ],
+ ],
+ 'price_array2' => [
+ 'type' => 'composite',
+ 'dbType' => 'currency_money_composite',
+ 'phpType' => 'array',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ 'dimension' => 2,
+ 'columns' => [
+ 'value' => [
+ 'type' => 'decimal',
+ 'dbType' => 'numeric',
+ 'phpType' => 'double',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => 10,
+ 'scale' => 2,
+ 'defaultValue' => null,
+ ],
+ 'currency_code' => [
+ 'type' => 'char',
+ 'dbType' => 'bpchar',
+ 'phpType' => 'string',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => 3,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ ],
+ ],
+ ],
+ 'range_price_col' => [
+ 'type' => 'composite',
+ 'dbType' => 'range_price_composite',
+ 'phpType' => 'array',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => [
+ 'price_from' => ['value' => 0.0, 'currency_code' => 'USD'],
+ 'price_to' => ['value' => 100.0, 'currency_code' => 'USD'],
+ ],
+ 'dimension' => 0,
+ 'columns' => [
+ 'price_from' => [
+ 'type' => 'composite',
+ 'dbType' => 'currency_money_composite',
+ 'phpType' => 'array',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ 'columns' => [
+ 'value' => [
+ 'type' => 'decimal',
+ 'dbType' => 'numeric',
+ 'phpType' => 'double',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => 10,
+ 'scale' => 2,
+ 'defaultValue' => null,
+ ],
+ 'currency_code' => [
+ 'type' => 'char',
+ 'dbType' => 'bpchar',
+ 'phpType' => 'string',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => 3,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ ],
+ ],
+ ],
+ 'price_to' => [
+ 'type' => 'composite',
+ 'dbType' => 'currency_money_composite',
+ 'phpType' => 'array',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ 'columns' => [
+ 'value' => [
+ 'type' => 'decimal',
+ 'dbType' => 'numeric',
+ 'phpType' => 'double',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => null,
+ 'precision' => 10,
+ 'scale' => 2,
+ 'defaultValue' => null,
+ ],
+ 'currency_code' => [
+ 'type' => 'char',
+ 'dbType' => 'bpchar',
+ 'phpType' => 'string',
+ 'primaryKey' => false,
+ 'allowNull' => true,
+ 'autoIncrement' => false,
+ 'enumValues' => null,
+ 'size' => 3,
+ 'precision' => null,
+ 'scale' => null,
+ 'defaultValue' => null,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'test_composite_type',
+ ],
+ ];
+ }
+
+ public static function normolizedValues()
+ {
+ $price5UsdColumns = [
+ 'value' => ColumnSchemaBuilder::numeric(name: 'value', precision: 10, scale: 2, defaultValue: 5.0),
+ 'currency_code' => ColumnSchemaBuilder::char(name: 'currency_code', size: 3, defaultValue: 'USD'),
+ ];
+
+ return [
+ 'Sort according to `$columns` order' => [
+ ['currency_code' => 'USD', 'value' => 10.0],
+ ['value' => 10.0, 'currency_code' => 'USD'],
+ $price5UsdColumns,
+ ],
+ 'Remove excessive elements' => [
+ ['value' => 10.0, 'currency_code' => 'USD', 'excessive' => 'element'],
+ ['value' => 10.0, 'currency_code' => 'USD'],
+ $price5UsdColumns,
+ ],
+ 'Fill default values for skipped fields' => [
+ ['currency_code' => 'CNY'],
+ ['value' => 5.0, 'currency_code' => 'CNY'],
+ $price5UsdColumns,
+ ],
+ 'Fill default values and column names for skipped indexed fields' => [
+ [10.0],
+ ['value' => 10.0, 'currency_code' => 'USD'],
+ $price5UsdColumns,
+ ],
+ 'Fill default values and column names for iterable object' => [
+ new TraversableObject([10.0]),
+ ['value' => 10.0, 'currency_code' => 'USD'],
+ $price5UsdColumns,
+ ],
+ 'Fill default values for iterable object' => [
+ new ArrayIterator(['currency_code' => 'CNY']),
+ ['value' => 5.0, 'currency_code' => 'CNY'],
+ $price5UsdColumns,
+ ],
+ 'Fill default values for empty array' => [
+ [],
+ ['value' => 5.0, 'currency_code' => 'USD'],
+ $price5UsdColumns,
+ ],
+ 'Do not normalize scalar values' => [
+ 1,
+ 1,
+ $price5UsdColumns,
+ ],
+ 'Do not normalize with empty columns' => [
+ [10.0],
+ [10.0],
+ [],
+ ],
+ ];
+ }
+}
diff --git a/tests/Provider/QueryBuilderProvider.php b/tests/Provider/QueryBuilderProvider.php
index 16d58b637..d015f812c 100644
--- a/tests/Provider/QueryBuilderProvider.php
+++ b/tests/Provider/QueryBuilderProvider.php
@@ -7,6 +7,8 @@
use Yiisoft\Db\Expression\ArrayExpression;
use Yiisoft\Db\Expression\Expression;
use Yiisoft\Db\Expression\JsonExpression;
+use Yiisoft\Db\Pgsql\Composite\CompositeExpression;
+use Yiisoft\Db\Pgsql\Tests\Support\ColumnSchemaBuilder;
use Yiisoft\Db\Pgsql\Tests\Support\TestTrait;
use Yiisoft\Db\Query\Query;
use Yiisoft\Db\Schema\SchemaInterface;
@@ -24,6 +26,11 @@ public static function buildCondition(): array
{
$buildCondition = parent::buildCondition();
+ $priceColumns = [
+ 'value' => ColumnSchemaBuilder::numeric(name: 'value', precision: 10, scale: 2),
+ 'currency_code' => ColumnSchemaBuilder::char(name: 'currency_code', size: 3),
+ ];
+
return array_merge(
$buildCondition,
[
@@ -245,6 +252,63 @@ public static function buildCondition(): array
[['>=', 'id', new ArrayExpression([1])], '"id" >= ARRAY[:qp0]', [':qp0' => 1]],
[['<=', 'id', new ArrayExpression([1])], '"id" <= ARRAY[:qp0]', [':qp0' => 1]],
[['&&', 'id', new ArrayExpression([1])], '"id" && ARRAY[:qp0]', [':qp0' => 1]],
+
+ /* composite conditions */
+ 'composite without type' => [
+ ['=', 'price_col', new CompositeExpression(['value' => 10, 'currency_code' => 'USD'])],
+ '[[price_col]] = ROW(:qp0, :qp1)',
+ [':qp0' => 10, ':qp1' => 'USD'],
+ ],
+ 'composite with type' => [
+ ['=', 'price_col', new CompositeExpression(['value' => 10, 'currency_code' => 'USD'], 'currency_money_composite')],
+ '[[price_col]] = ROW(:qp0, :qp1)::currency_money_composite',
+ [':qp0' => 10, ':qp1' => 'USD'],
+ ],
+ 'composite with columns' => [
+ ['=', 'price_col', new CompositeExpression(['value' => 10, 'currency_code' => 'USD'], 'currency_money_composite', $priceColumns)],
+ '[[price_col]] = ROW(:qp0, :qp1)::currency_money_composite',
+ [':qp0' => 10.0, ':qp1' => 'USD'],
+ ],
+ 'scalar can not be converted to composite' => [['=', 'price_col', new CompositeExpression(1)], '"price_col" = NULL', []],
+ 'array of composite' => [
+ ['=', 'price_array', new ArrayExpression(
+ [
+ null,
+ new CompositeExpression(['value' => 11.11, 'currency_code' => 'USD']),
+ new CompositeExpression(['value' => null, 'currency_code' => null]),
+ ]
+ )],
+ '"price_array" = ARRAY[:qp0, ROW(:qp1, :qp2), ROW(:qp3, :qp4)]',
+ [':qp0' => null, ':qp1' => 11.11, ':qp2' => 'USD', ':qp3' => null, ':qp4' => null],
+ ],
+ 'composite null value' => [['=', 'price_col', new CompositeExpression(null)], '"price_col" = NULL', []],
+ 'composite null values' => [
+ ['=', 'price_col', new CompositeExpression([null, null])], '"price_col" = ROW(:qp0, :qp1)', [':qp0' => null, ':qp1' => null],
+ ],
+ 'composite query' => [
+ ['=', 'price_col', new CompositeExpression(
+ (new Query(self::getDb()))->select('price')->from('product')->where(['id' => 1])
+ )],
+ '[[price_col]] = (SELECT [[price]] FROM [[product]] WHERE [[id]]=:qp0)',
+ [':qp0' => 1],
+ ],
+ 'composite query with type' => [
+ [
+ '=',
+ 'price_col',
+ new CompositeExpression(
+ (new Query(self::getDb()))->select('price')->from('product')->where(['id' => 1]),
+ 'currency_money_composite'
+ ),
+ ],
+ '[[price_col]] = (SELECT [[price]] FROM [[product]] WHERE [[id]]=:qp0)::currency_money_composite',
+ [':qp0' => 1],
+ ],
+ 'traversable objects are supported in composite' => [
+ ['=', 'price_col', new CompositeExpression(new TraversableObject([10, 'USD']))],
+ '[[price_col]] = ROW(:qp0, :qp1)',
+ [':qp0' => 10, ':qp1' => 'USD'],
+ ],
]
);
}
diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php
index c508c55eb..6ba76e4f4 100644
--- a/tests/SchemaTest.php
+++ b/tests/SchemaTest.php
@@ -579,4 +579,30 @@ public function testGetViewNames(): void
$db->close();
}
+
+ /** @dataProvider \Yiisoft\Db\Pgsql\Tests\Provider\CompositeTypeProvider::columns */
+ public function testCompositeTypeColumnSchema(array $columns, string $tableName): void
+ {
+ $this->testCompositeTypeColumnSchemaRecursive($columns, $tableName);
+ }
+
+ private function testCompositeTypeColumnSchemaRecursive(array $columns, string $tableName): void
+ {
+ $this->columnSchema($columns, $tableName);
+
+ $db = $this->getConnection(true);
+ $table = $db->getTableSchema($tableName, true);
+
+ foreach ($table->getColumns() as $name => $column) {
+ if ($column->getType() === 'composite') {
+ $this->assertTrue(
+ isset($columns[$name]['columns']),
+ "Columns of composite type `$name` do not exist, dbType is `{$column->getDbType()}`."
+ );
+ $this->testCompositeTypeColumnSchemaRecursive($columns[$name]['columns'], $column->getDbType());
+ }
+ }
+
+ $db->close();
+ }
}
diff --git a/tests/Support/ColumnSchemaBuilder.php b/tests/Support/ColumnSchemaBuilder.php
new file mode 100644
index 000000000..dbbb8de21
--- /dev/null
+++ b/tests/Support/ColumnSchemaBuilder.php
@@ -0,0 +1,35 @@
+type('decimal');
+ $column->dbType('numeric');
+ $column->phpType('double');
+ $column->precision($precision);
+ $column->scale($scale);
+ $column->defaultValue($defaultValue);
+
+ return $column;
+ }
+
+ public static function char(string $name, int|null $size, mixed $defaultValue = null): ColumnSchema
+ {
+ $column = new ColumnSchema($name);
+ $column->type('char');
+ $column->dbType('bpchar');
+ $column->phpType('string');
+ $column->size($size);
+ $column->defaultValue($defaultValue);
+
+ return $column;
+ }
+}
diff --git a/tests/Support/Fixture/pgsql.sql b/tests/Support/Fixture/pgsql.sql
index 4736e2629..6b786a9de 100644
--- a/tests/Support/Fixture/pgsql.sql
+++ b/tests/Support/Fixture/pgsql.sql
@@ -463,3 +463,27 @@ CREATE TABLE "table_uuid" (
"uuid" uuid NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),
"col" varchar(16)
);
+
+DROP TYPE IF EXISTS "currency_money_composite" CASCADE;
+DROP TYPE IF EXISTS "range_price_composite" CASCADE;
+DROP TABLE IF EXISTS "test_composite_type" CASCADE;
+
+CREATE TYPE "currency_money_composite" AS (
+ "value" numeric(10,2),
+ "currency_code" char(3)
+);
+
+CREATE TYPE "range_price_composite" AS (
+ "price_from" "currency_money_composite",
+ "price_to" "currency_money_composite"
+);
+
+CREATE TABLE "test_composite_type"
+(
+ "id" SERIAL NOT NULL PRIMARY KEY,
+ "price_col" "currency_money_composite",
+ "price_default" "currency_money_composite" DEFAULT '(5,USD)',
+ "price_array" "currency_money_composite"[] DEFAULT '{null,"(10.55,USD)","(-1,)"}',
+ "price_array2" "currency_money_composite"[][],
+ "range_price_col" "range_price_composite" DEFAULT '("(0,USD)","(100,USD)")'
+);