Skip to content

Commit 4fde09a

Browse files
authored
Fix singular/plural variable collision in baked templates (#1075)
* Fix singular/plural variable collision in baked templates When baking templates for models where the singular and plural forms are identical (e.g., "news", "sheep", "fish"), the generated foreach loops would incorrectly use the same variable for both the collection and the iteration variable, producing invalid code like: foreach ($news as $news): This fix appends "Item" to the singular variable name when a collision is detected, producing valid code: foreach ($news as $newsItem): Refs https://discourse.cakephp.org/t/paginator-error/12806/4 * Fix additional singular/plural variable collisions - Change suffix from 'Item' to 'Entity' (less likely to collide with model names like 'NewsItem') - Add same fix to ControllerCommand for controller action generation - Add collision detection in view.twig for related entity loops (appends 'Related' suffix when otherSingularVar matches singularVar) - Add collision detection in Controller/add.twig and edit.twig for association list variables (appends 'List' suffix when otherPlural matches singularName) - Add test for ControllerCommand singular/plural collision handling
1 parent 448ead3 commit 4fde09a

File tree

9 files changed

+118
-0
lines changed

9 files changed

+118
-0
lines changed

src/Command/ControllerCommand.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ public function bake(string $controllerName, Arguments $args, ConsoleIo $io): vo
129129
$singularHumanName = $this->_singularHumanName($controllerName);
130130
$pluralHumanName = $this->_variableName($controllerName);
131131

132+
// Handle cases where singular and plural are identical (e.g., "news", "sheep")
133+
// to avoid variable collisions in generated controller code
134+
if ($singularName === $pluralName) {
135+
$singularName .= 'Entity';
136+
}
137+
132138
$defaultModel = sprintf('%s\Model\Table\%sTable', $namespace, $controllerName);
133139
if (!class_exists($defaultModel)) {
134140
$defaultModel = null;

src/Command/TemplateCommand.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,12 @@ protected function _loadController(ConsoleIo $io): array
325325
$pluralVar = Inflector::variable($this->controllerName);
326326
$pluralHumanName = $this->_pluralHumanName($this->controllerName);
327327

328+
// Handle cases where singular and plural are identical (e.g., "news", "sheep")
329+
// to avoid generating invalid code like `foreach ($news as $news)`
330+
if ($singularVar === $pluralVar) {
331+
$singularVar .= 'Entity';
332+
}
333+
328334
return compact(
329335
'modelObject',
330336
'modelClass',

templates/bake/Template/view.twig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@
120120
{% set relations = associations.BelongsToMany|merge(associations.HasMany) %}
121121
{% for alias, details in relations %}
122122
{%~ set otherSingularVar = alias|singularize|variable %}
123+
{%~ if otherSingularVar == singularVar %}
124+
{%~ set otherSingularVar = otherSingularVar ~ 'Related' %}
125+
{%~ endif %}
123126
{%~ set otherPluralHumanName = details.controller|underscore|humanize %}
124127
<div class="related">
125128
<h4><?= __('Related {{ otherPluralHumanName }}') ?></h4>

templates/bake/element/Controller/add.twig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
{%- for assoc in associations %}
4141
{%~ set otherName = Bake.getAssociatedTableAlias(modelObj, assoc) %}
4242
{%~ set otherPlural = otherName|variable %}
43+
{%~ if otherPlural == singularName %}
44+
{%~ set otherPlural = otherPlural ~ 'List' %}
45+
{%~ endif %}
4346
${{ otherPlural }} = $this->{{ currentModelName }}->{{ otherName }}->find('list', limit: 200)->all();
4447
{%~ set compact = compact|merge(["'#{otherPlural}'"]) %}
4548
{% endfor %}

templates/bake/element/Controller/edit.twig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
{% for assoc in belongsTo|merge(belongsToMany) %}
4242
{%~ set otherName = Bake.getAssociatedTableAlias(modelObj, assoc) %}
4343
{%~ set otherPlural = otherName|variable %}
44+
{%~ if otherPlural == singularName %}
45+
{%~ set otherPlural = otherPlural ~ 'List' %}
46+
{%~ endif %}
4447
${{ otherPlural }} = $this->{{ currentModelName }}->{{ otherName }}->find('list', limit: 200)->all();
4548
{%~ set compact = compact|merge(["'#{otherPlural}'"]) %}
4649
{% endfor %}

tests/Fixture/NewsFixture.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
/**
3+
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
4+
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
5+
*
6+
* Licensed under The MIT License
7+
* For full copyright and license information, please see the LICENSE.txt
8+
* Redistributions of files must retain the above copyright notice
9+
*
10+
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
11+
* @link https://cakephp.org CakePHP(tm) Project
12+
* @license https://www.opensource.org/licenses/mit-license.php MIT License
13+
*/
14+
namespace Bake\Test\Fixture;
15+
16+
use Cake\TestSuite\Fixture\TestFixture;
17+
18+
/**
19+
* NewsFixture - tests singular/plural variable collision
20+
* "news" is both singular and plural
21+
*/
22+
class NewsFixture extends TestFixture
23+
{
24+
public array $records = [
25+
['title' => 'First News', 'body' => 'First News Body'],
26+
['title' => 'Second News', 'body' => 'Second News Body'],
27+
];
28+
}

tests/TestCase/Command/ControllerCommandTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class ControllerCommandTest extends TestCase
3939
'plugin.Bake.BakeArticlesBakeTags',
4040
'plugin.Bake.BakeComments',
4141
'plugin.Bake.BakeTags',
42+
'plugin.Bake.News',
4243
'plugin.Bake.Users',
4344
];
4445

@@ -435,4 +436,34 @@ public function testMainWithPluginOption()
435436
$this->assertFileContains('use Company\Pastry\Controller\AppController;', $this->generatedFile);
436437
$this->assertFileContains('BakeArticlesController extends AppController', $this->generatedFile);
437438
}
439+
440+
/**
441+
* Test baking controller for models where singular and plural are identical.
442+
*
443+
* This tests the fix for generating variable collisions like `$news` for both
444+
* the entity and the paginated collection when the model name is both singular
445+
* and plural (e.g., "news", "sheep", "fish").
446+
*
447+
* @return void
448+
*/
449+
public function testBakeControllerWithSingularPluralCollision(): void
450+
{
451+
$this->generatedFile = APP . 'Controller/NewsController.php';
452+
if (file_exists($this->generatedFile)) {
453+
unlink($this->generatedFile);
454+
}
455+
$this->exec('bake controller --connection test --no-test News');
456+
457+
$this->assertExitCode(CommandInterface::CODE_SUCCESS);
458+
$this->assertFileExists($this->generatedFile);
459+
460+
$result = file_get_contents($this->generatedFile);
461+
462+
// In view/edit/add/delete actions, the entity should use 'newsEntity' to avoid collision
463+
$this->assertStringContainsString('$newsEntity = $this->News->get(', $result);
464+
465+
// In index action, the paginated collection should still use 'news'
466+
$this->assertStringContainsString('$news = $this->paginate(', $result);
467+
$this->assertStringContainsString("compact('news')", $result);
468+
}
438469
}

