diff --git a/CHANGELOG.md b/CHANGELOG.md index 791bdee1..d3c1346e 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 23bfcce1..54a52e17 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 00000000..5a531b12 --- /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 db39d788..374f6543 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 00000000..56ba9e0b --- /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 00000000..5e2064df --- /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 604bbf37..1ed4a577 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 1cfbe77d..7c466186 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 8d3ec9a6..fc037d16 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 00000000..846c4253 --- /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 00000000..6e1bfb4b --- /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 00000000..a19ff986 --- /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 16d58b63..d015f812 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 c508c55e..6ba76e4f 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 00000000..dbbb8de2 --- /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 4736e262..6b786a9d 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)")' +);