Skip to content

Commit c705be2

Browse files
authored
Support baking references. (#940)
* Support baking references. * Update src/Command/BakeMigrationCommand.php * 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>
1 parent fa38386 commit c705be2

File tree

8 files changed

+410
-3
lines changed

8 files changed

+410
-3
lines changed

src/Command/BakeMigrationCommand.php

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public function templateData(Arguments $arguments): array
8383
$fields = $columnParser->parseFields($args);
8484
$indexes = $columnParser->parseIndexes($args);
8585
$primaryKey = $columnParser->parsePrimaryKey($args);
86+
$foreignKeys = $columnParser->parseForeignKeys($args);
8687

8788
$action = $this->detectAction($className);
8889

@@ -119,6 +120,7 @@ public function templateData(Arguments $arguments): array
119120
'indexes' => $indexes,
120121
'primaryKey' => $primaryKey,
121122
],
123+
'constraints' => $foreignKeys,
122124
'name' => $className,
123125
'backend' => Configure::read('Migrations.backend', 'builtin'),
124126
];
@@ -169,16 +171,19 @@ public function getOptionParser(): ConsoleOptionParser
169171
170172
When describing columns you can use the following syntax:
171173
172-
<warning>{name}:{primary}{type}{nullable}[{length}]:{index}</warning>
174+
<warning>{name}:{primary}{type}{nullable}[{length}]:{index}:{indexName}</warning>
173175
174176
All sections other than name are optional.
175177
176178
* The types are the abstract database column types in CakePHP.
177179
* The <warning>?</warning> value indicates if a column is nullable.
178-
e.x. <warning>role:string?</warning>.
180+
e.g. <warning>role:string?</warning>.
179181
* Length option must be enclosed in <warning>[]</warning>, for example: <warning>name:string?[100]</warning>.
180182
* The <warning>index</warning> attribute can define the column as having a unique
181183
key with <warning>unique</warning> or a primary key with <warning>primary</warning>.
184+
* Use <warning>references</warning> type to create a foreign key constraint.
185+
e.g. <warning>category_id:references</warning> (auto-infers table as 'categories')
186+
or <warning>category_id:references:custom_table</warning> to specify the referenced table.
182187
183188
<info>Examples</info>
184189
@@ -195,9 +200,17 @@ public function getOptionParser(): ConsoleOptionParser
195200
table.
196201
197202
<warning>bin/cake bake migration AddSlugToProjects name:string[128]:unique</warning>
198-
Create a migration that adds (<warning>name VARCHAR(128)</warning> and a <warning>UNIQUE<.warning index)
203+
Create a migration that adds (<warning>name VARCHAR(128)</warning> and a <warning>UNIQUE</warning> index)
199204
to the <warning>projects</warning> table.
200205
206+
<warning>bin/cake bake migration CreatePosts title:string user_id:references</warning>
207+
Create a migration that creates the <warning>posts</warning> table with a foreign key
208+
constraint on <warning>user_id</warning> referencing the <warning>users</warning> table.
209+
210+
<warning>bin/cake bake migration AddCategoryIdToArticles category_id:references:categories</warning>
211+
Create a migration that adds a foreign key column (<warning>category_id</warning>) to the <warning>articles</warning>
212+
table referencing the <warning>categories</warning> table.
213+
201214
TEXT;
202215

203216
$parser->setDescription($text);

src/Util/ColumnParser.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
use Cake\Collection\Collection;
77
use Cake\Utility\Hash;
8+
use Cake\Utility\Inflector;
89
use Migrations\Db\Adapter\AdapterInterface;
910
use ReflectionClass;
1011

@@ -64,6 +65,13 @@ public function parseFields(array $arguments): array
6465
$type = 'primary';
6566
}
6667
}
68+
69+
// Handle references - convert to integer type
70+
$isReference = in_array($type, ['references', 'references?'], true);
71+
if ($isReference) {
72+
$type = str_contains($type, '?') ? 'integer?' : 'integer';
73+
}
74+
6775
$nullable = (bool)strpos($type, '?');
6876
$type = $nullable ? str_replace('?', '', $type) : $type;
6977

