Skip to content

Commit fdc2278

Browse files
committed
Merge remote-tracking branch 'origin/4.x' into 5.x
# Conflicts: # src/Command/BakeMigrationCommand.php # templates/bake/config/skeleton.twig # tests/TestCase/Command/BakeMigrationCommandTest.php
2 parents 14db43d + 35750c5 commit fdc2278

20 files changed

+2907
-7
lines changed

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@
6666
"@stan",
6767
"@test"
6868
],
69-
"cs-check": "phpcs -p",
70-
"cs-fix": "phpcbf -p",
69+
"cs-check": "phpcs --parallel=16 -p",
70+
"cs-fix": "phpcbf --parallel=16 -p",
7171
"phpstan": "tools/phpstan analyse",
7272
"stan": "@phpstan",
7373
"stan-baseline": "tools/phpstan --generate-baseline",

src/Command/BakeMigrationCommand.php

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public function templateData(Arguments $arguments): array
8888
$fields = $columnParser->parseFields($args);
8989
$indexes = $columnParser->parseIndexes($args);
9090
$primaryKey = $columnParser->parsePrimaryKey($args);
91+
$foreignKeys = $columnParser->parseForeignKeys($args);
9192

9293
$action = $this->detectAction($className);
9394

@@ -123,6 +124,7 @@ public function templateData(Arguments $arguments): array
123124
'indexes' => $indexes,
124125
'primaryKey' => $primaryKey,
125126
],
127+
'constraints' => $foreignKeys,
126128
'name' => $className,
127129
];
128130
}
@@ -172,16 +174,19 @@ public function getOptionParser(): ConsoleOptionParser
172174
173175
When describing columns you can use the following syntax:
174176
175-
<warning>{name}:{primary}{type}{nullable}[{length}]:{index}</warning>
177+
<warning>{name}:{primary}{type}{nullable}[{length}]:{index}:{indexName}</warning>
176178
177179
All sections other than name are optional.
178180
179181
* The types are the abstract database column types in CakePHP.
180182
* The <warning>?</warning> value indicates if a column is nullable.
181-
e.x. <warning>role:string?</warning>.
183+
e.g. <warning>role:string?</warning>.
182184
* Length option must be enclosed in <warning>[]</warning>, for example: <warning>name:string?[100]</warning>.
183185
* The <warning>index</warning> attribute can define the column as having a unique
184186
key with <warning>unique</warning> or a primary key with <warning>primary</warning>.
187+
* Use <warning>references</warning> type to create a foreign key constraint.
188+
e.g. <warning>category_id:references</warning> (auto-infers table as 'categories')
189+
or <warning>category_id:references:custom_table</warning> to specify the referenced table.
185190
186191
<info>Examples</info>
187192
@@ -198,9 +203,17 @@ public function getOptionParser(): ConsoleOptionParser
198203
table.
199204
200205
<warning>bin/cake bake migration AddSlugToProjects name:string[128]:unique</warning>
201-
Create a migration that adds (<warning>name VARCHAR(128)</warning> and a <warning>UNIQUE<.warning index)
206+
Create a migration that adds (<warning>name VARCHAR(128)</warning> and a <warning>UNIQUE</warning> index)
202207
to the <warning>projects</warning> table.
203208
209+
<warning>bin/cake bake migration CreatePosts title:string user_id:references</warning>
210+
Create a migration that creates the <warning>posts</warning> table with a foreign key
211+
constraint on <warning>user_id</warning> referencing the <warning>users</warning> table.
212+
213+
<warning>bin/cake bake migration AddCategoryIdToArticles category_id:references:categories</warning>
214+
Create a migration that adds a foreign key column (<warning>category_id</warning>) to the <warning>articles</warning>
215+
table referencing the <warning>categories</warning> table.
216+
204217
<info>Migration Styles</info>
205218
206219
You can generate migrations in different styles:

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
*

src/Util/TableFinder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public function getTablesToBake(CollectionInterface $collection, array $options
6969
return $tables;
7070
}
7171

