Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
34 changes: 34 additions & 0 deletions docs/en/upgrading.rst
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,40 @@ insertOrSkip() for Seeds
New ``insertOrSkip()`` method for seeds to insert records only if they don't already exist,
making seeds more idempotent.

Foreign Key Constraint Naming
=============================

Starting in 5.x, when you use ``addForeignKey()`` without providing an explicit constraint
name, migrations will auto-generate a name using the pattern ``{table}_{columns}``.

Previously, MySQL would auto-generate constraint names (like ``articles_ibfk_1``), while
PostgreSQL and SQL Server used migrations-generated names. Now all adapters use the same
consistent naming pattern.

**Impact on existing migrations:**

If you have existing migrations that use ``addForeignKey()`` without explicit names, and
later migrations that reference those constraints by name (e.g., in ``dropForeignKey()``),
the generated names may differ between old and new migrations. This could cause
``dropForeignKey()`` to fail if it's looking for a name that doesn't exist.

**Recommendations:**

1. For new migrations, you can rely on auto-generated names or provide explicit names
2. If you have rollback issues with existing migrations, you may need to update them
to use explicit constraint names
3. The auto-generated names include conflict resolution - if ``{table}_{columns}`` already
exists, a counter suffix is added (``_2``, ``_3``, etc.)

**Name length limits:**

Auto-generated names are truncated to respect database limits:

- MySQL: 61 characters (64 - 3 for counter suffix)
- PostgreSQL: 60 characters (63 - 3)
- SQL Server: 125 characters (128 - 3)
- SQLite: No limit

Migration File Compatibility
============================

Expand Down
102 changes: 98 additions & 4 deletions docs/en/writing-migrations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1284,7 +1284,15 @@ during index creation::
}
}

PostgreSQL adapters also supports Generalized Inverted Index ``gin`` indexes::
PostgreSQL Index Access Methods
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

PostgreSQL supports several index access methods beyond the default B-tree.
Use the ``type`` option to specify the access method.

**GIN (Generalized Inverted Index)**

GIN indexes are useful for full-text search, arrays, and JSONB columns::

<?php

Expand All @@ -1294,9 +1302,95 @@ PostgreSQL adapters also supports Generalized Inverted Index ``gin`` indexes::
{
public function change(): void
{
$table = $this->table('users');
$table->addColumn('address', 'string')
->addIndex('address', ['type' => 'gin'])
$table = $this->table('articles');
$table->addColumn('tags', 'jsonb')
->addIndex('tags', ['type' => 'gin'])
->create();
}
}

**GiST (Generalized Search Tree)**

GiST indexes support geometric data, range types, and full-text search.
For trigram similarity searches (requires the ``pg_trgm`` extension), use
the ``opclass`` option::

<?php

use Migrations\BaseMigration;

class MyNewMigration extends BaseMigration
{
public function change(): void
{
$table = $this->table('products');
$table->addColumn('name', 'string')
->addIndex('name', [
'type' => 'gist',
'opclass' => ['name' => 'gist_trgm_ops'],
])
->create();
}
}

**BRIN (Block Range Index)**

BRIN indexes are highly efficient for large, naturally-ordered tables like
time-series data. They are much smaller than B-tree indexes but only work well
when data is physically ordered by the indexed column::

<?php

use Migrations\BaseMigration;

class MyNewMigration extends BaseMigration
{
public function change(): void
{
$table = $this->table('sensor_readings');
$table->addColumn('recorded_at', 'timestamp')
->addColumn('value', 'decimal')
->addIndex('recorded_at', ['type' => 'brin'])
->create();
}
}

**SP-GiST (Space-Partitioned GiST)**

SP-GiST indexes work well for data with natural clustering, like IP addresses
or phone numbers::

<?php

use Migrations\BaseMigration;

class MyNewMigration extends BaseMigration
{
public function change(): void
{
$table = $this->table('access_logs');
$table->addColumn('client_ip', 'inet')
->addIndex('client_ip', ['type' => 'spgist'])
->create();
}
}

**Hash**

Hash indexes handle simple equality comparisons. They are rarely needed since
B-tree handles equality efficiently too::

<?php

use Migrations\BaseMigration;

