diff --git a/src/Phinx/Db/Adapter/AdapterInterface.php b/src/Phinx/Db/Adapter/AdapterInterface.php index 12cf21d26..60107920b 100644 --- a/src/Phinx/Db/Adapter/AdapterInterface.php +++ b/src/Phinx/Db/Adapter/AdapterInterface.php @@ -70,12 +70,14 @@ interface AdapterInterface self::PHINX_TYPE_POLYGON, ]; - // only for mysql so far + // MySQL-specific types public const PHINX_TYPE_MEDIUM_INTEGER = 'mediuminteger'; - public const PHINX_TYPE_ENUM = 'enum'; public const PHINX_TYPE_SET = 'set'; public const PHINX_TYPE_YEAR = 'year'; + // Supported by MySQL and PostgreSQL + public const PHINX_TYPE_ENUM = 'enum'; + // only for postgresql so far public const PHINX_TYPE_CIDR = 'cidr'; public const PHINX_TYPE_INET = 'inet'; diff --git a/src/Phinx/Db/Adapter/PostgresAdapter.php b/src/Phinx/Db/Adapter/PostgresAdapter.php index 9dc0fe431..08c1649ec 100644 --- a/src/Phinx/Db/Adapter/PostgresAdapter.php +++ b/src/Phinx/Db/Adapter/PostgresAdapter.php @@ -42,6 +42,7 @@ class PostgresAdapter extends PdoAdapter self::PHINX_TYPE_MACADDR, self::PHINX_TYPE_INTERVAL, self::PHINX_TYPE_BINARYUUID, + self::PHINX_TYPE_ENUM, ]; private const GIN_INDEX_TYPE = 'gin'; @@ -277,7 +278,7 @@ public function createTable(Table $table, array $columns = [], array $indexes = $this->columnsWithComments = []; foreach ($columns as $column) { - $sql .= $this->quoteColumnName($column->getName()) . ' ' . $this->getColumnSqlDefinition($column); + $sql .= $this->quoteColumnName($column->getName()) . ' ' . $this->getColumnSqlDefinition($column, $table->getName()); if ($this->useIdentity && $column->getIdentity() && $column->getGenerated() !== null) { $sql .= sprintf(' GENERATED %s AS IDENTITY', $column->getGenerated()); } @@ -304,6 +305,26 @@ public function createTable(Table $table, array $columns = [], array $indexes = } $sql .= ')'; + + // Emit CREATE TYPE ... AS ENUM statements before the CREATE TABLE statement + foreach ($columns as $column) { + if ($column->getType() === static::PHINX_TYPE_ENUM) { + $values = $column->getValues(); + if (empty($values)) { + throw new InvalidArgumentException(sprintf( + 'Column "%s" is of type enum but has no values defined.', + $column->getName(), + )); + } + $typeName = $this->getEnumTypeName($table->getName(), $column->getName()); + $queries[] = sprintf( + 'CREATE TYPE %s AS ENUM (%s)', + $this->quoteColumnName($typeName), + implode(', ', array_map(fn($v) => $this->getConnection()->quote($v), $values)), + ); + } + } + $queries[] = $sql; // process column comments @@ -421,10 +442,26 @@ protected function getRenameTableInstructions(string $tableName, string $newTabl */ protected function getDropTableInstructions(string $tableName): AlterInstructions { + $parts = $this->getSchemaName($tableName); $this->removeCreatedTable($tableName); $sql = sprintf('DROP TABLE %s', $this->quoteTableName($tableName)); + $instructions = new AlterInstructions([], [$sql]); - return new AlterInstructions([], [$sql]); + // Drop any Phinx-managed enum types associated with this table's enum columns + foreach ($this->getColumns($tableName) as $column) { + if ($column->getType() !== static::PHINX_TYPE_ENUM) { + continue; + } + $typeName = $this->getEnumTypeName($tableName, $column->getName()); + if ($this->hasEnumType($typeName, $parts['schema'])) { + $instructions->addPostStep(sprintf( + 'DROP TYPE %s', + $this->quoteColumnName($typeName), + )); + } + } + + return $instructions; } /** @@ -462,9 +499,15 @@ public function getColumns(string $tableName): array $columnsInfo = $this->fetchAll($sql); foreach ($columnsInfo as $columnInfo) { $isUserDefined = strtoupper(trim($columnInfo['data_type'])) === 'USER-DEFINED'; + $enumValues = null; if ($isUserDefined) { - $columnType = Literal::from($columnInfo['udt_name']); + $enumValues = $this->getEnumTypeValues($columnInfo['udt_name'], $parts['schema']); + if ($enumValues !== null) { + $columnType = static::PHINX_TYPE_ENUM; + } else { + $columnType = Literal::from($columnInfo['udt_name']); + } } else { $columnType = $this->getPhinxType($columnInfo['data_type']); } @@ -512,6 +555,11 @@ public function getColumns(string $tableName): array } elseif ($columnType === self::PHINX_TYPE_DECIMAL) { $column->setPrecision($columnInfo['numeric_precision']); } + + if ($enumValues !== null) { + $column->setValues($enumValues); + } + $columns[] = $column; } @@ -544,10 +592,26 @@ public function hasColumn(string $tableName, string $columnName): bool protected function getAddColumnInstructions(Table $table, Column $column): AlterInstructions { $instructions = new AlterInstructions(); + + if ($column->getType() === static::PHINX_TYPE_ENUM) { + $values = $column->getValues(); + if (empty($values)) { + throw new InvalidArgumentException(sprintf( + 'Column "%s" is of type enum but has no values defined.', + $column->getName(), + )); + } + $typeName = $this->getEnumTypeName($table->getName(), $column->getName()); + $instructions->addPreStep(sprintf( + 'CREATE TYPE %s AS ENUM (%s)', + $this->quoteColumnName($typeName), + implode(', ', array_map(fn($v) => $this->getConnection()->quote($v), $values)), + )); + } $instructions->addAlter(sprintf( 'ADD %s %s %s', $this->quoteColumnName($column->getName()), - $this->getColumnSqlDefinition($column), + $this->getColumnSqlDefinition($column, $table->getName()), $column->isIdentity() && $column->getGenerated() !== null && $this->useIdentity ? sprintf('GENERATED %s AS IDENTITY', $column->getGenerated()) : '', )); @@ -614,7 +678,7 @@ protected function getChangeColumnInstructions( $sql = sprintf( 'ALTER COLUMN %s TYPE %s', $quotedColumnName, - $this->getColumnSqlDefinition($newColumn), + $this->getColumnSqlDefinition($newColumn, $tableName), ); if (in_array($newColumn->getType(), ['smallinteger', 'integer', 'biginteger'], true)) { $sql .= sprintf( @@ -735,7 +799,19 @@ protected function getDropColumnInstructions(string $tableName, string $columnNa $this->quoteColumnName($columnName), ); - return new AlterInstructions([$alter]); + $instructions = new AlterInstructions([$alter]); + + // Drop the associated Phinx-managed enum type if it exists + $parts = $this->getSchemaName($tableName); + $typeName = $this->getEnumTypeName($tableName, $columnName); + if ($this->hasEnumType($typeName, $parts['schema'])) { + $instructions->addPostStep(sprintf( + 'DROP TYPE %s', + $this->quoteColumnName($typeName), + )); + } + + return $instructions; } /** @@ -1098,6 +1174,8 @@ public function getSqlType(Literal|string $type, ?int $limit = null): array return ['name' => 'bytea']; case static::PHINX_TYPE_INTERVAL: return ['name' => 'interval']; + case static::PHINX_TYPE_ENUM: + return ['name' => 'enum']; // Geospatial database types // Spatial storage in Postgres is done via the PostGIS extension, // which enables the use of the "geography" type in combination @@ -1227,9 +1305,10 @@ public function dropDatabase($name): void * Gets the PostgreSQL Column Definition for a Column object. * * @param \Phinx\Db\Table\Column $column Column + * @param string|null $tableName Table name, used to derive the enum type name when column type is 'enum' * @return string */ - protected function getColumnSqlDefinition(Column $column): string + protected function getColumnSqlDefinition(Column $column, ?string $tableName = null): string { $buffer = []; @@ -1241,6 +1320,11 @@ protected function getColumnSqlDefinition(Column $column): string } else { $buffer[] = 'SERIAL'; } + } elseif ($column->getType() === static::PHINX_TYPE_ENUM) { + $typeName = $tableName !== null + ? $this->getEnumTypeName($tableName, $column->getName()) + : $column->getName(); + $buffer[] = $this->quoteColumnName($typeName); } elseif ($column->getType() instanceof Literal) { $buffer[] = (string)$column->getType(); } else { @@ -1295,6 +1379,70 @@ protected function getColumnSqlDefinition(Column $column): string return implode(' ', $buffer); } + /** + * Returns the Phinx-managed PostgreSQL enum type name for a given table column. + * + * The convention is `{table}_{column}` using the unqualified table name so that + * two tables cannot accidentally share the same type with different values. + * + * @param string $tableName Table name (may be schema-qualified) + * @param string $columnName Column name + * @return string + */ + protected function getEnumTypeName(string $tableName, string $columnName): string + { + return $this->getSchemaName($tableName)['table'] . '_' . $columnName; + } + + /** + * Returns the enum label values for a given PostgreSQL enum type name, or null if the + * type does not exist or is not an enum. + * + * @param string $typeName Type name (unqualified) + * @param string|null $schemaName Schema name (defaults to adapter schema) + * @return string[]|null + */ + protected function getEnumTypeValues(string $typeName, ?string $schemaName = null): ?array + { + $sql = sprintf( + "SELECT e.enumlabel + FROM pg_type t + JOIN pg_enum e ON t.oid = e.enumtypid + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE t.typname = %s AND t.typtype = 'e' AND n.nspname = %s + ORDER BY e.enumsortorder", + $this->getConnection()->quote($typeName), + $this->getConnection()->quote($schemaName ?? $this->schema), + ); + $rows = $this->fetchAll($sql); + + return !empty($rows) ? array_column($rows, 'enumlabel') : null; + } + + /** + * Returns true if a PostgreSQL enum type with the given unqualified name exists in the + * given schema. + * + * @param string $typeName Type name (unqualified) + * @param string|null $schemaName Schema name (defaults to adapter schema) + * @return bool + */ + protected function hasEnumType(string $typeName, ?string $schemaName = null): bool + { + $sql = sprintf( + "SELECT EXISTS( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE t.typname = %s AND t.typtype = 'e' AND n.nspname = %s + ) AS type_exists", + $this->getConnection()->quote($typeName), + $this->getConnection()->quote($schemaName ?? $this->schema), + ); + $result = $this->fetchRow($sql); + + return (bool)$result['type_exists']; + } + /** * Gets the PostgreSQL Column Comment Definition for a column object. * diff --git a/src/Phinx/Db/Table/Column.php b/src/Phinx/Db/Table/Column.php index 9ce50de3c..4131d04a2 100644 --- a/src/Phinx/Db/Table/Column.php +++ b/src/Phinx/Db/Table/Column.php @@ -40,14 +40,14 @@ class Column /** MySQL-only column type */ public const MEDIUMINTEGER = AdapterInterface::PHINX_TYPE_MEDIUM_INTEGER; /** MySQL-only column type */ - public const ENUM = AdapterInterface::PHINX_TYPE_ENUM; - /** MySQL-only column type */ public const SET = AdapterInterface::PHINX_TYPE_STRING; /** MySQL-only column type */ public const BLOB = AdapterInterface::PHINX_TYPE_BLOB; /** MySQL-only column type */ public const YEAR = AdapterInterface::PHINX_TYPE_YEAR; /** MySQL/Postgres-only column type */ + public const ENUM = AdapterInterface::PHINX_TYPE_ENUM; + /** MySQL/Postgres-only column type */ public const JSON = AdapterInterface::PHINX_TYPE_JSON; /** Postgres-only column type */ public const JSONB = AdapterInterface::PHINX_TYPE_JSONB; diff --git a/src/Phinx/Db/Util/AlterInstructions.php b/src/Phinx/Db/Util/AlterInstructions.php index 19f4a1962..444a2711d 100644 --- a/src/Phinx/Db/Util/AlterInstructions.php +++ b/src/Phinx/Db/Util/AlterInstructions.php @@ -16,6 +16,11 @@ */ class AlterInstructions { + /** + * @var (string|callable)[] The SQL commands to be executed before the ALTER instruction + */ + protected array $preSteps = []; + /** * @var string[] The SQL snippets to be added to an ALTER instruction */ @@ -48,6 +53,27 @@ public function __construct(array $alterParts = [], array $postSteps = []) $this->postSteps = $postSteps; } + /** + * Adds a SQL command to be executed before the ALTER instruction. + * + * @param string|callable $sql The SQL to run before, or a callable to execute + * @return void + */ + public function addPreStep(string|callable $sql): void + { + $this->preSteps[] = $sql; + } + + /** + * Returns the SQL commands to run before the ALTER instruction + * + * @return (string|callable)[] + */ + public function getPreSteps(): array + { + return $this->preSteps; + } + /** * Adds another part to the single ALTER instruction * @@ -146,6 +172,7 @@ public function getLock(): ?string */ public function merge(AlterInstructions $other): void { + $this->preSteps = array_merge($this->preSteps, $other->getPreSteps()); $this->alterParts = array_merge($this->alterParts, $other->getAlterParts()); $this->postSteps = array_merge($this->postSteps, $other->getPostSteps()); @@ -182,6 +209,15 @@ public function merge(AlterInstructions $other): void */ public function execute(string $alterTemplate, callable $executor): void { + foreach ($this->preSteps as $instruction) { + if (is_callable($instruction)) { + $instruction(); + continue; + } + + $executor($instruction); + } + if ($this->alterParts) { $alter = sprintf($alterTemplate, implode(', ', $this->alterParts)); $executor($alter); diff --git a/tests/Phinx/Db/Adapter/PostgresAdapterTest.php b/tests/Phinx/Db/Adapter/PostgresAdapterTest.php index d1db27e39..1a46d7b31 100644 --- a/tests/Phinx/Db/Adapter/PostgresAdapterTest.php +++ b/tests/Phinx/Db/Adapter/PostgresAdapterTest.php @@ -3048,4 +3048,425 @@ public function testPdoNotPersistentConnection() $adapter = new PostgresAdapter(PGSQL_DB_CONFIG); $this->assertFalse($adapter->getConnection()->getAttribute(PDO::ATTR_PERSISTENT)); } + + public function testGetSqlTypeEnum() + { + $this->assertEquals(['name' => 'enum'], $this->adapter->getSqlType('enum')); + } + + public function testEnumIsValidColumnType() + { + $col = new Column(); + $col->setType('enum'); + $this->assertTrue($this->adapter->isValidColumnType($col)); + } + + public function testCreateTableWithEnumColumn() + { + $table = new Table('moods', ['id' => false], $this->adapter); + $table->addColumn('current_mood', 'enum', [ + 'values' => ['sad', 'ok', 'happy'], + 'null' => false, + ])->save(); + + $this->assertTrue($this->adapter->hasTable('moods')); + $this->assertTrue($this->adapter->hasColumn('moods', 'current_mood')); + + // The PostgreSQL type should exist + $result = $this->adapter->fetchRow( + "SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = 'moods_current_mood' AND typtype = 'e') AS type_exists", + ); + $this->assertTrue((bool)$result['type_exists'], 'Enum type moods_current_mood should exist'); + } + + public function testCreateTableWithEnumColumnRoundTrip() + { + $values = ['pending', 'active', 'archived']; + + $table = new Table('orders', ['id' => false], $this->adapter); + $table->addColumn('status', 'enum', ['values' => $values, 'null' => false])->save(); + + $columns = $this->adapter->getColumns('orders'); + $statusColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'status') { + $statusColumn = $column; + break; + } + } + + $this->assertNotNull($statusColumn, 'Column status should exist'); + $this->assertEquals('enum', $statusColumn->getType()); + $this->assertEquals($values, $statusColumn->getValues()); + } + + public function testAddEnumColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $table->addColumn('mood', 'enum', [ + 'values' => ['happy', 'sad', 'neutral'], + 'null' => true, + ])->save(); + + $this->assertTrue($this->adapter->hasColumn('table1', 'mood')); + + // The type should have been created + $result = $this->adapter->fetchRow( + "SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = 'table1_mood' AND typtype = 'e') AS type_exists", + ); + $this->assertTrue((bool)$result['type_exists'], 'Enum type table1_mood should exist'); + } + + public function testAddEnumColumnRoundTrip() + { + $values = ['draft', 'published', 'deleted']; + + $table = new Table('articles', [], $this->adapter); + $table->save(); + $table->addColumn('state', 'enum', ['values' => $values, 'null' => false])->save(); + + $columns = $this->adapter->getColumns('articles'); + $stateColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'state') { + $stateColumn = $column; + break; + } + } + + $this->assertNotNull($stateColumn, 'Column state should exist'); + $this->assertEquals('enum', $stateColumn->getType()); + $this->assertEquals($values, $stateColumn->getValues()); + } + + public function testDropColumnDropsEnumType() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('status', 'enum', ['values' => ['on', 'off']])->save(); + + $this->assertTrue($this->adapter->hasColumn('table1', 'status')); + + $result = $this->adapter->fetchRow( + "SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = 'table1_status' AND typtype = 'e') AS type_exists", + ); + $this->assertTrue((bool)$result['type_exists'], 'Enum type should exist before drop'); + + $table->removeColumn('status')->save(); + + $this->assertFalse($this->adapter->hasColumn('table1', 'status')); + + $result = $this->adapter->fetchRow( + "SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = 'table1_status' AND typtype = 'e') AS type_exists", + ); + $this->assertFalse((bool)$result['type_exists'], 'Enum type should be dropped along with the column'); + } + + public function testDropTableDropsEnumTypes() + { + $table = new Table('notifications', ['id' => false], $this->adapter); + $table->addColumn('kind', 'enum', ['values' => ['email', 'sms', 'push'], 'null' => false]) + ->addColumn('state', 'enum', ['values' => ['queued', 'sent', 'failed'], 'null' => false]) + ->save(); + + $this->assertTrue($this->adapter->hasTable('notifications')); + + $table->drop()->save(); + + $this->assertFalse($this->adapter->hasTable('notifications')); + + foreach (['notifications_kind', 'notifications_state'] as $typeName) { + $result = $this->adapter->fetchRow(sprintf( + "SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = '%s' AND typtype = 'e') AS type_exists", + $typeName, + )); + $this->assertFalse((bool)$result['type_exists'], "Enum type $typeName should be dropped with the table"); + } + } + + public function testTwoTablesCanHaveEnumColumnsWithSameName() + { + $table1 = new Table('users', ['id' => false], $this->adapter); + $table1->addColumn('status', 'enum', ['values' => ['active', 'inactive']])->save(); + + $table2 = new Table('orders', ['id' => false], $this->adapter); + $table2->addColumn('status', 'enum', ['values' => ['pending', 'shipped', 'delivered']])->save(); + + // Each table gets its own enum type + foreach (['users_status' => ['active', 'inactive'], 'orders_status' => ['pending', 'shipped', 'delivered']] as $typeName => $expectedValues) { + $rows = $this->adapter->fetchAll(sprintf( + "SELECT e.enumlabel FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid + WHERE t.typname = '%s' ORDER BY e.enumsortorder", + $typeName, + )); + $this->assertEquals($expectedValues, array_column($rows, 'enumlabel'), "Values for $typeName should match"); + } + } + + public function testCreateTableWithEnumColumnWithSchema() + { + $this->adapter->createSchema('tschema'); + + $table = new Table('tschema.events', ['id' => false], $this->adapter); + $table->addColumn('level', 'enum', ['values' => ['info', 'warning', 'error'], 'null' => false])->save(); + + $this->assertTrue($this->adapter->hasTable('tschema.events')); + $this->assertTrue($this->adapter->hasColumn('tschema.events', 'level')); + } + + public function testCreateTableWithEnumColumnAndDefault() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('status', 'enum', [ + 'values' => ['pending', 'active', 'archived'], + 'null' => false, + 'default' => 'pending', + ])->save(); + + $this->assertTrue($this->adapter->hasColumn('table1', 'status')); + + $columns = $this->adapter->getColumns('table1'); + $statusColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'status') { + $statusColumn = $column; + break; + } + } + + $this->assertNotNull($statusColumn, 'Column status should exist'); + $this->assertEquals('enum', $statusColumn->getType()); + $this->assertEquals('pending', $statusColumn->getDefault()); + $this->assertFalse($statusColumn->isNull()); + } + + public function testNullableEnumColumnRoundTrip() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('priority', 'enum', [ + 'values' => ['low', 'medium', 'high'], + 'null' => true, + ])->save(); + + $columns = $this->adapter->getColumns('table1'); + $priorityColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'priority') { + $priorityColumn = $column; + break; + } + } + + $this->assertNotNull($priorityColumn, 'Column priority should exist'); + $this->assertEquals('enum', $priorityColumn->getType()); + $this->assertEquals(['low', 'medium', 'high'], $priorityColumn->getValues()); + $this->assertTrue($priorityColumn->isNull()); + } + + public function testDropNonEnumColumnKeepsEnumType() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('status', 'enum', ['values' => ['on', 'off']]) + ->addColumn('name', 'string') + ->save(); + + $this->assertTrue($this->adapter->hasColumn('table1', 'status')); + $this->assertTrue($this->adapter->hasColumn('table1', 'name')); + + // Drop the non-enum column + $table->removeColumn('name')->save(); + + $this->assertFalse($this->adapter->hasColumn('table1', 'name')); + $this->assertTrue($this->adapter->hasColumn('table1', 'status')); + + // The enum type should still exist + $result = $this->adapter->fetchRow( + "SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = 'table1_status' AND typtype = 'e') AS type_exists", + ); + $this->assertTrue((bool)$result['type_exists'], 'Enum type should not be dropped when removing a non-enum column'); + } + + public function testDropTableWithMixedEnumAndNonEnumColumns() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('status', 'enum', ['values' => ['on', 'off']]) + ->addColumn('name', 'string') + ->addColumn('age', 'integer') + ->save(); + + $table->drop()->save(); + + $this->assertFalse($this->adapter->hasTable('table1')); + + // Enum type should be cleaned up + $result = $this->adapter->fetchRow( + "SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = 'table1_status' AND typtype = 'e') AS type_exists", + ); + $this->assertFalse((bool)$result['type_exists'], 'Enum type should be dropped with the table'); + + // Non-enum columns should not leave orphaned types + $result = $this->adapter->fetchRow( + "SELECT EXISTS(SELECT 1 FROM pg_type WHERE typname = 'table1_name' AND typtype = 'e') AS type_exists", + ); + $this->assertFalse((bool)$result['type_exists'], 'No orphan type for non-enum column'); + } + + public function testGetColumnsWithEnumAndLiteralUserDefinedType() + { + // Create a table with both an enum column and a citext (USER-DEFINED, non-enum) column + $table = new Table('table1', ['id' => false], $this->adapter); + $table->addColumn('mood', 'enum', ['values' => ['happy', 'sad']]) + ->addColumn('label', Literal::from('citext')) + ->save(); + + $columns = $this->adapter->getColumns('table1'); + $this->assertCount(2, $columns); + + $moodColumn = null; + $labelColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'mood') { + $moodColumn = $column; + } + if ($column->getName() === 'label') { + $labelColumn = $column; + } + } + + // Enum column should come back as PHINX_TYPE_ENUM with values + $this->assertNotNull($moodColumn); + $this->assertEquals('enum', $moodColumn->getType()); + $this->assertEquals(['happy', 'sad'], $moodColumn->getValues()); + + // citext column should remain a Literal (USER-DEFINED non-enum) + $this->assertNotNull($labelColumn); + $this->assertInstanceOf(Literal::class, $labelColumn->getType()); + $this->assertEquals('citext', (string)$labelColumn->getType()); + } + + public function testAddEnumColumnWithDefaultValue() + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $table->addColumn('role', 'enum', [ + 'values' => ['admin', 'editor', 'viewer'], + 'null' => false, + 'default' => 'viewer', + ])->save(); + + $this->assertTrue($this->adapter->hasColumn('table1', 'role')); + + $columns = $this->adapter->getColumns('table1'); + $roleColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'role') { + $roleColumn = $column; + break; + } + } + + $this->assertNotNull($roleColumn, 'Column role should exist'); + $this->assertEquals('enum', $roleColumn->getType()); + $this->assertEquals(['admin', 'editor', 'viewer'], $roleColumn->getValues()); + $this->assertEquals('viewer', $roleColumn->getDefault()); + $this->assertFalse($roleColumn->isNull()); + } + + public function testChangeColumnPreservesEnumOnOtherColumns() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('status', 'enum', ['values' => ['active', 'inactive']]) + ->addColumn('name', 'string', ['limit' => 50]) + ->save(); + + // Change the non-enum column + $newColumn = new Column(); + $newColumn->setName('name') + ->setType('string') + ->setLimit(100); + + $table->changeColumn('name', $newColumn)->save(); + + // Verify the enum column is intact + $columns = $this->adapter->getColumns('table1'); + $statusColumn = null; + $nameColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'status') { + $statusColumn = $column; + } + if ($column->getName() === 'name') { + $nameColumn = $column; + } + } + + $this->assertNotNull($statusColumn, 'Column status should exist'); + $this->assertEquals('enum', $statusColumn->getType()); + $this->assertEquals(['active', 'inactive'], $statusColumn->getValues()); + + $this->assertNotNull($nameColumn, 'Column name should exist'); + $this->assertEquals('100', $nameColumn->getLimit()); + } + + public function testEnumValuesOrderIsPreserved() + { + $values = ['zebra', 'apple', 'mango', 'banana']; + + $table = new Table('table1', ['id' => false], $this->adapter); + $table->addColumn('fruit', 'enum', ['values' => $values])->save(); + + $columns = $this->adapter->getColumns('table1'); + $fruitColumn = null; + foreach ($columns as $column) { + if ($column->getName() === 'fruit') { + $fruitColumn = $column; + break; + } + } + + $this->assertNotNull($fruitColumn, 'Column fruit should exist'); + $this->assertSame($values, $fruitColumn->getValues(), 'Enum values should preserve insertion order'); + } + + public function testInsertDataWithEnumColumn() + { + $table = new Table('table1', [], $this->adapter); + $table->addColumn('status', 'enum', [ + 'values' => ['pending', 'active', 'closed'], + 'null' => false, + 'default' => 'pending', + ])->save(); + + $table->insert([ + ['status' => 'active'], + ['status' => 'closed'], + ])->save(); + + $rows = $this->adapter->fetchAll('SELECT status FROM table1 ORDER BY id'); + $this->assertCount(2, $rows); + $this->assertEquals('active', $rows[0]['status']); + $this->assertEquals('closed', $rows[1]['status']); + } + + public function testCreateTableWithEnumColumnWithoutValuesThrows() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Column "status" is of type enum but has no values defined.'); + + $table = new Table('table1', [], $this->adapter); + $table->addColumn('status', 'enum', [])->save(); + } + + public function testAddEnumColumnWithoutValuesThrows() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Column "role" is of type enum but has no values defined.'); + + $table = new Table('table1', [], $this->adapter); + $table->save(); + + $table->addColumn('role', 'enum', [])->save(); + } } diff --git a/tests/Phinx/Db/Util/AlterInstructionsTest.php b/tests/Phinx/Db/Util/AlterInstructionsTest.php new file mode 100644 index 000000000..7fcd5b6a7 --- /dev/null +++ b/tests/Phinx/Db/Util/AlterInstructionsTest.php @@ -0,0 +1,82 @@ +addPreStep('CREATE TYPE my_enum AS ENUM (\'a\', \'b\')'); + $instructions->addAlter('ADD my_col my_enum NOT NULL'); + + $executed = []; + $instructions->execute('ALTER TABLE t %s', function ($sql) use (&$executed) { + $executed[] = $sql; + }); + + $this->assertCount(2, $executed); + $this->assertEquals('CREATE TYPE my_enum AS ENUM (\'a\', \'b\')', $executed[0]); + $this->assertEquals('ALTER TABLE t ADD my_col my_enum NOT NULL', $executed[1]); + } + + public function testPreStepsWithCallable() + { + $instructions = new AlterInstructions(); + $called = false; + $instructions->addPreStep(function () use (&$called) { + $called = true; + }); + $instructions->addAlter('ADD col INTEGER'); + + $instructions->execute('ALTER TABLE t %s', function ($sql) { + }); + + $this->assertTrue($called, 'PreStep callable should have been invoked'); + } + + public function testGetPreSteps() + { + $instructions = new AlterInstructions(); + $this->assertEmpty($instructions->getPreSteps()); + + $instructions->addPreStep('CREATE TYPE t AS ENUM (\'x\')'); + $this->assertCount(1, $instructions->getPreSteps()); + } + + public function testMergeIncludesPreSteps() + { + $a = new AlterInstructions(); + $a->addPreStep('CREATE TYPE a AS ENUM (\'1\')'); + $a->addAlter('ADD col_a a NOT NULL'); + + $b = new AlterInstructions(); + $b->addPreStep('CREATE TYPE b AS ENUM (\'2\')'); + $b->addAlter('ADD col_b b NOT NULL'); + + $a->merge($b); + + $this->assertCount(2, $a->getPreSteps()); + $this->assertCount(2, $a->getAlterParts()); + } + + public function testExecutionOrder() + { + $instructions = new AlterInstructions(); + $instructions->addPreStep('PRE1'); + $instructions->addPreStep('PRE2'); + $instructions->addAlter('ALTER_PART'); + $instructions->addPostStep('POST1'); + + $executed = []; + $instructions->execute('ALTER TABLE t %s', function ($sql) use (&$executed) { + $executed[] = $sql; + }); + + $this->assertEquals(['PRE1', 'PRE2', 'ALTER TABLE t ALTER_PART', 'POST1'], $executed); + } +}