72-
if ($options['require-table'] === true || $options['plugin']) {
72+
if ($options['require-table'] === true) {
7373
$tableNamesInPlugin = $this->getTableNames($options['plugin']);
7474

7575
if (!$tableNamesInPlugin) {

templates/bake/element/add-foreign-keys.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{% set statement = Migration.tableStatement(table, true) %}
22
{% set hasProcessedConstraint = false %}
33
{% for constraintName, constraint in constraints %}
4-
{%~ set constraintColumns = constraint['columns']|sort %}
4+
{%~ set constraintColumns = constraint['columns']|default([])|sort %}
55
{%~ if constraint['type'] == 'foreign' %}
66
{%~ set hasProcessedConstraint = true %}
77
{%~ set columnsList = '\'' ~ constraint['columns'][0] ~ '\'' %}

templates/bake/element/change-method-body.twig

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,29 @@
4343
Migration.stringifyList(columns['primaryKey'], {'indent': 3}) | raw
4444
}}]);
4545
{%~ endif %}
46+
{%~ if constraints is defined and constraints is not empty %}
47+
{%~ for constraintName, constraint in constraints %}
48+
{%~ if constraint['type'] == 'foreign' %}
49+
{%~ set columnsList = '\'' ~ constraint['columns'][0] ~ '\'' %}
50+
{%~ if constraint['columns']|length > 1 %}
51+
{%~ set columnsList = '[' ~ Migration.stringifyList(constraint['columns'], {'indent': 3}) ~ ']' %}
52+
{%~ endif %}
53+
{%~ if constraint['references'][1] is iterable %}
54+
{%~ set columnsReference = '[' ~ Migration.stringifyList(constraint['references'][1], {'indent': 3}) ~ ']' %}
55+
{%~ else %}
56+
{%~ set columnsReference = '\'' ~ constraint['references'][1] ~ '\'' %}
57+
{%~ endif %}
58+
$table->addForeignKey(
59+
$this->foreignKey({{ columnsList | raw }})
60+
->setReferencedTable('{{ constraint['references'][0] }}')
61+
->setReferencedColumns({{ columnsReference | raw }})
62+
->setOnDelete('{{ Migration.formatConstraintAction(constraint['delete']) | raw }}')
63+
->setOnUpdate('{{ Migration.formatConstraintAction(constraint['update']) | raw }}')
64+
->setName('{{ constraintName }}')
65+
);
66+
{%~ endif %}
67+
{%~ endfor %}
68+
{%~ endif %}
4669
{%~ endif %}
4770
{% endif %}
4871
$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
/**
@@ -425,6 +446,57 @@ public function testCreateAnonymousStyleWithConfigure()
425446
$this->assertStringNotContainsString('function (int $version)', $result);
426447
}
427448

449+
/**
450+
* Test creating migration with references (foreign keys)
451+
*
452+
* @return void
453+
*/
454+
public function testCreateWithReferences()
455+
{
456+
$this->exec('bake migration CreatePosts title:string user_id:references --connection test');
457+
458+
$file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreatePosts.php');
459+
$filePath = current($file);
460+
461+
$this->assertExitCode(BaseCommand::CODE_SUCCESS);
462+
$result = file_get_contents($filePath);
463+
$this->assertSameAsFile(__FUNCTION__ . '.php', $result);
464+
}
465+
466+
/**
467+
* Test creating migration with references to specific table
468+
*
469+
* @return void
470+
*/
471+
public function testCreateWithReferencesCustomTable()
472+
{
473+
$this->exec('bake migration CreateArticles title:string author_id:references:authors --connection test');
474+
475+
$file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_CreateArticles.php');
476+
$filePath = current($file);
477+
478+
$this->assertExitCode(BaseCommand::CODE_SUCCESS);
479+
$result = file_get_contents($filePath);
480+
$this->assertSameAsFile(__FUNCTION__ . '.php', $result);
481+
}
482+
483+
/**
484+
* Test adding a field with reference
485+
*
486+
* @return void
487+
*/
488+
public function testAddFieldWithReference()
489+
{
490+
$this->exec('bake migration AddCategoryIdToProducts category_id:references --connection test');
491+
492+
$file = glob(ROOT . DS . 'config' . DS . 'Migrations' . DS . '*_AddCategoryIdToProducts.php');
493+
$filePath = current($file);
494+
495+
$this->assertExitCode(BaseCommand::CODE_SUCCESS);
496+
$result = file_get_contents($filePath);
497+
$this->assertSameAsFile(__FUNCTION__ . '.php', $result);
498+
}
499+
428500
public function testBakeMigrationWithoutBake()
429501
{
430502
// Make sure to unload the Bake plugin

tests/TestCase/Command/BakeMigrationSnapshotCommandTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,34 @@ public function testPluginBlog()
226226
$this->runSnapshotTest('PluginBlog', '-p TestBlog');
227227
}
228228

229+
/**
230+
* Test baking a snapshot for a plugin with custom connection (issue #463).
231+
* This tests that when using both --plugin and --connection options,
232+
* the migration includes all tables from the connection, not just those
233+
* with Table classes in the plugin.
234+
*
235+
* @return void
236+
*/
237+
public function testPluginWithCustomConnection()
238+
{
239+
$this->loadPlugins(['SimpleSnapshot']);
240+
$this->migrationPath = ROOT . DS . 'Plugin' . DS . 'SimpleSnapshot' . DS . 'config' . DS . 'Migrations' . DS;
241+
242+
$bakeName = $this->getBakeName('TestSnapshotPluginCustomConnection');
243+
$this->exec("bake migration_snapshot {$bakeName} -c test -p SimpleSnapshot");
244+
245+
$generatedMigration = glob($this->migrationPath . '*_TestSnapshotPluginCustomConnection*.php');
246+
$this->generatedFiles = $generatedMigration;
247+
$this->generatedFiles[] = $this->migrationPath . 'schema-dump-test.lock';
248+
249+
$this->assertNotEmpty($generatedMigration, 'Migration file should be generated');
250+
251+
$content = file_get_contents($generatedMigration[0]);
252+
$this->assertStringContainsString('function up()', $content);
253+
$this->assertStringNotContainsString('public function up(): void {}', $content, 'up() method should not be empty');
254+
$this->assertStringContainsString('->create()', $content, 'Migration should contain table creation statements');
255+
}
256+
229257
protected function runSnapshotTest(string $scenario, string $arguments = ''): void
230258
{
231259
if ($arguments) {

0 commit comments

Comments
 (0)