diff --git a/CHANGELOG.md b/CHANGELOG.md index fa617d4d..54f40d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - Enh #387: Refactor `DMLQueryBuilder::upsert()` method (@Tigrov) - Chg #391: Update expression namespaces according to changes in `yiisoft/db` package (@Tigrov) - Chg #402: Throw exception on "unsigned" column usage (@vjik) +- New #404: Add enumeration column type support (@vjik) ## 1.2.0 March 21, 2024 diff --git a/src/Column/ColumnDefinitionBuilder.php b/src/Column/ColumnDefinitionBuilder.php index 81dea0d1..02ab0617 100644 --- a/src/Column/ColumnDefinitionBuilder.php +++ b/src/Column/ColumnDefinitionBuilder.php @@ -104,6 +104,7 @@ protected function getDbType(ColumnInterface $column): string ColumnType::ARRAY => 'json', ColumnType::STRUCTURED => 'json', ColumnType::JSON => 'json', + ColumnType::ENUM => 'varchar', default => 'varchar', }; } diff --git a/src/Column/ColumnFactory.php b/src/Column/ColumnFactory.php index 14ffe8d3..b93203f6 100644 --- a/src/Column/ColumnFactory.php +++ b/src/Column/ColumnFactory.php @@ -36,7 +36,6 @@ final class ColumnFactory extends AbstractColumnFactory 'numeric' => ColumnType::DECIMAL, 'char' => ColumnType::CHAR, 'varchar' => ColumnType::STRING, - 'enum' => ColumnType::STRING, 'tinytext' => ColumnType::TEXT, 'mediumtext' => ColumnType::TEXT, 'longtext' => ColumnType::TEXT, diff --git a/src/Schema.php b/src/Schema.php index b018339c..aa355d72 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -21,6 +21,7 @@ use function array_column; use function array_map; use function count; +use function in_array; use function strncasecmp; /** @@ -241,8 +242,10 @@ protected function loadTableDefaultValues(string $tableName): array */ protected function findColumns(TableSchemaInterface $table): bool { - $columns = $this->loadTableColumnsInfo($table->getName()); + $tableName = $table->getName(); + $columns = $this->loadTableColumnsInfo($tableName); $jsonColumns = $this->getJsonColumns($table); + $checks = $this->getTableChecks($tableName); foreach ($columns as $info) { if (in_array($info['name'], $jsonColumns, true)) { @@ -250,9 +253,9 @@ protected function findColumns(TableSchemaInterface $table): bool } $info['schema'] = $table->getSchemaName(); - $info['table'] = $table->getName(); + $info['table'] = $tableName; - $column = $this->loadColumn($info); + $column = $this->loadColumn($info, $checks); $table->column($info['name'], $column); } @@ -314,12 +317,13 @@ protected function findViewNames(string $schema = ''): array * Loads the column information into a {@see ColumnInterface} object. * * @param array $info The column information. + * @param Check[] $checks * * @return ColumnInterface The column object. * * @psalm-param ColumnInfo $info */ - private function loadColumn(array $info): ColumnInterface + private function loadColumn(array $info, array $checks): ColumnInterface { return $this->db->getColumnFactory()->fromDefinition($info['type'], [ 'defaultValueRaw' => $info['dflt_value'], @@ -328,6 +332,7 @@ private function loadColumn(array $info): ColumnInterface 'primaryKey' => (bool) $info['pk'], 'schema' => $info['schema'], 'table' => $info['table'], + 'values' => $this->tryGetEnumValuesFromCheck($info['name'], $checks), ]); } @@ -397,4 +402,47 @@ private function getJsonColumns(TableSchemaInterface $table): array return $result; } + + /** + * @param Check[] $checks + * + * @psalm-return list|null + */ + private function tryGetEnumValuesFromCheck(string $name, array $checks): ?array + { + if (empty($checks)) { + return null; + } + + foreach ($checks as $check) { + if ($this->isCheckNotStartsFromColumnName($check->expression, $name)) { + continue; + } + + preg_match_all( + "~(?expression, + $block, + ); + + if (empty($block[0][0])) { + continue; + } + + preg_match_all("~'((?:''|[^'])*)'~", $block[0][0], $matches); + + return array_map( + static fn($v) => str_replace("''", "'", $v), + $matches[1] ?? [], + ); + } + + return null; + } + + private function isCheckNotStartsFromColumnName(string $check, string $columnName): bool + { + $quotedColumnName = preg_quote($columnName, '~'); + return preg_match("~^(?:(?i:$quotedColumnName)|[\"`\[]{$quotedColumnName}[\"`\]])\s~", $check) !== 1; + } } diff --git a/tests/Column/EnumColumnTest.php b/tests/Column/EnumColumnTest.php new file mode 100644 index 00000000..0bc5b74a --- /dev/null +++ b/tests/Column/EnumColumnTest.php @@ -0,0 +1,124 @@ +dropTable('test_enum_table'); + $this->executeStatements( + <<getSharedConnection(); + $column = $db->getTableSchema('test_enum_table')->getColumn('status'); + + $this->assertNotInstanceOf(EnumColumn::class, $column); + + $this->dropTable('test_enum_table'); + } + + #[TestWith([ + 'knot', + "TEXT CHECK (knot IN ('a', 'b'))", + ['a', 'b'], + ])] + #[TestWith([ + 'status', + "TEXT CHECK (status in ('a', 'b'))", + ['a', 'b'], + ])] + #[TestWith([ + 'letter', + "TEXT CHECK (letter IN ('a', 'b'))", + ['a', 'b'], + ])] + #[TestWith([ + 'letter', + "TEXT CHECK (LETTER IN ('a', 'b'))", + ['a', 'b'], + ])] + #[TestWith([ + 'letter', + "TEXT CHECK (`letter` IN ('a', 'b'))", + ['a', 'b'], + ])] + #[TestWith([ + 'letter', + "TEXT CHECK ([letter] IN ('a', 'b'))", + ['a', 'b'], + ])] + #[TestWith([ + 'letter', + "TEXT CHECK (\"letter\" IN ('a', 'b'))", + ['a', 'b'], + ])] + #[TestWith([ + 'status', + "TEXT CHECK (status IN\n(\n'a',\n'b'\n))", + ['a', 'b'], + ])] + public function testEnumCheck(string $columnName, string $columnDefinition, array $expectedValues): void + { + $this->dropTable('test_enum_table'); + + $quotedColumnName = $this->getSharedConnection()->getQuoter()->quoteColumnName($columnName); + $this->executeStatements( + <<getSharedConnection(); + $column = $db->getTableSchema('test_enum_table')->getColumn($columnName); + + $this->assertInstanceOf(EnumColumn::class, $column, $column::class); + $this->assertEqualsCanonicalizing($expectedValues, $column->getValues()); + + $this->dropTable('test_enum_table'); + } + + protected function createDatabaseObjectsStatements(): array + { + return [ + <<