From 4085be7f02c50042a036cb408dc484d6ae3c89cc Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 8 Mar 2026 22:00:57 +0100 Subject: [PATCH 1/5] Auto-generate foreign key constraint names when not provided When a foreign key is added without an explicit name, the MysqlAdapter and SqliteAdapter now generate a name following the pattern 'tablename_columnname' (e.g., 'articles_user_id' for a FK on the user_id column in the articles table). This matches the behavior of PostgresAdapter and SqlserverAdapter, which already auto-generate FK names. This ensures constraint names are always strings and prevents issues with constraint lookup methods that expect string names. --- src/Db/Adapter/MysqlAdapter.php | 11 +++++------ src/Db/Adapter/SqliteAdapter.php | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index a8aa1e8c..01dfe8c1 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -977,7 +977,7 @@ protected function getAddForeignKeyInstructions(TableMetadata $table, ForeignKey { $alter = sprintf( 'ADD %s', - $this->getForeignKeySqlDefinition($foreignKey), + $this->getForeignKeySqlDefinition($foreignKey, $table->getName()), ); return new AlterInstructions([$alter]); @@ -1192,14 +1192,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() ?: ($tableName . '_' . implode('_', $foreignKey->getColumns())); + $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName); $columnNames = []; foreach ($foreignKey->getColumns() as $column) { $columnNames[] = $this->quoteColumnName($column); diff --git a/src/Db/Adapter/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php index 9145e0cb..f78ff76b 100644 --- a/src/Db/Adapter/SqliteAdapter.php +++ b/src/Db/Adapter/SqliteAdapter.php @@ -1425,7 +1425,7 @@ protected function getAddForeignKeyInstructions(TableMetadata $table, ForeignKey $tableName = $table->getName(); $instructions->addPostStep(function ($state) use ($foreignKey, $tableName) { $this->execute('pragma foreign_keys = ON'); - $sql = substr($state['createSQL'], 0, -1) . ',' . $this->getForeignKeySqlDefinition($foreignKey) . '); '; + $sql = substr($state['createSQL'], 0, -1) . ',' . $this->getForeignKeySqlDefinition($foreignKey, $tableName) . '); '; //Delete indexes from original table and recreate them in temporary table $schema = $this->getSchemaName($tableName, true)['schema']; @@ -1670,14 +1670,13 @@ public function getColumnTypes(): array * Gets the SQLite 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() ?: ($tableName . '_' . implode('_', $foreignKey->getColumns())); + $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName); $columnNames = []; foreach ($foreignKey->getColumns() as $column) { $columnNames[] = $this->quoteColumnName($column); From 629c85e49aee95c9206fac2df58c1cb182dbbf5a Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 8 Mar 2026 22:01:50 +0100 Subject: [PATCH 2/5] Update test comparison files for new FK naming convention Update the expected FK names in comparison files and schema dumps to match the new auto-generated naming pattern (tablename_columnname). --- .../schema-dump-test_comparisons_mysql.lock | Bin 8605 -> 8607 bytes .../Diff/default/the_diff_default_mysql.php | 8 ++++---- .../Diff/simple/the_diff_simple_mysql.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/comparisons/Diff/default/schema-dump-test_comparisons_mysql.lock b/tests/comparisons/Diff/default/schema-dump-test_comparisons_mysql.lock index 10092ee02d595b356d45800afedae1bd4fa91e8b..340a2713d99cb3a2a61d65a07a2680a95329fec4 100644 GIT binary patch delta 57 zcmbR1Jl}akxsb4#l~Q6+NoI0RYH@sNacWU~X3FGy60(z*2`TU(%S|p6aNaB;+{p(3 DWl|Jx delta 62 zcmbR5JlAtable('articles') - ->dropForeignKey([], 'articles_ibfk_1') + ->dropForeignKey([], 'articles_user_id') ->removeIndexByName('UNIQUE_SLUG') ->removeIndexByName('rating_index') ->removeIndexByName('BY_NAME') @@ -86,7 +86,7 @@ public function up(): void ]) ->addIndex( $this->index('user_id') - ->setName('categories_ibfk_1') + ->setName('categories_user_id') ) ->addIndex( $this->index('name') @@ -101,7 +101,7 @@ public function up(): void ->setReferencedColumns('id') ->setOnDelete('RESTRICT') ->setOnUpdate('RESTRICT') - ->setName('categories_ibfk_1') + ->setName('categories_user_id') ) ->update(); @@ -234,7 +234,7 @@ public function down(): void ->setReferencedColumns('id') ->setOnDelete('CASCADE') ->setOnUpdate('CASCADE') - ->setName('articles_ibfk_1') + ->setName('articles_user_id') ) ->update(); diff --git a/tests/comparisons/Diff/simple/the_diff_simple_mysql.php b/tests/comparisons/Diff/simple/the_diff_simple_mysql.php index 93edeb6d..a9224032 100644 --- a/tests/comparisons/Diff/simple/the_diff_simple_mysql.php +++ b/tests/comparisons/Diff/simple/the_diff_simple_mysql.php @@ -72,7 +72,7 @@ public function up(): void ->setReferencedColumns('id') ->setOnDelete('RESTRICT') ->setOnUpdate('RESTRICT') - ->setName('articles_ibfk_1') + ->setName('articles_user_id') ) ->update(); } From 22eb54e695d05c5e6e1244bf52d3ce997d254430 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 8 Mar 2026 22:08:08 +0100 Subject: [PATCH 3/5] Update test comparison files with index rename operations The FK constraint name change from auto-generated (ibfk_N) to explicit (table_column) also affects the implicit index MySQL creates for FKs. Update comparison files to reflect the index rename from user_id to articles_user_id. --- .../schema-dump-test_comparisons_mysql.lock | Bin 8607 -> 8627 bytes .../Diff/default/the_diff_default_mysql.php | 10 ++++++++++ 2 files changed, 10 insertions(+) diff --git a/tests/comparisons/Diff/default/schema-dump-test_comparisons_mysql.lock b/tests/comparisons/Diff/default/schema-dump-test_comparisons_mysql.lock index 340a2713d99cb3a2a61d65a07a2680a95329fec4..9d56436ca2229477ac0d0b9eb70a113e273b0b84 100644 GIT binary patch delta 38 ucmbR5yxDn!t^lv0nUzvvQAuWUPHJ)dWCH=a$;X5OCV%G(-^?vIMF0Q>LJfKV delta 28 kcmdn&Jl}bPt^l*SmC|G@0lUdEJbaTs30Z7DA@EuN0EK1=l>h($ diff --git a/tests/comparisons/Diff/default/the_diff_default_mysql.php b/tests/comparisons/Diff/default/the_diff_default_mysql.php index 5c6e8dd7..bd3532e1 100644 --- a/tests/comparisons/Diff/default/the_diff_default_mysql.php +++ b/tests/comparisons/Diff/default/the_diff_default_mysql.php @@ -20,6 +20,7 @@ public function up(): void ->removeIndexByName('UNIQUE_SLUG') ->removeIndexByName('rating_index') ->removeIndexByName('BY_NAME') + ->removeIndexByName('user_id') ->update(); $this->table('articles') @@ -124,6 +125,10 @@ public function up(): void $this->index('slug') ->setName('UNIQUE_SLUG') ) + ->addIndex( + $this->index('user_id') + ->setName('articles_user_id') + ) ->addIndex( $this->index('category_id') ->setName('category_id') @@ -152,6 +157,7 @@ public function down(): void $this->table('articles') ->removeIndexByName('UNIQUE_SLUG') + ->removeIndexByName('articles_user_id') ->removeIndexByName('category_id') ->removeIndexByName('rating_index') ->update(); @@ -207,6 +213,10 @@ public function down(): void $this->index('name') ->setName('BY_NAME') ) + ->addIndex( + $this->index('user_id') + ->setName('user_id') + ) ->update(); $this->table('tags') From 2897bf25a8ebe7c7d5d77349cb04bb6aa59cd1ef Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 8 Mar 2026 22:14:41 +0100 Subject: [PATCH 4/5] Remove unnecessary index operations from comparison file With the FK naming changes, both the lock file and database have the index named articles_user_id, so no diff is needed for this index. Remove the index rename operations that were incorrectly added. --- .../Diff/default/the_diff_default_mysql.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/comparisons/Diff/default/the_diff_default_mysql.php b/tests/comparisons/Diff/default/the_diff_default_mysql.php index bd3532e1..5c6e8dd7 100644 --- a/tests/comparisons/Diff/default/the_diff_default_mysql.php +++ b/tests/comparisons/Diff/default/the_diff_default_mysql.php @@ -20,7 +20,6 @@ public function up(): void ->removeIndexByName('UNIQUE_SLUG') ->removeIndexByName('rating_index') ->removeIndexByName('BY_NAME') - ->removeIndexByName('user_id') ->update(); $this->table('articles') @@ -125,10 +124,6 @@ public function up(): void $this->index('slug') ->setName('UNIQUE_SLUG') ) - ->addIndex( - $this->index('user_id') - ->setName('articles_user_id') - ) ->addIndex( $this->index('category_id') ->setName('category_id') @@ -157,7 +152,6 @@ public function down(): void $this->table('articles') ->removeIndexByName('UNIQUE_SLUG') - ->removeIndexByName('articles_user_id') ->removeIndexByName('category_id') ->removeIndexByName('rating_index') ->update(); @@ -213,10 +207,6 @@ public function down(): void $this->index('name') ->setName('BY_NAME') ) - ->addIndex( - $this->index('user_id') - ->setName('user_id') - ) ->update(); $this->table('tags') From fc56383be59fd7a73631f9f127abb663fb8a5b62 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Wed, 11 Mar 2026 18:53:35 +0100 Subject: [PATCH 5/5] Add conflict resolution for auto-generated FK constraint names (#1042) * Add conflict resolution for auto-generated FK constraint names When auto-generating FK constraint names, check if the name already exists and append a counter suffix (_2, _3, etc.) if needed. This prevents duplicate constraint name errors when multiple FKs are created on the same columns with different references. * Remove unused variable * Truncate FK constraint names to max 128 characters Limit auto-generated foreign key constraint names to 125 characters to ensure the final name (including potential _XX counter suffix) stays within 128 characters. This prevents identifier length errors on databases with strict limits (MySQL: 64, PostgreSQL: 63). * Use database-specific identifier length limits - MySQL: 61 chars (64 limit - 3 for _XX suffix) - PostgreSQL: 60 chars (63 limit - 3 for _XX suffix) - SQL Server: 125 chars (128 limit - 3 for _XX suffix) - SQLite: No limit needed * Use IDENTIFIER_MAX_LENGTH class constant for clarity Each adapter now defines its database-specific identifier length limit as a class constant, making the code more self-documenting. --- src/Db/Adapter/MysqlAdapter.php | 36 ++++++++++++++++++++++++- src/Db/Adapter/PostgresAdapter.php | 41 +++++++++++++++++++++++++---- src/Db/Adapter/SqliteAdapter.php | 27 ++++++++++++++++++- src/Db/Adapter/SqlserverAdapter.php | 36 ++++++++++++++++++++++++- 4 files changed, 132 insertions(+), 8 deletions(-) diff --git a/src/Db/Adapter/MysqlAdapter.php b/src/Db/Adapter/MysqlAdapter.php index 01dfe8c1..694f821b 100644 --- a/src/Db/Adapter/MysqlAdapter.php +++ b/src/Db/Adapter/MysqlAdapter.php @@ -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[] */ @@ -1197,7 +1202,7 @@ protected function getIndexSqlDefinition(Index $index): string */ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string { - $constraintName = $foreignKey->getName() ?: ($tableName . '_' . implode('_', $foreignKey->getColumns())); + $constraintName = $foreignKey->getName() ?: $this->getUniqueForeignKeyName($tableName, $foreignKey->getColumns()); $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName); $columnNames = []; foreach ($foreignKey->getColumns() as $column) { @@ -1221,6 +1226,35 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta return $def; } + /** + * Generate a unique foreign key constraint name. + * + * @param string $tableName Table name + * @param array $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). * diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index 878f81ee..b0e61d22 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -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'; /** @@ -949,11 +954,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) . @@ -972,6 +973,36 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta return $def; } + /** + * Generate a unique foreign key constraint name. + * + * @param string $tableName Table name + * @param array $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 */ diff --git a/src/Db/Adapter/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php index f78ff76b..82a3812d 100644 --- a/src/Db/Adapter/SqliteAdapter.php +++ b/src/Db/Adapter/SqliteAdapter.php @@ -1675,7 +1675,7 @@ public function getColumnTypes(): array */ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string { - $constraintName = $foreignKey->getName() ?: ($tableName . '_' . implode('_', $foreignKey->getColumns())); + $constraintName = $foreignKey->getName() ?: $this->getUniqueForeignKeyName($tableName, $foreignKey->getColumns()); $def = ' CONSTRAINT ' . $this->quoteColumnName($constraintName); $columnNames = []; foreach ($foreignKey->getColumns() as $column) { @@ -1697,6 +1697,31 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta return $def; } + /** + * Generate a unique foreign key constraint name. + * + * @param string $tableName Table name + * @param array $columns Column names + * @return string + */ + protected function getUniqueForeignKeyName(string $tableName, array $columns): string + { + $baseName = $tableName . '_' . implode('_', $columns); + $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 */ diff --git a/src/Db/Adapter/SqlserverAdapter.php b/src/Db/Adapter/SqlserverAdapter.php index e804d590..67f08c00 100644 --- a/src/Db/Adapter/SqlserverAdapter.php +++ b/src/Db/Adapter/SqlserverAdapter.php @@ -28,6 +28,11 @@ */ class SqlserverAdapter extends AbstractAdapter { + /** + * Maximum length for identifiers (table names, column names, constraint names, etc.) + */ + protected const IDENTIFIER_MAX_LENGTH = 128; + /** * @var string[] */ @@ -864,7 +869,7 @@ protected function getIndexSqlDefinition(Index $index, string $tableName): strin */ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $tableName): string { - $constraintName = $foreignKey->getName() ?: $tableName . '_' . implode('_', $foreignKey->getColumns()); + $constraintName = $foreignKey->getName() ?: $this->getUniqueForeignKeyName($tableName, $foreignKey->getColumns()); $columnList = implode(', ', array_map($this->quoteColumnName(...), $foreignKey->getColumns())); $refColumnList = implode(', ', array_map($this->quoteColumnName(...), $foreignKey->getReferencedColumns())); @@ -881,6 +886,35 @@ protected function getForeignKeySqlDefinition(ForeignKey $foreignKey, string $ta return $def; } + /** + * Generate a unique foreign key constraint name. + * + * @param string $tableName Table name + * @param array $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; + } + /** * Creates the specified schema. *