From 81a2b41668b4ac5e672f8276fdd90e96e40a145b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Chav=C3=A9e?= Date: Mon, 3 Aug 2026 15:08:44 +0200 Subject: [PATCH] Fix PostgresAdapter support for ARRAY and custom enum array columns --- src/Phinx/Db/Adapter/PostgresAdapter.php | 126 +++++++++++++++- .../Phinx/Db/Adapter/PostgresAdapterTest.php | 141 ++++++++++++++++++ 2 files changed, 261 insertions(+), 6 deletions(-) diff --git a/src/Phinx/Db/Adapter/PostgresAdapter.php b/src/Phinx/Db/Adapter/PostgresAdapter.php index e343db9c8..a404cbd20 100644 --- a/src/Phinx/Db/Adapter/PostgresAdapter.php +++ b/src/Phinx/Db/Adapter/PostgresAdapter.php @@ -523,7 +523,8 @@ public function getColumns(string $tableName): array ); $columnsInfo = $this->fetchAll($sql); foreach ($columnsInfo as $columnInfo) { - $isUserDefined = strtoupper(trim($columnInfo['data_type'])) === 'USER-DEFINED'; + $dataType = strtoupper(trim($columnInfo['data_type'])); + $isUserDefined = $dataType === 'USER-DEFINED'; $enumValues = null; if ($isUserDefined) { @@ -533,6 +534,14 @@ public function getColumns(string $tableName): array } else { $columnType = Literal::from($columnInfo['udt_name']); } + } elseif ($dataType === 'ARRAY') { + // information_schema reports array columns as data_type = ARRAY; + // the element type lives in udt_name (e.g. _int4, _text). + $columnType = $this->getPhinxArrayType( + $columnInfo['udt_name'], + $parts, + $columnInfo['column_name'], + ); } else { $columnType = $this->getPhinxType($columnInfo['data_type']); } @@ -1230,7 +1239,7 @@ public function getSqlType(Literal|string $type, ?int $limit = null): array return ['name' => 'geography', 'type' => 'polygon', 'srid' => 4326]; default: if ($this->isArrayType($type)) { - return ['name' => $type]; + return $this->getArraySqlType($type, $limit); } // Return array type throw new UnsupportedColumnTypeException('Column type `' . $type . '` is not supported by Postgresql.'); @@ -1252,6 +1261,7 @@ public function getPhinxType(string $sqlType): string return static::PHINX_TYPE_STRING; case 'character': case 'char': + case 'bpchar': return static::PHINX_TYPE_CHAR; case 'text': return static::PHINX_TYPE_TEXT; @@ -1260,6 +1270,7 @@ public function getPhinxType(string $sqlType): string case 'jsonb': return static::PHINX_TYPE_JSONB; case 'smallint': + case 'int2': return static::PHINX_TYPE_SMALL_INTEGER; case 'int': case 'int4': @@ -1275,6 +1286,7 @@ public function getPhinxType(string $sqlType): string case 'float4': return static::PHINX_TYPE_FLOAT; case 'double precision': + case 'float8': return static::PHINX_TYPE_DOUBLE; case 'bytea': return static::PHINX_TYPE_BINARY; @@ -1720,18 +1732,120 @@ public function isValidColumnType(Column $column): bool /** * Check if the given column is an array of a valid type. * + * Accepts arrays of Phinx types (`integer[]`) and native/custom PostgreSQL + * types such as user-defined enums (`my_status_enum[]`). + * * @param string|\Phinx\Util\Literal $columnType Column type * @return bool */ protected function isArrayType(string|Literal $columnType): bool { - if (!preg_match('/^([a-z]+)(?:\[\]){1,}$/', $columnType, $matches)) { - return false; + return $this->parseArrayType($columnType) !== null; + } + + /** + * Parse an array column type into its base name and `[]` suffix. + * + * @param string|\Phinx\Util\Literal $columnType Column type + * @return array{base: string, suffix: string}|null + */ + protected function parseArrayType(string|Literal $columnType): ?array + { + if (!preg_match('/^([a-z_][a-z0-9_]*)((?:\[\])+)$/', (string)$columnType, $matches)) { + return null; + } + + return [ + 'base' => $matches[1], + 'suffix' => $matches[2], + ]; + } + + /** + * Resolve a PostgreSQL array column type to a SQL type definition. + * + * Known Phinx base types are translated (e.g. `string[]` -> `character varying[]`). + * Native/custom PostgreSQL types are passed through unchanged (e.g. `my_enum[]`). + * + * @param string $type Array column type + * @param int|null $limit Limit for the base type, if applicable + * @return array + */ + protected function getArraySqlType(string $type, ?int $limit = null): array + { + $parsed = $this->parseArrayType($type); + if ($parsed === null) { + throw new UnsupportedColumnTypeException('Column type `' . $type . '` is not supported by Postgresql.'); } - $baseType = $matches[1]; + if (in_array($parsed['base'], $this->getColumnTypes(), true)) { + $baseType = $this->getSqlType($parsed['base'], $limit); + + return ['name' => $baseType['name'] . $parsed['suffix']]; + } + + return ['name' => $type]; + } + + /** + * Convert a PostgreSQL array udt_name into a Phinx array column type. + * + * PostgreSQL stores array element types with a leading underscore in + * information_schema.columns.udt_name (e.g. `_int4`, `_text`). Array + * dimensionality is read from pg_attribute.attndims. + * + * @param string $udtName PostgreSQL udt_name (e.g. _int4) + * @param array $parts Schema/table parts from getSchemaName() + * @param string $columnName Column name + * @return string|\Phinx\Util\Literal + */ + protected function getPhinxArrayType(string $udtName, array $parts, string $columnName): string|Literal + { + $baseUdt = str_starts_with($udtName, '_') ? substr($udtName, 1) : $udtName; + $dimensions = $this->getArrayDimensions($parts['schema'], $parts['table'], $columnName); + $suffix = str_repeat('[]', $dimensions); + $arrayType = $baseUdt . $suffix; + + try { + return $this->getPhinxType($baseUdt) . $suffix; + } catch (UnsupportedColumnTypeException) { + // Keep native/custom PostgreSQL types (enums, domains, etc.) as a + // plain array type string so addColumn/changeColumn round-trips work. + if ($this->isArrayType($arrayType)) { + return $arrayType; + } + + return Literal::from($arrayType); + } + } + + /** + * Return the number of array dimensions for a column (minimum 1). + * + * @param string $schema Schema name + * @param string $table Table name + * @param string $columnName Column name + * @return int + */ + protected function getArrayDimensions(string $schema, string $table, string $columnName): int + { + $sql = sprintf( + 'SELECT a.attndims + FROM pg_catalog.pg_namespace n + JOIN pg_catalog.pg_class c ON c.relnamespace = n.oid + JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid + WHERE n.nspname = %s + AND c.relname = %s + AND a.attname = %s + AND a.attnum > 0 + AND NOT a.attisdropped', + $this->getConnection()->quote($schema), + $this->getConnection()->quote($table), + $this->getConnection()->quote($columnName), + ); + $row = $this->fetchRow($sql); - return in_array($baseType, $this->getColumnTypes(), true); + return (int)($row['attndims'] ?? 1); } /** diff --git a/tests/Phinx/Db/Adapter/PostgresAdapterTest.php b/tests/Phinx/Db/Adapter/PostgresAdapterTest.php index d34d815a2..30dce8518 100644 --- a/tests/Phinx/Db/Adapter/PostgresAdapterTest.php +++ b/tests/Phinx/Db/Adapter/PostgresAdapterTest.php @@ -918,6 +918,81 @@ public function testAddColumnArrayType($column_name, $column_type) $this->assertTrue($table->hasColumn($column_name)); } + /** + * @dataProvider providerArrayType + */ + public function testGetColumnsReturnsArrayType($column_name, $column_type) + { + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn($column_name, $column_type) + ->save(); + + $columns = $this->adapter->getColumns('table1'); + $found = null; + foreach ($columns as $column) { + if ($column->getName() === $column_name) { + $found = $column; + break; + } + } + + $this->assertNotNull($found, sprintf('Column %s not found', $column_name)); + + // timestamp[] is introspected as datetime[] (same as non-array timestamp columns) + $expectedType = $column_type === 'timestamp[]' ? 'datetime[]' : $column_type; + $this->assertSame($expectedType, $found->getType()); + } + + public function testGetColumnsReturnsArrayTypeForNativePostgresArray() + { + $this->adapter->execute('CREATE TABLE table1 (id SERIAL NOT NULL PRIMARY KEY, tags varchar[], scores integer[][])'); + + $columns = $this->adapter->getColumns('table1'); + $byName = []; + foreach ($columns as $column) { + $byName[$column->getName()] = $column; + } + + $this->assertArrayHasKey('tags', $byName); + $this->assertArrayHasKey('scores', $byName); + $this->assertSame('string[]', $byName['tags']->getType()); + $this->assertSame('integer[][]', $byName['scores']->getType()); + } + + public function testCustomEnumArrayType() + { + $this->adapter->execute("CREATE TYPE custom_status_enum AS ENUM ('pending', 'approved', 'rejected')"); + + $table = new Table('table1', [], $this->adapter); + $table->save(); + $table->addColumn('statuses', 'custom_status_enum[]') + ->save(); + + $this->assertTrue($table->hasColumn('statuses')); + + $columns = $this->adapter->getColumns('table1'); + $found = null; + foreach ($columns as $column) { + if ($column->getName() === 'statuses') { + $found = $column; + break; + } + } + + $this->assertNotNull($found); + $this->assertSame('custom_status_enum[]', $found->getType()); + $this->assertSame( + ['name' => 'custom_status_enum[]'], + $this->adapter->getSqlType('custom_status_enum[]'), + ); + + // Round-trip: changeColumn using the introspected custom enum array type + $table->changeColumn('statuses', 'custom_status_enum[]', ['null' => false]) + ->save(); + $this->assertTrue($table->hasColumn('statuses')); + } + public function testAddColumnWithLiteralTypeAndDefault() { $table = new Table('table1', [], $this->adapter); @@ -2011,6 +2086,9 @@ public function testGetPhinxType() $this->assertEquals('integer', $this->adapter->getPhinxType('int4')); $this->assertEquals('integer', $this->adapter->getPhinxType('integer')); + $this->assertEquals('smallinteger', $this->adapter->getPhinxType('smallint')); + $this->assertEquals('smallinteger', $this->adapter->getPhinxType('int2')); + $this->assertEquals('biginteger', $this->adapter->getPhinxType('bigint')); $this->assertEquals('biginteger', $this->adapter->getPhinxType('int8')); @@ -2021,6 +2099,7 @@ public function testGetPhinxType() $this->assertEquals('float', $this->adapter->getPhinxType('float4')); $this->assertEquals('double', $this->adapter->getPhinxType('double precision')); + $this->assertEquals('double', $this->adapter->getPhinxType('float8')); $this->assertEquals('boolean', $this->adapter->getPhinxType('bool')); $this->assertEquals('boolean', $this->adapter->getPhinxType('boolean')); @@ -2028,6 +2107,10 @@ public function testGetPhinxType() $this->assertEquals('string', $this->adapter->getPhinxType('character varying')); $this->assertEquals('string', $this->adapter->getPhinxType('varchar')); + $this->assertEquals('char', $this->adapter->getPhinxType('character')); + $this->assertEquals('char', $this->adapter->getPhinxType('char')); + $this->assertEquals('char', $this->adapter->getPhinxType('bpchar')); + $this->assertEquals('text', $this->adapter->getPhinxType('text')); $this->assertEquals('time', $this->adapter->getPhinxType('time')); @@ -2045,6 +2128,64 @@ public function testGetPhinxType() $this->assertEquals('interval', $this->adapter->getPhinxType('interval')); } + public function providerPostgresInternalArrayUdt() + { + return [ + // sql column definition, expected information_schema.udt_name, expected Phinx type + ['smallints smallint[]', '_int2', 'smallinteger[]'], + ['ints integer[]', '_int4', 'integer[]'], + ['bigints bigint[]', '_int8', 'biginteger[]'], + ['floats real[]', '_float4', 'float[]'], + ['doubles double precision[]', '_float8', 'double[]'], + ['chars character(1)[]', '_bpchar', 'char[]'], + ['varchars character varying[]', '_varchar', 'string[]'], + ]; + } + + /** + * @dataProvider providerPostgresInternalArrayUdt + */ + public function testGetColumnsMapsPostgresInternalArrayUdtNames($columnSql, $expectedUdtName, $expectedPhinxType) + { + $this->adapter->execute(sprintf( + 'CREATE TABLE table1 (id SERIAL NOT NULL PRIMARY KEY, %s)', + $columnSql, + )); + + $columnName = explode(' ', $columnSql, 2)[0]; + + $meta = $this->adapter->fetchRow(sprintf( + "SELECT data_type, udt_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'table1' AND column_name = %s", + $this->adapter->getConnection()->quote($columnName), + )); + $this->assertSame('ARRAY', $meta['data_type']); + $this->assertSame($expectedUdtName, $meta['udt_name']); + + $columns = $this->adapter->getColumns('table1'); + $found = null; + foreach ($columns as $column) { + if ($column->getName() === $columnName) { + $found = $column; + break; + } + } + + $this->assertNotNull($found, sprintf('Column %s not found', $columnName)); + $this->assertSame($expectedPhinxType, $found->getType()); + } + + public function testGetSqlTypeTranslatesPhinxArrayBaseTypes() + { + $this->assertSame(['name' => 'smallint[]'], $this->adapter->getSqlType('smallinteger[]')); + $this->assertSame(['name' => 'character[]'], $this->adapter->getSqlType('char[]')); + $this->assertSame(['name' => 'double precision[]'], $this->adapter->getSqlType('double[]')); + $this->assertSame(['name' => 'character varying[]'], $this->adapter->getSqlType('string[]')); + $this->assertSame(['name' => 'real[]'], $this->adapter->getSqlType('float[]')); + $this->assertSame(['name' => 'timestamp[]'], $this->adapter->getSqlType('datetime[]')); + } + public function testCreateTableWithComment() { $tableComment = 'Table comment';