Skip to content
Merged
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
19 changes: 16 additions & 3 deletions src/Command/BakeMigrationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -119,6 +120,7 @@ public function templateData(Arguments $arguments): array
'indexes' => $indexes,
'primaryKey' => $primaryKey,
],
'constraints' => $foreignKeys,
'name' => $className,
'backend' => Configure::read('Migrations.backend', 'builtin'),
];
Expand Down Expand Up @@ -169,16 +171,19 @@ public function getOptionParser(): ConsoleOptionParser

When describing columns you can use the following syntax:

<warning>{name}:{primary}{type}{nullable}[{length}]:{index}</warning>
<warning>{name}:{primary}{type}{nullable}[{length}]:{index}:{indexName}</warning>

All sections other than name are optional.

* The types are the abstract database column types in CakePHP.
* The <warning>?</warning> value indicates if a column is nullable.
e.x. <warning>role:string?</warning>.
e.g. <warning>role:string?</warning>.
* Length option must be enclosed in <warning>[]</warning>, for example: <warning>name:string?[100]</warning>.
* The <warning>index</warning> attribute can define the column as having a unique
key with <warning>unique</warning> or a primary key with <warning>primary</warning>.
* Use <warning>references</warning> type to create a foreign key constraint.
e.g. <warning>category_id:references</warning> (auto-infers table as 'categories')
or <warning>category_id:references:custom_table</warning> to specify the referenced table.

<info>Examples</info>

Expand All @@ -195,9 +200,17 @@ public function getOptionParser(): ConsoleOptionParser
table.

<warning>bin/cake bake migration AddSlugToProjects name:string[128]:unique</warning>
Create a migration that adds (<warning>name VARCHAR(128)</warning> and a <warning>UNIQUE<.warning index)
Create a migration that adds (<warning>name VARCHAR(128)</warning> and a <warning>UNIQUE</warning> index)
to the <warning>projects</warning> table.

<warning>bin/cake bake migration CreatePosts title:string user_id:references</warning>
Create a migration that creates the <warning>posts</warning> table with a foreign key
constraint on <warning>user_id</warning> referencing the <warning>users</warning> table.

<warning>bin/cake bake migration AddCategoryIdToArticles category_id:references:categories</warning>
Create a migration that adds a foreign key column (<warning>category_id</warning>) to the <warning>articles</warning>
table referencing the <warning>categories</warning> table.

TEXT;

$parser->setDescription($text);
Expand Down
62 changes: 62 additions & 0 deletions src/Util/ColumnParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use Cake\Collection\Collection;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
use Migrations\Db\Adapter\AdapterInterface;
use ReflectionClass;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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<int, string> $arguments A list of arguments being parsed
* @return array<string, 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
*
Expand Down
36 changes: 36 additions & 0 deletions templates/bake/config/skeleton.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 %};
Expand Down
72 changes: 72 additions & 0 deletions tests/TestCase/Command/BakeMigrationCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Comment on lines +60 to +79
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The teardown cleanup logic is duplicated for three different file patterns. Consider extracting this into a helper method that accepts a pattern parameter to reduce code duplication.

Copilot uses AI. Check for mistakes.
}

/**
Expand Down Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions tests/TestCase/Util/ColumnParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading
Loading