class MyNewMigration extends BaseMigration
{
public function change(): void
{
$table = $this->table('sessions');
$table->addColumn('session_id', 'string', ['limit' => 64])
->addIndex('session_id', ['type' => 'hash'])
->create();
}
}
Expand Down
49 changes: 42 additions & 7 deletions src/Db/Adapter/MysqlAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
*/
class MysqlAdapter extends AbstractAdapter
{
/**
* Maximum length for identifiers (table names, column names, constraint names, etc.)
*/
protected const IDENTIFIER_MAX_LENGTH = 64;

/**
* @var string[]
*/
Expand Down Expand Up @@ -369,8 +374,10 @@ public function createTable(TableMetadata $table, array $columns = [], array $in
protected function mapColumnData(array $data): array
{
if ($data['type'] == self::TYPE_TEXT && $data['length'] !== null) {
// Accept both migrations TEXT_LONG and CakePHP LENGTH_LONG for backward compatibility
// with migrations generated before the fix (LENGTH_TINY/MEDIUM are already equal to TEXT_TINY/MEDIUM)
$data['length'] = match ($data['length']) {
self::TEXT_LONG => TableSchema::LENGTH_LONG,
self::TEXT_LONG, TableSchema::LENGTH_LONG => TableSchema::LENGTH_LONG,
self::TEXT_MEDIUM => TableSchema::LENGTH_MEDIUM,
self::TEXT_REGULAR => null,
self::TEXT_TINY => TableSchema::LENGTH_TINY,
Expand Down Expand Up @@ -977,7 +984,7 @@ protected function getAddForeignKeyInstructions(TableMetadata $table, ForeignKey
{
$alter = sprintf(
'ADD %s',
$this->getForeignKeySqlDefinition($foreignKey),
$this->getForeignKeySqlDefinition($foreignKey, $table->getName()),
);

return new AlterInstructions([$alter]);
Expand Down Expand Up @@ -1192,14 +1199,13 @@ protected function getIndexSqlDefinition(Index $index): string
* Gets the MySQL Foreign Key Definition for an ForeignKey object.
*
* @param \Migrations\Db\Table\ForeignKey $foreignKey Foreign key
* @param string $tableName Table name for auto-generating constraint name
* @return string
*/
protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string
protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string
{
$def = '';
if ($foreignKey->getName()) {
$def .= ' CONSTRAINT ' . $this->quoteColumnName((string)$foreignKey->getName());
}
$constraintName = $foreignKey->getName() ?: $this->getUniqueForeignKeyName($tableName, $foreignKey->getColumns());
$def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName);
$columnNames = [];
foreach ($foreignKey->getColumns() as $column) {
$columnNames[] = $this->quoteColumnName($column);
Expand All @@ -1222,6 +1228,35 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey): string
return $def;
}

/**
* Generate a unique foreign key constraint name.
*
* @param string $tableName Table name
* @param array<string> $columns Column names
* @return string
*/
protected function getUniqueForeignKeyName(string $tableName, array $columns): string
{
$baseName = $tableName . '_' . implode('_', $columns);
$maxLength = static::IDENTIFIER_MAX_LENGTH - 3;
if (strlen($baseName) > $maxLength) {
$baseName = substr($baseName, 0, $maxLength);
}
$existingKeys = $this->getForeignKeys($tableName);
$existingNames = array_column($existingKeys, 'name');

if (!in_array($baseName, $existingNames, true)) {
return $baseName;
}

$counter = 2;
while (in_array($baseName . '_' . $counter, $existingNames, true)) {
$counter++;
}

return $baseName . '_' . $counter;
}

/**
* Returns MySQL column types (inherited and MySQL specified).
*
Expand Down
80 changes: 66 additions & 14 deletions src/Db/Adapter/PostgresAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@

class PostgresAdapter extends AbstractAdapter
{
/**
* Maximum length for identifiers (table names, column names, constraint names, etc.)
*/
protected const IDENTIFIER_MAX_LENGTH = 63;

public const GENERATED_ALWAYS = 'ALWAYS';
public const GENERATED_BY_DEFAULT = 'BY DEFAULT';
/**
Expand All @@ -49,7 +54,18 @@ class PostgresAdapter extends AbstractAdapter
self::TYPE_NATIVE_UUID,
];

private const GIN_INDEX_TYPE = 'gin';
/**
* PostgreSQL index access methods that require USING clause.
*
* @var array<string>
*/
private const ACCESS_METHOD_TYPES = [
Index::GIN,
Index::GIST,
Index::SPGIST,
Index::BRIN,
Index::HASH,
];

/**
* Columns with comments
Expand Down Expand Up @@ -905,8 +921,16 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin
}

$order = $index->getOrder() ?? [];
$columnNames = array_map(function ($columnName) use ($order) {
$opclass = $index->getOpclass() ?? [];
$columnNames = array_map(function ($columnName) use ($order, $opclass) {
$ret = '"' . $columnName . '"';

// Add operator class if specified (e.g., gist_trgm_ops)
if (isset($opclass[$columnName])) {
$ret .= ' ' . $opclass[$columnName];
}

// Add ordering if specified (e.g., ASC NULLS FIRST)
if (isset($order[$columnName])) {
$ret .= ' ' . $order[$columnName];
}
Expand All @@ -917,23 +941,25 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin
$include = $index->getInclude();
$includedColumns = $include ? sprintf(' INCLUDE ("%s")', implode('","', $include)) : '';

$createIndexSentence = 'CREATE %sINDEX%s %s ON %s ';
if ($index->getType() === self::GIN_INDEX_TYPE) {
$createIndexSentence .= ' USING ' . $index->getType() . '(%s) %s;';
} else {
$createIndexSentence .= '(%s)%s%s;';
// Build USING clause for access method types (gin, gist, spgist, brin, hash)
$indexType = $index->getType();
$usingClause = '';
if (in_array($indexType, self::ACCESS_METHOD_TYPES, true)) {
$usingClause = ' USING ' . $indexType;
}

$where = (string)$index->getWhere();
if ($where) {
$where = ' WHERE ' . $where;
}

return sprintf(
$createIndexSentence,
$index->getType() === Index::UNIQUE ? 'UNIQUE ' : '',
'CREATE %sINDEX%s %s ON %s%s (%s)%s%s;',
$indexType === Index::UNIQUE ? 'UNIQUE ' : '',
$index->getConcurrently() ? ' CONCURRENTLY' : '',
$this->quoteColumnName((string)$indexName),
$this->quoteTableName($tableName),
$usingClause,
implode(',', $columnNames),
$includedColumns,
$where,
Expand All @@ -949,11 +975,7 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin
*/
protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string
{
$parts = $this->getSchemaName($tableName);

$constraintName = $foreignKey->getName() ?: (
$parts['table'] . '_' . implode('_', $foreignKey->getColumns()) . '_fkey'
);
$constraintName = $foreignKey->getName() ?: $this->getUniqueForeignKeyName($tableName, $foreignKey->getColumns());
$columnList = implode(', ', array_map($this->quoteColumnName(...), $foreignKey->getColumns()));
$refColumnList = implode(', ', array_map($this->quoteColumnName(...), $foreignKey->getReferencedColumns()));
$def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName) .
Expand All @@ -972,6 +994,36 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta
return $def;
}

/**
* Generate a unique foreign key constraint name.
*
* @param string $tableName Table name
* @param array<string> $columns Column names
* @return string
*/
protected function getUniqueForeignKeyName(string $tableName, array $columns): string
{
$parts = $this->getSchemaName($tableName);
$baseName = $parts['table'] . '_' . implode('_', $columns) . '_fkey';
$maxLength = static::IDENTIFIER_MAX_LENGTH - 3;
if (strlen($baseName) > $maxLength) {
$baseName = substr($baseName, 0, $maxLength);
}
$existingKeys = $this->getForeignKeys($tableName);
$existingNames = array_column($existingKeys, 'name');

if (!in_array($baseName, $existingNames, true)) {
return $baseName;
}

$counter = 2;
while (in_array($baseName . '_' . $counter, $existingNames, true)) {
$counter++;
}

return $baseName . '_' . $counter;
}

/**
* @inheritDoc
*/
Expand Down
Loading
Loading