Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 120 additions & 6 deletions src/Phinx/Db/Adapter/PostgresAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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']);
}
Expand Down Expand Up @@ -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.');
Expand All @@ -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;
Expand All @@ -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':
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
141 changes: 141 additions & 0 deletions tests/Phinx/Db/Adapter/PostgresAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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'));

Expand All @@ -2021,13 +2099,18 @@ 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'));

$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'));
Expand All @@ -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';
Expand Down