diff --git a/.gitignore b/.gitignore index 3ff596fea..3d694002b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,11 +32,9 @@ composer.phar # phpunit itself is not needed phpunit.phar -# PHPUnit cache -/.phpunit.result.cache - -# local phpunit config +# local phpunit config and cache /phpunit.xml +/.phpunit.result.cache # NPM packages /node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 4854633b0..353c169cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Enh #284: Add tests for `binary` type and fix casting of default value (@Tigrov) - Bug #287: Fix `bit` type (@Tigrov) - Enh #289: Array parser refactoring (@Tigrov) +- Chg #288: Typecast refactoring (@Tigrov) ## 1.0.0 April 12, 2023 diff --git a/src/ColumnSchema.php b/src/ColumnSchema.php index c4d6e9e5b..f816b5004 100644 --- a/src/ColumnSchema.php +++ b/src/ColumnSchema.php @@ -16,13 +16,11 @@ use function array_walk_recursive; use function bindec; use function decbin; -use function in_array; use function is_array; use function is_int; use function is_string; use function json_decode; use function str_pad; -use function strtolower; /** * Represents the metadata of a column in a database table for PostgreSQL Server. @@ -72,11 +70,7 @@ final class ColumnSchema extends AbstractColumnSchema */ public function dbTypecast(mixed $value): mixed { - if ($value === null) { - return null; - } - - if ($value instanceof ExpressionInterface) { + if ($value === null || $value instanceof ExpressionInterface) { return $value; } @@ -84,20 +78,19 @@ public function dbTypecast(mixed $value): mixed return new ArrayExpression($value, $this->getDbType(), $this->dimension); } - if (in_array($this->getDbType(), [SchemaInterface::TYPE_JSON, SchemaInterface::TYPE_JSONB], true)) { - return new JsonExpression($value, $this->getDbType()); - } + return match ($this->getType()) { + SchemaInterface::TYPE_JSON => new JsonExpression($value, $this->getDbType()), - if (is_string($value) && $this->getType() === SchemaInterface::TYPE_BINARY) { - /** explicitly setup PDO param type for binary column */ - return new Param($value, PDO::PARAM_LOB); - } + SchemaInterface::TYPE_BINARY => is_string($value) + ? new Param($value, PDO::PARAM_LOB) // explicitly setup PDO param type for binary column + : $this->typecast($value), - if (is_int($value) && $this->getType() === Schema::TYPE_BIT) { - return str_pad(decbin($value), $this->getSize() ?? 0, '0', STR_PAD_LEFT); - } + Schema::TYPE_BIT => is_int($value) + ? str_pad(decbin($value), (int) $this->getSize(), '0', STR_PAD_LEFT) + : $this->typecast($value), - return $this->typecast($value); + default => $this->typecast($value), + }; } /** @@ -144,23 +137,21 @@ protected function phpTypecastValue(mixed $value): mixed return null; } - switch ($this->getType()) { - case Schema::TYPE_BIT: - return is_string($value) ? bindec($value) : $value; - case SchemaInterface::TYPE_BOOLEAN: - /** @psalm-var mixed $value */ - $value = is_string($value) ? strtolower($value) : $value; - - return match ($value) { - 't', 'true' => true, - 'f', 'false' => false, - default => (bool)$value, - }; - case SchemaInterface::TYPE_JSON: - return json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR); - } + return match ($this->getType()) { + Schema::TYPE_BIT => is_string($value) ? bindec($value) : $value, + + SchemaInterface::TYPE_BOOLEAN + => match ($value) { + 't' => true, + 'f' => false, + default => (bool) $value, + }, + + SchemaInterface::TYPE_JSON + => json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR), - return parent::phpTypecast($value); + default => parent::phpTypecast($value), + }; } /** diff --git a/src/Schema.php b/src/Schema.php index acfa2da5e..3c8932b0c 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -47,7 +47,7 @@ * column_comment: string|null, * modifier: int, * is_nullable: bool, - * column_default: mixed, + * column_default: string|null, * is_autoinc: bool, * sequence_name: string|null, * enum_values: array|string|null, @@ -532,30 +532,14 @@ protected function findConstraints(TableSchemaInterface $table): void /** @psalm-var array{array{tableName: string, columns: array}} $constraints */ $constraints = []; - /** - * @psalm-var array< - * array{ - * constraint_name: string, - * column_name: string, - * foreign_table_name: string, - * foreign_table_schema: string, - * foreign_column_name: string, - * } - * > $rows - */ + /** @psalm-var array $rows */ $rows = $this->db->createCommand($sql, [ ':schemaName' => $table->getSchemaName(), ':tableName' => $table->getName(), ])->queryAll(); foreach ($rows as $constraint) { - /** @psalm-var array{ - * constraint_name: string, - * column_name: string, - * foreign_table_name: string, - * foreign_table_schema: string, - * foreign_column_name: string, - * } $constraint */ + /** @psalm-var FindConstraintArray $constraint */ $constraint = $this->normalizeRowKeyCase($constraint, false); if ($constraint['foreign_table_schema'] !== $this->defaultSchema) { @@ -756,61 +740,21 @@ protected function findColumns(TableSchemaInterface $table): bool return false; } - /** @psalm-var array $column */ - foreach ($columns as $column) { - /** @psalm-var ColumnArray $column */ - $column = $this->normalizeRowKeyCase($column, false); + /** @psalm-var ColumnArray $info */ + foreach ($columns as $info) { + /** @psalm-var ColumnArray $info */ + $info = $this->normalizeRowKeyCase($info, false); - /** @psalm-var ColumnSchema $loadColumnSchema */ - $loadColumnSchema = $this->loadColumnSchema($column); + /** @psalm-var ColumnSchema $column */ + $column = $this->loadColumnSchema($info); - $table->column($loadColumnSchema->getName(), $loadColumnSchema); + $table->column($column->getName(), $column); - /** @psalm-var mixed $defaultValue */ - $defaultValue = $loadColumnSchema->getDefaultValue(); - - if ($loadColumnSchema->isPrimaryKey()) { - $table->primaryKey($loadColumnSchema->getName()); + if ($column->isPrimaryKey()) { + $table->primaryKey($column->getName()); if ($table->getSequenceName() === null) { - $table->sequenceName($loadColumnSchema->getSequenceName()); - } - - $loadColumnSchema->defaultValue(null); - } elseif ($defaultValue) { - if ( - is_string($defaultValue) && - in_array( - $loadColumnSchema->getType(), - [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME], - true - ) && - in_array( - strtoupper($defaultValue), - ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'], - true - ) - ) { - $loadColumnSchema->defaultValue(new Expression($defaultValue)); - } elseif ($loadColumnSchema->getType() === 'boolean') { - $loadColumnSchema->defaultValue($defaultValue === 'true'); - } elseif (is_string($defaultValue) && preg_match("/^B?'(.*?)'::/", $defaultValue, $matches)) { - if ($loadColumnSchema->getType() === 'binary' && str_starts_with($matches[1], '\\x')) { - $loadColumnSchema->defaultValue(hex2bin(substr($matches[1], 2))); - } else { - $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[1])); - } - } elseif ( - is_string($defaultValue) && - preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches) - ) { - if ($matches[2] === 'NULL') { - $loadColumnSchema->defaultValue(null); - } else { - $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($matches[2])); - } - } else { - $loadColumnSchema->defaultValue($loadColumnSchema->phpTypecast($defaultValue)); + $table->sequenceName($column->getSequenceName()); } } } @@ -821,27 +765,7 @@ protected function findColumns(TableSchemaInterface $table): bool /** * Loads the column information into a {@see ColumnSchemaInterface} object. * - * @psalm-param array{ - * table_schema: string, - * table_name: string, - * column_name: string, - * data_type: string, - * type_type: string|null, - * type_scheme: string|null, - * character_maximum_length: int, - * column_comment: string|null, - * modifier: int, - * is_nullable: bool, - * column_default: mixed, - * is_autoinc: bool, - * sequence_name: string|null, - * enum_values: array|string|null, - * numeric_precision: int|null, - * numeric_scale: int|null, - * size: string|null, - * is_pkey: bool|null, - * dimension: int - * } $info Column information. + * @psalm-param ColumnArray $info Column information. * * @return ColumnSchemaInterface The column schema object. */ @@ -852,16 +776,15 @@ protected function loadColumnSchema(array $info): ColumnSchemaInterface $column->autoIncrement($info['is_autoinc']); $column->comment($info['column_comment']); - if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true) - ) { + if (!in_array($info['type_scheme'], [$this->defaultSchema, 'pg_catalog'], true)) { $column->dbType($info['type_scheme'] . '.' . $info['data_type']); } else { $column->dbType($info['data_type']); } - $column->defaultValue($info['column_default']); - $column->enumValues(($info['enum_values'] !== null) - ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) : null); + $column->enumValues($info['enum_values'] !== null + ? explode(',', str_replace(["''"], ["'"], $info['enum_values'])) + : null); $column->unsigned(false); // has no meaning in PG $column->primaryKey((bool) $info['is_pkey']); $column->precision($info['numeric_precision']); @@ -871,35 +794,26 @@ protected function loadColumnSchema(array $info): ColumnSchemaInterface /** * pg_get_serial_sequence() doesn't track DEFAULT value change. - * * GENERATED BY IDENTITY columns always have a null default value. - * - * @psalm-var mixed $defaultValue */ - $defaultValue = $column->getDefaultValue(); - $sequenceName = $info['sequence_name'] ?? null; + $defaultValue = $info['column_default']; if ( - isset($defaultValue) && - is_string($defaultValue) && - preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1 + $defaultValue !== null + && preg_match("/nextval\\('\"?\\w+\"?\.?\"?\\w+\"?'(::regclass)?\\)/", $defaultValue) === 1 ) { $column->sequenceName(preg_replace( ['/nextval/', '/::/', '/regclass/', '/\'\)/', '/\(\'/'], '', $defaultValue )); - } elseif ($sequenceName !== null) { - $column->sequenceName($this->resolveTableName($sequenceName)->getFullName()); - } - - if (isset($this->typeMap[$column->getDbType() ?? ''])) { - $column->type($this->typeMap[$column->getDbType() ?? '']); - } else { - $column->type(self::TYPE_STRING); + } elseif ($info['sequence_name'] !== null) { + $column->sequenceName($this->resolveTableName($info['sequence_name'])->getFullName()); } + $column->type($this->typeMap[(string) $column->getDbType()] ?? self::TYPE_STRING); $column->phpType($this->getColumnPhpType($column)); + $column->defaultValue($this->normalizeDefaultValue($defaultValue, $column)); return $column; } @@ -920,6 +834,46 @@ protected function getColumnPhpType(ColumnSchemaInterface $column): string return parent::getColumnPhpType($column); } + /** + * Converts column's default value according to {@see ColumnSchema::phpType} after retrieval from the database. + * + * @param string|null $defaultValue The default value retrieved from the database. + * @param ColumnSchemaInterface $column The column schema object. + * + * @return mixed The normalized default value. + */ + private function normalizeDefaultValue(?string $defaultValue, ColumnSchemaInterface $column): mixed + { + if ($defaultValue === null || $column->isPrimaryKey()) { + return null; + } + + if ($column->getType() === self::TYPE_BOOLEAN && in_array($defaultValue, ['true', 'false'], true)) { + return $defaultValue === 'true'; + } + + if ( + in_array($column->getType(), [self::TYPE_TIMESTAMP, self::TYPE_DATE, self::TYPE_TIME], true) + && in_array(strtoupper($defaultValue), ['NOW()', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME'], true) + ) { + return new Expression($defaultValue); + } + + if (preg_match("/^B?'(.*?)'::/", $defaultValue, $matches) === 1) { + return $column->getType() === self::TYPE_BINARY && str_starts_with($matches[1], '\\x') + ? hex2bin(substr($matches[1], 2)) + : $column->phpTypecast($matches[1]); + } + + if (preg_match('/^(\()?(.*?)(?(1)\))(?:::.+)?$/', $defaultValue, $matches) === 1) { + return $matches[2] !== 'NULL' + ? $column->phpTypecast($matches[2]) + : null; + } + + return $column->phpTypecast($defaultValue); + } + /** * Loads multiple types of constraints and returns the specified ones. * diff --git a/tests/ColumnSchemaTest.php b/tests/ColumnSchemaTest.php index 7103c6921..de2fdab73 100644 --- a/tests/ColumnSchemaTest.php +++ b/tests/ColumnSchemaTest.php @@ -10,6 +10,7 @@ use Yiisoft\Db\Exception\Exception; use Yiisoft\Db\Exception\InvalidConfigException; use Yiisoft\Db\Expression\ArrayExpression; +use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Expression\JsonExpression; use Yiisoft\Db\Pgsql\ColumnSchema; use Yiisoft\Db\Pgsql\Tests\Support\TestTrait; @@ -109,7 +110,67 @@ public function testPhpTypeCastBool(): void $columnSchema->type('boolean'); - $this->assertFalse($columnSchema->phpTypeCast('false')); - $this->assertTrue($columnSchema->phpTypeCast('true')); + $this->assertFalse($columnSchema->phpTypeCast('f')); + $this->assertTrue($columnSchema->phpTypeCast('t')); + } + + public function testDbTypeCastJson(): void + { + $db = $this->getConnection(true); + $schema = $db->getSchema(); + $tableSchema = $schema->getTableSchema('type'); + + $this->assertEquals(new JsonExpression('', 'json'), $tableSchema->getColumn('json_col')->dbTypecast('')); + $this->assertEquals(new JsonExpression('', 'jsonb'), $tableSchema->getColumn('jsonb_col')->dbTypecast('')); + } + + public function testBoolDefault(): void + { + $db = $this->getConnection(true); + + $command = $db->createCommand(); + $schema = $db->getSchema(); + $tableSchema = $schema->getTableSchema('bool_values'); + $command->insert('bool_values', ['id' => new Expression('DEFAULT')]); + $command->execute(); + $query = (new Query($db))->from('bool_values')->one(); + + $this->assertNull($query['bool_col']); + $this->assertTrue($query['default_true']); + $this->assertTrue($query['default_qtrueq']); + $this->assertTrue($query['default_t']); + $this->assertTrue($query['default_yes']); + $this->assertTrue($query['default_on']); + $this->assertTrue($query['default_1']); + $this->assertFalse($query['default_false']); + $this->assertFalse($query['default_qfalseq']); + $this->assertFalse($query['default_f']); + $this->assertFalse($query['default_no']); + $this->assertFalse($query['default_off']); + $this->assertFalse($query['default_0']); + $this->assertSame( + [null, true, true, true, true, true, true, false, false, false, false, false, false], + $tableSchema->getColumn('default_array')->phpTypecast($query['default_array']) + ); + + $this->assertNull($tableSchema->getColumn('bool_col')->getDefaultValue()); + $this->assertTrue($tableSchema->getColumn('default_true')->getDefaultValue()); + $this->assertTrue($tableSchema->getColumn('default_qtrueq')->getDefaultValue()); + $this->assertTrue($tableSchema->getColumn('default_t')->getDefaultValue()); + $this->assertTrue($tableSchema->getColumn('default_yes')->getDefaultValue()); + $this->assertTrue($tableSchema->getColumn('default_on')->getDefaultValue()); + $this->assertTrue($tableSchema->getColumn('default_1')->getDefaultValue()); + $this->assertFalse($tableSchema->getColumn('default_false')->getDefaultValue()); + $this->assertFalse($tableSchema->getColumn('default_qfalseq')->getDefaultValue()); + $this->assertFalse($tableSchema->getColumn('default_f')->getDefaultValue()); + $this->assertFalse($tableSchema->getColumn('default_no')->getDefaultValue()); + $this->assertFalse($tableSchema->getColumn('default_off')->getDefaultValue()); + $this->assertFalse($tableSchema->getColumn('default_0')->getDefaultValue()); + $this->assertSame( + [null, true, true, true, true, true, true, false, false, false, false, false, false], + $tableSchema->getColumn('default_array')->getDefaultValue() + ); + + $db->close(); } } diff --git a/tests/Support/Fixture/pgsql.sql b/tests/Support/Fixture/pgsql.sql index 9f15b613d..dd990765f 100644 --- a/tests/Support/Fixture/pgsql.sql +++ b/tests/Support/Fixture/pgsql.sql @@ -167,8 +167,19 @@ CREATE TABLE "type" ( CREATE TABLE "bool_values" ( id serial not null primary key, bool_col bool, - default_true bool not null default true, - default_false boolean not null default false + default_true bool not null default TRUE, + default_qtrueq boolean not null default 'TRUE', + default_t boolean not null default 'T', + default_yes boolean not null default 'yes', + default_on boolean not null default 'on', + default_1 boolean not null default '1', + default_false boolean not null default FALSE, + default_qfalseq boolean not null default 'FALSE', + default_f boolean not null default 'F', + default_no boolean not null default 'no', + default_off boolean not null default 'off', + default_0 boolean not null default '0', + default_array boolean[] not null default '{null,TRUE,"TRUE",T,yes,on,1,FALSE,"FALSE",F,no,off,0}' ); CREATE TABLE "negative_default_values" (