Skip to content

Commit 3e8ca4b

Browse files
authored
Merge pull request #1064 from cakephp/bake-classes
Allow baking any class.
2 parents 77a0af1 + dfd486f commit 3e8ca4b

File tree

4 files changed

+344
-11
lines changed

4 files changed

+344
-11
lines changed

src/Command/TestCommand.php

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class TestCommand extends BakeCommand
5555
'Command' => 'Command',
5656
'CommandHelper' => 'Command\Helper',
5757
'Middleware' => 'Middleware',
58+
'Class' => '',
5859
];
5960

6061
/**
@@ -75,6 +76,7 @@ class TestCommand extends BakeCommand
7576
'Command' => 'Command',
7677
'CommandHelper' => 'Helper',
7778
'Middleware' => 'Middleware',
79+
'Class' => '',
7880
];
7981

8082
/**
@@ -123,7 +125,11 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
123125
$name = $args->getArgument('name');
124126
$name = $this->_getName($name);
125127

126-
if ($this->bake($type, $name, $args, $io)) {
128+
$result = $this->bake($type, $name, $args, $io);
129+
if ($result === static::CODE_ERROR) {
130+
return static::CODE_ERROR;
131+
}
132+
if ($result) {
127133
$io->success('Done');
128134
}
129135

@@ -212,10 +218,29 @@ protected function _getClassOptions(string $namespace): array
212218
}
213219

214220
$path = $base . str_replace('\\', DS, $namespace);
215-
$files = (new Filesystem())->find($path);
216-
foreach ($files as $fileObj) {
217-
if ($fileObj->isFile()) {
218-
$classes[] = substr($fileObj->getFileName(), 0, -4) ?: '';
221+
222+
// For generic Class type (empty namespace), search recursively
223+
if ($namespace === '') {
224+
$files = (new Filesystem())->findRecursive($path, '/\.php$/');
225+
foreach ($files as $fileObj) {
226+
if ($fileObj->isFile() && $fileObj->getFileName() !== 'Application.php') {
227+
// Build the namespace path relative to App directory
228+
$relativePath = str_replace($base, '', $fileObj->getPath());
229+
$relativePath = trim(str_replace(DS, '\\', $relativePath), '\\');
230+
$className = substr($fileObj->getFileName(), 0, -4) ?: '';
231+
if ($relativePath) {
232+
$classes[] = $relativePath . '\\' . $className;
233+
} else {
234+
$classes[] = $className;
235+
}
236+
}
237+
}
238+
} else {
239+
$files = (new Filesystem())->find($path);
240+
foreach ($files as $fileObj) {
241+
if ($fileObj->isFile()) {
242+
$classes[] = substr($fileObj->getFileName(), 0, -4) ?: '';
243+
}
219244
}
220245
}
221246
sort($classes);
@@ -230,18 +255,43 @@ protected function _getClassOptions(string $namespace): array
230255
* @param string $className the 'cake name' for the class ie. Posts for the PostsController
231256
* @param \Cake\Console\Arguments $args Arguments
232257
* @param \Cake\Console\ConsoleIo $io ConsoleIo instance
233-
* @return string|bool
258+
* @return string|bool|int Returns the generated code as string on success, false on failure, or CODE_ERROR for validation errors
234259
*/
235-
public function bake(string $type, string $className, Arguments $args, ConsoleIo $io): string|bool
260+
public function bake(string $type, string $className, Arguments $args, ConsoleIo $io): string|bool|int
236261
{
237262
$type = $this->normalize($type);
238263
if (!isset($this->classSuffixes[$type]) || !isset($this->classTypes[$type])) {
239264
return false;
240265
}
241266

267+
// For Class type, validate that backslashes are properly escaped
268+
if ($type === 'Class' && !str_contains($className, '\\')) {
269+
$io->error('Class name appears to have no namespace separators.');
270+
$io->out('');
271+
$io->out('If you meant to specify a namespaced class, please use quotes:');
272+
$io->out(" <info>bin/cake bake test class '{$className}'</info>");
273+
$io->out('');
274+
$io->out('Or specify without the base namespace:');
275+
$io->out(' <info>bin/cake bake test class YourNamespace\\ClassName</info>');
276+
277+
return static::CODE_ERROR;
278+
}
279+
242280
$prefix = $this->getPrefix($args);
243281
$fullClassName = $this->getRealClassName($type, $className, $prefix);
244282

283+
// For Class type, validate that the class exists
284+
if ($type === 'Class' && !class_exists($fullClassName)) {
285+
$io->error("Class '{$fullClassName}' does not exist or cannot be loaded.");
286+
$io->out('');
287+
$io->out('Please check:');
288+
$io->out(' - The class file exists in the correct location');
289+
$io->out(' - The class is properly autoloaded');
290+
$io->out(' - The namespace and class name are correct');
291+
292+
return static::CODE_ERROR;
293+
}
294+
245295
// Check if fixture factories plugin is available
246296
$hasFixtureFactories = $this->hasFixtureFactories();
247297

@@ -266,8 +316,14 @@ public function bake(string $type, string $className, Arguments $args, ConsoleIo
266316
[$preConstruct, $construction, $postConstruct] = $this->generateConstructor($type, $fullClassName);
267317
$uses = $this->generateUses($type, $fullClassName);
268318

269-
$subject = $className;
270-
[$namespace, $className] = namespaceSplit($fullClassName);
319+
// For generic Class type, extract just the class name for the subject
320+
if ($type === 'Class') {
321+
[$namespace, $className] = namespaceSplit($fullClassName);
322+
$subject = $className;
323+
} else {
324+
$subject = $className;
325+
[$namespace, $className] = namespaceSplit($fullClassName);
326+
}
271327

272328
$baseNamespace = Configure::read('App.namespace');
273329
if ($this->plugin) {
@@ -381,6 +437,17 @@ public function getRealClassName(string $type, string $class, ?string $prefix =
381437
if ($this->plugin) {
382438
$namespace = str_replace('/', '\\', $this->plugin);
383439
}
440+
441+
// For generic Class type, the class name contains the full subnamespace path
442+
if ($type === 'Class') {
443+
// Strip base namespace if user included it
444+
if (str_starts_with($class, $namespace . '\\')) {
445+
$class = substr($class, strlen($namespace) + 1);
446+
}
447+
448+
return $namespace . '\\' . $class;
449+
}
450+
384451
$suffix = $this->classSuffixes[$type];
385452
$subSpace = $this->mapType($type);
386453
if ($suffix && strpos($class, $suffix) === false) {
@@ -415,7 +482,7 @@ public function getSubspacePath(string $type): string
415482
*/
416483
public function mapType(string $type): string
417484
{
418-
if (empty($this->classTypes[$type])) {
485+
if (!isset($this->classTypes[$type])) {
419486
throw new CakeException('Invalid object type: ' . $type);
420487
}
421488

@@ -585,6 +652,18 @@ public function generateConstructor(string $type, string $fullClassName): array
585652
$pre .= ' $this->io = new ConsoleIo($this->stub);';
586653
$construct = "new {$className}(\$this->io);";
587654
}
655+
if ($type === 'Class') {
656+
// Check if class has required constructor parameters
657+
if (class_exists($fullClassName)) {
658+
$reflection = new ReflectionClass($fullClassName);
659+
$constructor = $reflection->getConstructor();
660+
if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) {
661+
$construct = "new {$className}();";
662+
}
663+
} else {
664+
$construct = "new {$className}();";
665+
}
666+
}
588667

589668
return [$pre, $construct, $post];
590669
}
@@ -635,7 +714,17 @@ public function generateProperties(string $type, string $subject, string $fullCl
635714
break;
636715
}
637716

638-
if (!in_array($type, ['Controller', 'Command'])) {
717+
// Skip test subject property for Controller, Command, and Class types with required constructor params
718+
$skipProperty = in_array($type, ['Controller', 'Command'], true);
719+
if ($type === 'Class' && class_exists($fullClassName)) {
720+
$reflection = new ReflectionClass($fullClassName);
721+
$constructor = $reflection->getConstructor();
722+
if ($constructor && $constructor->getNumberOfRequiredParameters() > 0) {
723+
$skipProperty = true;
724+
}
725+
}
726+
727+
if (!$skipProperty) {
639728
$properties[] = [
640729
'description' => 'Test subject',
641730
'type' => '\\' . $fullClassName,

tests/TestCase/Command/TestCommandTest.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,7 @@ public static function mapTypeProvider()
707707
['Entity', 'Model\Entity'],
708708
['Behavior', 'Model\Behavior'],
709709
['Helper', 'View\Helper'],
710+
['Class', ''],
710711
];
711712
}
712713

@@ -761,4 +762,169 @@ public function testGenerateUsesDocBlockTable()
761762
$testsPath . 'TestCase/Model/Table/ProductsTableTest.php',
762763
);
763764
}
765+
766+
/**
767+
* Test baking generic Class type without constructor args
768+
*
769+
* @return void
770+
*/
771+
public function testBakeGenericClassWithoutConstructor()
772+
{
773+
$testsPath = ROOT . 'tests' . DS;
774+
$this->generatedFiles = [
775+
$testsPath . 'TestCase/Service/SimpleCalculatorTest.php',
776+
];
777+
778+
$this->exec('bake test Class Service\SimpleCalculator', ['y']);
779+
780+
$this->assertExitCode(CommandInterface::CODE_SUCCESS);
781+
$this->assertFilesExist($this->generatedFiles);
782+
$this->assertFileContains(
783+
'class SimpleCalculatorTest extends TestCase',
784+
$this->generatedFiles[0],
785+
);
786+
$this->assertFileContains(
787+
'protected $SimpleCalculator;',
788+
$this->generatedFiles[0],
789+
);
790+
$this->assertFileContains(
791+
'protected function setUp(): void',
792+
$this->generatedFiles[0],
793+
);
794+
$this->assertFileContains(
795+
'$this->SimpleCalculator = new SimpleCalculator();',
796+
$this->generatedFiles[0],
797+
);
798+
$this->assertFileContains(
799+
'public function testAdd(): void',
800+
$this->generatedFiles[0],
801+
);
802+
$this->assertFileContains(
803+
'public function testSubtract(): void',
804+
$this->generatedFiles[0],
805+
);
806+
}
807+
808+
/**
809+
* Test baking generic Class type with required constructor args
810+
*
811+
* @return void
812+
*/
813+
public function testBakeGenericClassWithRequiredConstructor()
814+
{
815+
$testsPath = ROOT . 'tests' . DS;
816+
$this->generatedFiles = [
817+
$testsPath . 'TestCase/Service/UserServiceTest.php',
818+
];
819+
820+
$this->exec('bake test Class Service\UserService', ['y']);
821+
822+
$this->assertExitCode(CommandInterface::CODE_SUCCESS);
823+
$this->assertFilesExist($this->generatedFiles);
824+
$this->assertFileContains(
825+
'class UserServiceTest extends TestCase',
826+
$this->generatedFiles[0],
827+
);
828+
$this->assertFileNotContains(
829+
'protected UserService $UserService;',
830+
$this->generatedFiles[0],
831+
);
832+
$this->assertFileNotContains(
833+
'protected function setUp(): void',
834+
$this->generatedFiles[0],
835+
);
836+
$this->assertFileNotContains(
837+
'protected function tearDown(): void',
838+
$this->generatedFiles[0],
839+
);
840+
$this->assertFileContains(
841+
'public function testGetUserById(): void',
842+
$this->generatedFiles[0],
843+
);
844+
$this->assertFileContains(
845+
'public function testCreateUser(): void',
846+
$this->generatedFiles[0],
847+
);
848+
}
849+
850+
/**
851+
* Test that Class type generates correct namespace
852+
*
853+
* @return void
854+
*/
855+
public function testBakeGenericClassNamespace()
856+
{
857+
$testsPath = ROOT . 'tests' . DS;
858+
$this->generatedFiles = [
859+
$testsPath . 'TestCase/Service/SimpleCalculatorTest.php',
860+
];
861+
862+
$this->exec('bake test Class Service\SimpleCalculator', ['y']);
863+
864+
$this->assertExitCode(CommandInterface::CODE_SUCCESS);
865+
$this->assertFileContains(
866+
'namespace Bake\Test\App\Test\TestCase\Service;',
867+
$this->generatedFiles[0],
868+
);
869+
$this->assertFileContains(
870+
'class SimpleCalculatorTest extends TestCase',
871+
$this->generatedFiles[0],
872+
);
873+
$this->assertFileNotContains(
874+
'ServiceSimpleCalculator',
875+
$this->generatedFiles[0],
876+
);
877+
}
878+
879+
/**
880+
* Test that Class type handles user including base namespace
881+
*
882+
* @return void
883+
*/
884+
public function testBakeGenericClassWithBaseNamespace()
885+
{
886+
$testsPath = ROOT . 'tests' . DS;
887+
$this->generatedFiles = [
888+
$testsPath . 'TestCase/Service/UserServiceTest.php',
889+
];
890+
891+
// User includes "Bake\Test\App\" in the class name
892+
$this->exec('bake test Class Bake\Test\App\Service\UserService', ['y']);
893+
894+
$this->assertExitCode(CommandInterface::CODE_SUCCESS);
895+
$this->assertFileContains(
896+
'namespace Bake\Test\App\Test\TestCase\Service;',
897+
$this->generatedFiles[0],
898+
);
899+
$this->assertFileContains(
900+
'class UserServiceTest extends TestCase',
901+
$this->generatedFiles[0],
902+
);
903+
// Should not have duplicated namespace
904+
$this->assertFileNotContains(
905+
'namespace Bake\Test\App\Test\TestCase\Bake\Test\App',
906+
$this->generatedFiles[0],
907+
);
908+
$this->assertFileNotContains(
909+
'BakeTestAppService',
910+
$this->generatedFiles[0],
911+
);
912+
}
913+
914+
/**
915+
* Test that Class type validates backslash escaping
916+
*
917+
* @return void
918+
*/
919+
public function testBakeGenericClassValidatesBackslashes()
920+
{
921+
// Simulate what happens when user doesn't quote: App\Error\ErrorLogger
922+
// Bash strips backslashes resulting in: AppErrorErrorLogger
923+
$this->exec('bake test Class AppErrorErrorLogger');
924+
925+
$this->assertExitCode(CommandInterface::CODE_ERROR);
926+
$this->assertErrorContains('Class name appears to have no namespace separators');
927+
$this->assertOutputContains('please use quotes');
928+
$this->assertOutputContains("bin/cake bake test class 'AppErrorErrorLogger'");
929+
}
764930
}

0 commit comments

Comments
 (0)