From a82447defaacabdc12ea72cf5f8bc93654ea9ba9 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 3 Nov 2025 04:32:51 +0100 Subject: [PATCH 1/3] Support baking references. --- src/Command/BakeMigrationCommand.php | 15 ++- src/Util/ColumnParser.php | 62 ++++++++++ templates/bake/config/skeleton.twig | 36 ++++++ .../Command/BakeMigrationCommandTest.php | 72 +++++++++++ tests/TestCase/Util/ColumnParserTest.php | 112 ++++++++++++++++++ .../Migration/testAddFieldWithReference.php | 34 ++++++ .../Migration/testCreateWithReferences.php | 39 ++++++ .../testCreateWithReferencesCustomTable.php | 39 ++++++ 8 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 tests/comparisons/Migration/testAddFieldWithReference.php create mode 100644 tests/comparisons/Migration/testCreateWithReferences.php create mode 100644 tests/comparisons/Migration/testCreateWithReferencesCustomTable.php diff --git a/src/Command/BakeMigrationCommand.php b/src/Command/BakeMigrationCommand.php index 829166fc7..789802803 100644 --- a/src/Command/BakeMigrationCommand.php +++ b/src/Command/BakeMigrationCommand.php @@ -83,6 +83,7 @@ public function templateData(Arguments $arguments): array $fields = $columnParser->parseFields($args); $indexes = $columnParser->parseIndexes($args); $primaryKey = $columnParser->parsePrimaryKey($args); + $foreignKeys = $columnParser->parseForeignKeys($args); $action = $this->detectAction($className); @@ -119,6 +120,7 @@ public function templateData(Arguments $arguments): array 'indexes' => $indexes, 'primaryKey' => $primaryKey, ], + 'constraints' => $foreignKeys, 'name' => $className, 'backend' => Configure::read('Migrations.backend', 'builtin'), ]; @@ -169,7 +171,7 @@ public function getOptionParser(): ConsoleOptionParser When describing columns you can use the following syntax: -{name}:{primary}{type}{nullable}[{length}]:{index} +{name}:{primary}{type}{nullable}[{length}]:{index}:{indexName} All sections other than name are optional. @@ -179,6 +181,9 @@ public function getOptionParser(): ConsoleOptionParser * Length option must be enclosed in [], for example: name:string?[100]. * The index attribute can define the column as having a unique key with unique or a primary key with primary. +* Use references type to create a foreign key constraint. + e.x. category_id:references (auto-infers table as 'categories') + or category_id:references:custom_table to specify the referenced table. Examples @@ -198,6 +203,14 @@ public function getOptionParser(): ConsoleOptionParser Create a migration that adds (name VARCHAR(128) and a UNIQUE<.warning index) to the projects table. +bin/cake bake migration CreatePosts title:string user_id:references +Create a migration that creates the posts table with a foreign key +constraint on user_id referencing the users table. + +bin/cake bake migration AddCategoryIdToArticles category_id:references:categories +Create a migration that adds a foreign key column (category_id) to the articles +table referencing the categories table. + TEXT; $parser->setDescription($text); diff --git a/src/Util/ColumnParser.php b/src/Util/ColumnParser.php index 21fe4bc9f..02a1b5ee8 100644 --- a/src/Util/ColumnParser.php +++ b/src/Util/ColumnParser.php @@ -5,6 +5,7 @@ use Cake\Collection\Collection; use Cake\Utility\Hash; +use Cake\Utility\Inflector; use Migrations\Db\Adapter\AdapterInterface; use ReflectionClass; @@ -64,6 +65,13 @@ public function parseFields(array $arguments): array $type = 'primary'; } } + + // Handle references - convert to integer type + $isReference = in_array($type, ['references', 'references?'], true); + if ($isReference) { + $type = str_contains($type, '?') ? 'integer?' : 'integer'; + } + $nullable = (bool)strpos($type, '?'); $type = $nullable ? str_replace('?', '', $type) : $type; @@ -109,6 +117,11 @@ public function parseIndexes(array $arguments): array $indexType = Hash::get($matches, 3); $indexName = Hash::get($matches, 4); + // Skip references - they create foreign keys, not indexes + if ($type && str_starts_with($type, 'references')) { + continue; + } + if ( in_array($type, ['primary', 'primary_key'], true) || in_array($indexType, ['primary', 'primary_key'], true) || @@ -168,6 +181,55 @@ public function parsePrimaryKey(array $arguments): array return $primaryKey; } + /** + * Parses a list of arguments into an array of foreign key constraints + * + * @param array $arguments A list of arguments being parsed + * @return array + */ + public function parseForeignKeys(array $arguments): array + { + $foreignKeys = []; + $arguments = $this->validArguments($arguments); + + foreach ($arguments as $field) { + preg_match($this->regexpParseColumn, $field, $matches); + $fieldName = $matches[1]; + $type = Hash::get($matches, 2, ''); + $indexType = Hash::get($matches, 3); + $indexName = Hash::get($matches, 4); + + // Check if type is 'references' or 'references?' + $isReference = str_starts_with($type, 'references'); + if (!$isReference) { + continue; + } + + // Determine referenced table + // If indexType is provided, use it as the referenced table name + // Otherwise, infer from field name (e.g., category_id -> categories) + $referencedTable = $indexType; + if (!$referencedTable) { + // Remove common suffixes like _id and pluralize + $referencedTable = preg_replace('/_id$/', '', $fieldName); + $referencedTable = Inflector::pluralize($referencedTable); + } + + // Generate constraint name + $constraintName = $indexName ?: 'fk_' . $fieldName; + + $foreignKeys[$constraintName] = [ + 'type' => 'foreign', + 'columns' => [$fieldName], + 'references' => [$referencedTable, 'id'], + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ]; + } + + return $foreignKeys; + } + /** * Returns a list of only valid arguments * diff --git a/templates/bake/config/skeleton.twig b/templates/bake/config/skeleton.twig index 5246eee89..d428deae0 100644 --- a/templates/bake/config/skeleton.twig +++ b/templates/bake/config/skeleton.twig @@ -78,6 +78,42 @@ class {{ name }} extends AbstractMigration Migration.stringifyList(columns['primaryKey'], {'indent': 3}) | raw }}]); {%~ endif %} + {%~ if constraints is defined and constraints is not empty %} + {%~ for constraintName, constraint in constraints %} + {%~ if constraint['type'] == 'foreign' %} + {%~ set columnsList = '\'' ~ constraint['columns'][0] ~ '\'' %} + {%~ if constraint['columns']|length > 1 %} + {%~ set columnsList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': 3}) ~ ']' %} + {%~ endif %} + {%~ if constraint['references'][1] is iterable %} + {%~ set columnsReference = '[' ~ Migration.stringifyList(constraint['references'][1], {'indent': 3}) ~ ']' %} + {%~ else %} + {%~ set columnsReference = '\'' ~ constraint['references'][1] ~ '\'' %} + {%~ endif %} + {%~ if backend == 'builtin' %} + $table->addForeignKey( + $this->foreignKey({{ columnsList | raw }}) + ->setReferencedTable('{{ constraint['references'][0] }}') + ->setReferencedColumns({{ columnsReference | raw }}) + ->setOnDelete('{{ Migration.formatConstraintAction(constraint['delete']) | raw }}') + ->setOnUpdate('{{ Migration.formatConstraintAction(constraint['update']) | raw }}') + ->setName('{{ constraintName }}') + ); + {%~ else %} + $table->addForeignKey( + {{ columnsList | raw }}, + '{{ constraint['references'][0] }}', + {{ columnsReference | raw }}, + [ + 'update' => '{{ Migration.formatConstraintAction(constraint['update']) | raw }}', + 'delete' => '{{ Migration.formatConstraintAction(constraint['delete']) | raw }}', + 'constraint' => '{{ constraintName }}' + ] + ); + {%~ endif %} + {%~ endif %} + {%~ endfor %} + {%~ endif %} {%~ endif %} {% endif %} $table->{{ tableMethod }}(){% if tableMethod == 'drop' %}->save(){% endif %}; diff --git a/tests/TestCase/Command/BakeMigrationCommandTest.php b/tests/TestCase/Command/BakeMigrationCommandTest.php index 505ec595b..f0d8279da 100644 --- a/tests/TestCase/Command/BakeMigrationCommandTest.php +++ b/tests/TestCase/Command/BakeMigrationCommandTest.php @@ -56,6 +56,27 @@ public function tearDown(): void unlink($file); } } + + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Posts.php'); + if ($files) { + foreach ($files as $file) { + unlink($file); + } + } + + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Articles.php'); + if ($files) { + foreach ($files as $file) { + unlink($file); + } + } + + $files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Products.php'); + if ($files) { + foreach ($files as $file) { + unlink($file); + } + } } /** @@ -387,6 +408,57 @@ public function testActionWithoutValidPrefix() $this->assertErrorContains('When applying fields the migration name should start with one of the following prefixes: `Create`, `Drop`, `Add`, `Remove`, `Alter`.'); } + /** + * Test creating migration with references (foreign keys) + * + * @return void + */ + public function testCreateWithReferences() + { + $this->exec('bake migration CreatePosts title:string user_id:references --connection test'); + + $file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreatePosts.php'); + $filePath = current($file); + + $this->assertExitCode(BaseCommand::CODE_SUCCESS); + $result = file_get_contents($filePath); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + + /** + * Test creating migration with references to specific table + * + * @return void + */ + public function testCreateWithReferencesCustomTable() + { + $this->exec('bake migration CreateArticles title:string author_id:references:authors --connection test'); + + $file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreateArticles.php'); + $filePath = current($file); + + $this->assertExitCode(BaseCommand::CODE_SUCCESS); + $result = file_get_contents($filePath); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + + /** + * Test adding a field with reference + * + * @return void + */ + public function testAddFieldWithReference() + { + $this->exec('bake migration AddCategoryIdToProducts category_id:references --connection test'); + + $file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_AddCategoryIdToProducts.php'); + $filePath = current($file); + + $this->assertExitCode(BaseCommand::CODE_SUCCESS); + $result = file_get_contents($filePath); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + public function testBakeMigrationWithoutBake() { // Make sure to unload the Bake plugin diff --git a/tests/TestCase/Util/ColumnParserTest.php b/tests/TestCase/Util/ColumnParserTest.php index efe383fc8..1a31d6717 100644 --- a/tests/TestCase/Util/ColumnParserTest.php +++ b/tests/TestCase/Util/ColumnParserTest.php @@ -367,4 +367,116 @@ public function testGetIndexName() $this->assertSame('PRIMARY', $this->columnParser->getIndexName('id', 'primary', null, false)); $this->assertSame('PRIMARY', $this->columnParser->getIndexName('id', 'primary', null, true)); } + + public function testParseFieldsWithReferences() + { + // Test basic references - should convert to integer + $expected = [ + 'user_id' => [ + 'columnType' => 'integer', + 'options' => [ + 'null' => false, + 'default' => null, + 'limit' => 11, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['user_id:references']); + $this->assertEquals($expected, $actual); + + // Test nullable references + $expected = [ + 'category_id' => [ + 'columnType' => 'integer', + 'options' => [ + 'null' => true, + 'default' => null, + 'limit' => 11, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['category_id:references?']); + $this->assertEquals($expected, $actual); + + // Test references with explicit table name + $expected = [ + 'category_id' => [ + 'columnType' => 'integer', + 'options' => [ + 'null' => false, + 'default' => null, + 'limit' => 11, + ], + ], + ]; + $actual = $this->columnParser->parseFields(['category_id:references:categories']); + $this->assertEquals($expected, $actual); + } + + public function testParseForeignKeys() + { + // Test basic reference - infer table name from field + $expected = [ + 'fk_user_id' => [ + 'type' => 'foreign', + 'columns' => ['user_id'], + 'references' => ['users', 'id'], + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ], + ]; + $actual = $this->columnParser->parseForeignKeys(['user_id:references']); + $this->assertEquals($expected, $actual); + + // Test reference with explicit table name + $expected = [ + 'fk_category_id' => [ + 'type' => 'foreign', + 'columns' => ['category_id'], + 'references' => ['custom_categories', 'id'], + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ], + ]; + $actual = $this->columnParser->parseForeignKeys(['category_id:references:custom_categories']); + $this->assertEquals($expected, $actual); + + // Test reference with custom constraint name + $expected = [ + 'custom_fk' => [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ], + ]; + $actual = $this->columnParser->parseForeignKeys(['author_id:references:authors:custom_fk']); + $this->assertEquals($expected, $actual); + + // Test multiple foreign keys + $expected = [ + 'fk_user_id' => [ + 'type' => 'foreign', + 'columns' => ['user_id'], + 'references' => ['users', 'id'], + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ], + 'fk_category_id' => [ + 'type' => 'foreign', + 'columns' => ['category_id'], + 'references' => ['categories', 'id'], + 'update' => 'CASCADE', + 'delete' => 'CASCADE', + ], + ]; + $actual = $this->columnParser->parseForeignKeys(['user_id:references', 'category_id:references']); + $this->assertEquals($expected, $actual); + + // Test that non-reference fields are ignored + $expected = []; + $actual = $this->columnParser->parseForeignKeys(['name:string', 'age:integer']); + $this->assertEquals($expected, $actual); + } } diff --git a/tests/comparisons/Migration/testAddFieldWithReference.php b/tests/comparisons/Migration/testAddFieldWithReference.php new file mode 100644 index 000000000..02991e218 --- /dev/null +++ b/tests/comparisons/Migration/testAddFieldWithReference.php @@ -0,0 +1,34 @@ +table('products'); + $table->addColumn('category_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]); + $table->addForeignKey( + $this->foreignKey('category_id') + ->setReferencedTable('categories') + ->setReferencedColumns('id') + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('fk_category_id') + ); + $table->update(); + } +} diff --git a/tests/comparisons/Migration/testCreateWithReferences.php b/tests/comparisons/Migration/testCreateWithReferences.php new file mode 100644 index 000000000..6b31569a5 --- /dev/null +++ b/tests/comparisons/Migration/testCreateWithReferences.php @@ -0,0 +1,39 @@ +table('posts'); + $table->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]); + $table->addColumn('user_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]); + $table->addForeignKey( + $this->foreignKey('user_id') + ->setReferencedTable('users') + ->setReferencedColumns('id') + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('fk_user_id') + ); + $table->create(); + } +} diff --git a/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php b/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php new file mode 100644 index 000000000..b60934d63 --- /dev/null +++ b/tests/comparisons/Migration/testCreateWithReferencesCustomTable.php @@ -0,0 +1,39 @@ +table('articles'); + $table->addColumn('title', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]); + $table->addColumn('author_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]); + $table->addForeignKey( + $this->foreignKey('author_id') + ->setReferencedTable('authors') + ->setReferencedColumns('id') + ->setOnDelete('CASCADE') + ->setOnUpdate('CASCADE') + ->setName('fk_author_id') + ); + $table->create(); + } +} From 58d99f0fb6722a7cfd094a962e75bd23543c15f2 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Tue, 4 Nov 2025 18:08:52 +0100 Subject: [PATCH 2/3] Update src/Command/BakeMigrationCommand.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Command/BakeMigrationCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/BakeMigrationCommand.php b/src/Command/BakeMigrationCommand.php index 789802803..e7bdc685a 100644 --- a/src/Command/BakeMigrationCommand.php +++ b/src/Command/BakeMigrationCommand.php @@ -200,7 +200,7 @@ public function getOptionParser(): ConsoleOptionParser table. bin/cake bake migration AddSlugToProjects name:string[128]:unique -Create a migration that adds (name VARCHAR(128) and a UNIQUE<.warning index) +Create a migration that adds (name VARCHAR(128) and a UNIQUE index) to the projects table. bin/cake bake migration CreatePosts title:string user_id:references From 8dff1d29ac21829a1de50b2df7d5e6f076da6e42 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 18:22:38 +0100 Subject: [PATCH 3/3] Fix abbreviation in BakeMigrationCommand help text (#946) * Initial plan * Fix abbreviation: change 'e.x.' to 'e.g.' in help text Co-authored-by: dereuromark <39854+dereuromark@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dereuromark <39854+dereuromark@users.noreply.github.com> --- src/Command/BakeMigrationCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Command/BakeMigrationCommand.php b/src/Command/BakeMigrationCommand.php index e7bdc685a..14fbf7781 100644 --- a/src/Command/BakeMigrationCommand.php +++ b/src/Command/BakeMigrationCommand.php @@ -177,12 +177,12 @@ public function getOptionParser(): ConsoleOptionParser * The types are the abstract database column types in CakePHP. * The ? value indicates if a column is nullable. - e.x. role:string?. + e.g. role:string?. * Length option must be enclosed in [], for example: name:string?[100]. * The index attribute can define the column as having a unique key with unique or a primary key with primary. * Use references type to create a foreign key constraint. - e.x. category_id:references (auto-infers table as 'categories') + e.g. category_id:references (auto-infers table as 'categories') or category_id:references:custom_table to specify the referenced table. Examples