tests/TestCase/Command/TemplateCommandTest.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class TemplateCommandTest extends TestCase
5454
'plugin.Bake.BakeTemplateProfiles',
5555
'plugin.Bake.CategoryThreads',
5656
'plugin.Bake.HiddenFields',
57+
'plugin.Bake.News',
5758
];
5859

5960
/**
@@ -891,4 +892,29 @@ public function testMainWithMissingTable()
891892

892893
$this->assertExitCode(CommandInterface::CODE_ERROR);
893894
}
895+
896+
/**
897+
* Test baking templates for models where singular and plural are identical.
898+
*
899+
* This tests the fix for generating invalid code like `foreach ($news as $news)`
900+
* when the model name is both singular and plural (e.g., "news", "sheep", "fish").
901+
*
902+
* @return void
903+
*/
904+
public function testBakeIndexWithSingularPluralCollision()
905+
{
906+
$this->generatedFile = ROOT . 'templates/News/index.php';
907+
$this->exec('bake template News index');
908+
909+
$this->assertExitCode(CommandInterface::CODE_SUCCESS);
910+
$this->assertFileExists($this->generatedFile);
911+
912+
$result = file_get_contents($this->generatedFile);
913+
914+
// Should NOT have `foreach ($news as $news)` which would overwrite the collection
915+
$this->assertStringNotContainsString('foreach ($news as $news)', $result);
916+
917+
// Should have `foreach ($news as $newsEntity)` instead
918+
$this->assertStringContainsString('foreach ($news as $newsEntity)', $result);
919+
}
894920
}

tests/schema.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,4 +587,16 @@
587587
'unique_self_referencing_parent' => ['type' => 'unique', 'columns' => ['parent_id']],
588588
],
589589
],
590+
// "news" is both singular and plural - tests variable collision fix
591+
[
592+
'table' => 'news',
593+
'columns' => [
594+
'id' => ['type' => 'integer'],
595+
'title' => ['type' => 'string', 'length' => 255, 'null' => false],
596+
'body' => ['type' => 'text'],
597+
'created' => ['type' => 'datetime'],
598+
'modified' => ['type' => 'datetime'],
599+
],
600+
'constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]],
601+
],
590602
];

0 commit comments

Comments
 (0)