@@ -109,6 +117,11 @@ public function parseIndexes(array $arguments): array
109117
$indexType = Hash::get($matches, 3);
110118
$indexName = Hash::get($matches, 4);
111119

120+
// Skip references - they create foreign keys, not indexes
121+
if ($type && str_starts_with($type, 'references')) {
122+
continue;
123+
}
124+
112125
if (
113126
in_array($type, ['primary', 'primary_key'], true) ||
114127
in_array($indexType, ['primary', 'primary_key'], true) ||
@@ -168,6 +181,55 @@ public function parsePrimaryKey(array $arguments): array
168181
return $primaryKey;
169182
}
170183

184+
/**
185+
* Parses a list of arguments into an array of foreign key constraints
186+
*
187+
* @param array<int, string> $arguments A list of arguments being parsed
188+
* @return array<string, array>
189+
*/
190+
public function parseForeignKeys(array $arguments): array
191+
{
192+
$foreignKeys = [];
193+
$arguments = $this->validArguments($arguments);
194+
195+
foreach ($arguments as $field) {
196+
preg_match($this->regexpParseColumn, $field, $matches);
197+
$fieldName = $matches[1];
198+
$type = Hash::get($matches, 2, '');
199+
$indexType = Hash::get($matches, 3);
200+
$indexName = Hash::get($matches, 4);
201+
202+
// Check if type is 'references' or 'references?'
203+
$isReference = str_starts_with($type, 'references');
204+
if (!$isReference) {
205+
continue;
206+
}
207+
208+
// Determine referenced table
209+
// If indexType is provided, use it as the referenced table name
210+
// Otherwise, infer from field name (e.g., category_id -> categories)
211+
$referencedTable = $indexType;
212+
if (!$referencedTable) {
213+
// Remove common suffixes like _id and pluralize
214+
$referencedTable = preg_replace('/_id$/', '', $fieldName);
215+
$referencedTable = Inflector::pluralize($referencedTable);
216+
}
217+
218+
// Generate constraint name
219+
$constraintName = $indexName ?: 'fk_' . $fieldName;
220+
221+
$foreignKeys[$constraintName] = [
222+
'type' => 'foreign',
223+
'columns' => [$fieldName],
224+
'references' => [$referencedTable, 'id'],
225+
'update' => 'CASCADE',
226+
'delete' => 'CASCADE',
227+
];
228+
}
229+
230+
return $foreignKeys;
231+
}
232+
171233
/**
172234
* Returns a list of only valid arguments
173235
*

templates/bake/config/skeleton.twig

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,42 @@ class {{ name }} extends AbstractMigration
7878
Migration.stringifyList(columns['primaryKey'], {'indent': 3}) | raw
7979
}}]);
8080
{%~ endif %}
81+
{%~ if constraints is defined and constraints is not empty %}
82+
{%~ for constraintName, constraint in constraints %}
83+
{%~ if constraint['type'] == 'foreign' %}
84+
{%~ set columnsList = '\'' ~ constraint['columns'][0] ~ '\'' %}
85+
{%~ if constraint['columns']|length > 1 %}
86+
{%~ set columnsList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': 3}) ~ ']' %}
87+
{%~ endif %}
88+
{%~ if constraint['references'][1] is iterable %}
89+
{%~ set columnsReference = '[' ~ Migration.stringifyList(constraint['references'][1], {'indent': 3}) ~ ']' %}
90+
{%~ else %}
91+
{%~ set columnsReference = '\'' ~ constraint['references'][1] ~ '\'' %}
92+
{%~ endif %}
93+
{%~ if backend == 'builtin' %}
94+
$table->addForeignKey(
95+
$this->foreignKey({{ columnsList | raw }})
96+
->setReferencedTable('{{ constraint['references'][0] }}')
97+
->setReferencedColumns({{ columnsReference | raw }})
98+
->setOnDelete('{{ Migration.formatConstraintAction(constraint['delete']) | raw }}')
99+
->setOnUpdate('{{ Migration.formatConstraintAction(constraint['update']) | raw }}')
100+
->setName('{{ constraintName }}')
101+
);
102+
{%~ else %}
103+
$table->addForeignKey(
104+
{{ columnsList | raw }},
105+
'{{ constraint['references'][0] }}',
106+
{{ columnsReference | raw }},
107+
[
108+
'update' => '{{ Migration.formatConstraintAction(constraint['update']) | raw }}',
109+
'delete' => '{{ Migration.formatConstraintAction(constraint['delete']) | raw }}',
110+
'constraint' => '{{ constraintName }}'
111+
]
112+
);
113+
{%~ endif %}
114+
{%~ endif %}
115+
{%~ endfor %}
116+
{%~ endif %}
81117
{%~ endif %}
82118
{% endif %}
83119
$table->{{ tableMethod }}(){% if tableMethod == 'drop' %}->save(){% endif %};

tests/TestCase/Command/BakeMigrationCommandTest.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,27 @@ public function tearDown(): void
5656
unlink($file);
5757
}
5858
}
59+
60+
$files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Posts.php');
61+
if ($files) {
62+
foreach ($files as $file) {
63+
unlink($file);
64+
}
65+
}
66+
67+
$files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Articles.php');
68+
if ($files) {
69+
foreach ($files as $file) {
70+
unlink($file);
71+
}
72+
}
73+
74+
$files = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_*Products.php');
75+
if ($files) {
76+
foreach ($files as $file) {
77+
unlink($file);
78+
}
79+
}
5980
}
6081

6182
/**
@@ -387,6 +408,57 @@ public function testActionWithoutValidPrefix()
387408
$this->assertErrorContains('When applying fields the migration name should start with one of the following prefixes: `Create`, `Drop`, `Add`, `Remove`, `Alter`.');
388409
}
389410

411+
/**
412+
* Test creating migration with references (foreign keys)
413+
*
414+
* @return void
415+
*/
416+
public function testCreateWithReferences()
417+
{
418+
$this->exec('bake migration CreatePosts title:string user_id:references --connection test');
419+
420+
$file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreatePosts.php');
421+
$filePath = current($file);
422+
423+
$this->assertExitCode(BaseCommand::CODE_SUCCESS);
424+
$result = file_get_contents($filePath);
425+
$this->assertSameAsFile(__FUNCTION__ . '.php', $result);
426+
}
427+
428+
/**
429+
* Test creating migration with references to specific table
430+
*
431+
* @return void
432+
*/
433+
public function testCreateWithReferencesCustomTable()
434+
{
435+
$this->exec('bake migration CreateArticles title:string author_id:references:authors --connection test');
436+
437+
$file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreateArticles.php');
438+
$filePath = current($file);
439+
440+
$this->assertExitCode(BaseCommand::CODE_SUCCESS);
441+
$result = file_get_contents($filePath);
442+
$this->assertSameAsFile(__FUNCTION__ . '.php', $result);
443+
}
444+
445+
/**
446+
* Test adding a field with reference
447+
*
448+
* @return void
449+
*/
450+
public function testAddFieldWithReference()
451+
{
452+
$this->exec('bake migration AddCategoryIdToProducts category_id:references --connection test');
453+
454+
$file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_AddCategoryIdToProducts.php');
455+
$filePath = current($file);
456+
457+
$this->assertExitCode(BaseCommand::CODE_SUCCESS);
458+
$result = file_get_contents($filePath);
459+
$this->assertSameAsFile(__FUNCTION__ . '.php', $result);
460+
}
461+
390462
public function testBakeMigrationWithoutBake()
391463
{
392464
// Make sure to unload the Bake plugin

tests/TestCase/Util/ColumnParserTest.php

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,4 +367,116 @@ public function testGetIndexName()
367367
$this->assertSame('PRIMARY', $this->columnParser->getIndexName('id', 'primary', null, false));
368368
$this->assertSame('PRIMARY', $this->columnParser->getIndexName('id', 'primary', null, true));
369369
}
370+
371+
public function testParseFieldsWithReferences()
372+
{
373+
// Test basic references - should convert to integer
374+
$expected = [
375+
'user_id' => [
376+
'columnType' => 'integer',
377+
'options' => [
378+
'null' => false,
379+
'default' => null,
380+
'limit' => 11,
381+
],
382+
],
383+
];
384+
$actual = $this->columnParser->parseFields(['user_id:references']);
385+
$this->assertEquals($expected, $actual);
386+
387+
// Test nullable references
388+
$expected = [
389+
'category_id' => [
390+
'columnType' => 'integer',
391+
'options' => [
392+
'null' => true,
393+
'default' => null,
394+
'limit' => 11,
395+
],
396+
],
397+
];
398+
$actual = $this->columnParser->parseFields(['category_id:references?']);
399+
$this->assertEquals($expected, $actual);
400+
401+
// Test references with explicit table name
402+
$expected = [
403+
'category_id' => [
404+
'columnType' => 'integer',
405+
'options' => [
406+
'null' => false,
407+
'default' => null,
408+
'limit' => 11,
409+
],
410+
],
411+
];
412+
$actual = $this->columnParser->parseFields(['category_id:references:categories']);
413+
$this->assertEquals($expected, $actual);
414+
}
415+
416+
public function testParseForeignKeys()
417+
{
418+
// Test basic reference - infer table name from field
419+
$expected = [
420+
'fk_user_id' => [
421+
'type' => 'foreign',
422+
'columns' => ['user_id'],
423+
'references' => ['users', 'id'],
424+
'update' => 'CASCADE',
425+
'delete' => 'CASCADE',
426+
],
427+
];
428+
$actual = $this->columnParser->parseForeignKeys(['user_id:references']);
429+
$this->assertEquals($expected, $actual);
430+
431+
// Test reference with explicit table name
432+
$expected = [
433+
'fk_category_id' => [
434+
'type' => 'foreign',
435+
'columns' => ['category_id'],
436+
'references' => ['custom_categories', 'id'],
437+
'update' => 'CASCADE',
438+
'delete' => 'CASCADE',
439+
],
440+
];
441+
$actual = $this->columnParser->parseForeignKeys(['category_id:references:custom_categories']);
442+
$this->assertEquals($expected, $actual);
443+
444+
// Test reference with custom constraint name
445+
$expected = [
446+
'custom_fk' => [
447+
'type' => 'foreign',
448+
'columns' => ['author_id'],
449+
'references' => ['authors', 'id'],
450+
'update' => 'CASCADE',
451+
'delete' => 'CASCADE',
452+
],
453+
];
454+
$actual = $this->columnParser->parseForeignKeys(['author_id:references:authors:custom_fk']);
455+
$this->assertEquals($expected, $actual);
456+
457+
// Test multiple foreign keys
458+
$expected = [
459+
'fk_user_id' => [
460+
'type' => 'foreign',
461+
'columns' => ['user_id'],
462+
'references' => ['users', 'id'],
463+
'update' => 'CASCADE',
464+
'delete' => 'CASCADE',
465+
],
466+
'fk_category_id' => [
467+
'type' => 'foreign',
468+
'columns' => ['category_id'],
469+
'references' => ['categories', 'id'],
470+
'update' => 'CASCADE',
471+
'delete' => 'CASCADE',
472+
],
473+
];
474+
$actual = $this->columnParser->parseForeignKeys(['user_id:references', 'category_id:references']);
475+
$this->assertEquals($expected, $actual);
476+
477+
// Test that non-reference fields are ignored
478+
$expected = [];
479+
$actual = $this->columnParser->parseForeignKeys(['name:string', 'age:integer']);
480+
$this->assertEquals($expected, $actual);
481+
}
370482
}

0 commit comments

Comments
 (0)