From 8dd533fe5203cd46463c47990cf0946f9249a12a Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 18 Sep 2026 16:43:07 +0800 Subject: [PATCH] refactor: migrate `make:scaffold` as modern command --- phpstan.dist.neon | 3 + .../Commands/Generators/ScaffoldGenerator.php | 193 +++++++++--------- .../Generators/ScaffoldGeneratorTest.php | 183 +++++++++-------- user_guide_src/source/changelogs/v4.8.0.rst | 2 + user_guide_src/source/cli/cli_generators.rst | 4 + 5 files changed, 208 insertions(+), 177 deletions(-) diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 7d6f2d81a709..37cc5719dc84 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -31,6 +31,9 @@ parameters: ignoreErrors: - identifier: missingType.generics + - + identifier: trait.unused + path: system/CLI/GeneratorTrait.php checkMissingCallableSignature: true treatPhpDocTypesAsCertain: false strictRules: diff --git a/system/Commands/Generators/ScaffoldGenerator.php b/system/Commands/Generators/ScaffoldGenerator.php index 0f26aa210aff..bfcb6f05f6ef 100644 --- a/system/Commands/Generators/ScaffoldGenerator.php +++ b/system/Commands/Generators/ScaffoldGenerator.php @@ -13,117 +13,120 @@ namespace CodeIgniter\Commands\Generators; -use CodeIgniter\CLI\BaseCommand; -use CodeIgniter\CLI\CLI; -use CodeIgniter\CLI\GeneratorTrait; - -/** - * Generates a complete set of scaffold files. - */ -class ScaffoldGenerator extends BaseCommand +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\CLI\PromptsForMissingInputInterface; + +#[Command(name: 'make:scaffold', description: 'Generates a complete set of scaffold files.', group: 'Generators')] +class ScaffoldGenerator extends AbstractCommand implements PromptsForMissingInputInterface { - use GeneratorTrait; + protected function configure(): void + { + $this + ->addArgument(new Argument(name: 'name', description: 'The class name.', required: true)) + ->addOption(new Option( + name: 'bare', + shortcut: 'b', + description: 'Pass "--bare" to the controller.', + )) + ->addOption(new Option( + name: 'restful', + description: 'Pass "--restful" to the controller.', + acceptsValue: true, + valueLabel: 'type', + )) + ->addOption(new Option( + name: 'table', + shortcut: 't', + description: 'Pass "--table" to the model.', + acceptsValue: true, + valueLabel: 'name', + )) + ->addOption(new Option( + name: 'dbgroup', + shortcut: 'g', + description: 'Pass "--dbgroup" to the model.', + acceptsValue: true, + valueLabel: 'group', + )) + ->addOption(new Option( + name: 'return', + description: 'Pass "--return" to the model.', + acceptsValue: true, + valueLabel: 'type', + )) + ->addOption(new Option( + name: 'namespace', + shortcut: 'n', + description: 'Set the root namespace.', + requiresValue: true, + default: APP_NAMESPACE, + )) + ->addOption(new Option( + name: 'suffix', + shortcut: 's', + description: 'Append the component suffix to each class name.', + )) + ->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Force overwrite existing files.', + )); + } - /** - * The Command's Group - * - * @var string - */ - protected $group = 'Generators'; + protected function getArgumentPromptLabels(): array + { + return ['name' => lang('CLI.generator.className.default')]; + } - /** - * The Command's Name - * - * @var string - */ - protected $name = 'make:scaffold'; + protected function execute(array $arguments, array $options): int + { + $name = [$arguments['name']]; + $shared = ['namespace' => $options['namespace']]; - /** - * The Command's Description - * - * @var string - */ - protected $description = 'Generates a complete set of scaffold files.'; + if ($options['suffix'] === true) { + $shared['suffix'] = null; + } - /** - * The Command's Usage - * - * @var string - */ - protected $usage = 'make:scaffold [options]'; + $forced = $options['force'] === true ? $shared + ['force' => null] : $shared; - /** - * The Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'name' => 'The class name', - ]; + return $this->call('make:controller', $name, $this->getControllerOptions($options) + $forced) + | $this->call('make:model', $name, $this->getModelOptions($options) + $forced) + | $this->call('make:migration', $name, $shared) + | $this->call('make:seeder', $name, $forced); + } /** - * The Command's Options + * @param array $options * - * @var array - */ - protected $options = [ - '--bare' => 'Add the "--bare" option to controller component.', - '--restful' => 'Add the "--restful" option to controller component.', - '--table' => 'Add the "--table" option to the model component.', - '--dbgroup' => 'Add the "--dbgroup" option to model component.', - '--return' => 'Add the "--return" option to the model component.', - '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', - '--suffix' => 'Append the component title to the class name.', - '--force' => 'Force overwrite existing file.', - ]; - - /** - * Actually execute a command. + * @return array */ - public function run(array $params) + private function getControllerOptions(array $options): array { - $this->params = $params; - - $options = []; - - if ($this->getOption('namespace')) { - $options['namespace'] = $this->getOption('namespace'); + if ($options['bare'] === true) { + return ['bare' => null]; } - if ($this->getOption('suffix')) { - $options['suffix'] = null; + if (! $this->hasUnboundOption('restful')) { + return []; } - if ($this->getOption('force')) { - $options['force'] = null; - } - - $controllerOpts = []; - - if ($this->getOption('bare')) { - $controllerOpts['bare'] = null; - } elseif ($this->getOption('restful')) { - $restful = $this->getOption('restful'); - - $controllerOpts['restful'] = is_string($restful) ? $restful : null; - } + return ['restful' => is_string($options['restful']) ? $options['restful'] : null]; + } - $modelOpts = array_filter([ - 'table' => $this->getOption('table'), - 'dbgroup' => $this->getOption('dbgroup'), - 'return' => $this->getOption('return'), + /** + * @param array $options + * + * @return array + */ + private function getModelOptions(array $options): array + { + return array_filter([ + 'table' => $options['table'], + 'dbgroup' => $options['dbgroup'], + 'return' => $options['return'], ], is_string(...)); - - $class = $params[0] ?? CLI::getSegment(2); - - // Call those commands! - $exit1 = $this->call('make:controller', array_merge([$class], $controllerOpts, $options)); - $exit2 = $this->call('make:model', array_merge([$class], $modelOpts, $options)); - $exit3 = $this->call('make:migration', array_merge([$class], array_diff_key($options, ['force' => null]))); - $exit4 = $this->call('make:seeder', array_merge([$class], $options)); - - assert(is_int($exit1) && is_int($exit2) && is_int($exit3) && is_int($exit4)); - - return $exit1 | $exit2 | $exit3 | $exit4; } } diff --git a/tests/system/Commands/Generators/ScaffoldGeneratorTest.php b/tests/system/Commands/Generators/ScaffoldGeneratorTest.php index 6ea74bb3c18f..1ad83ae859b2 100644 --- a/tests/system/Commands/Generators/ScaffoldGeneratorTest.php +++ b/tests/system/Commands/Generators/ScaffoldGeneratorTest.php @@ -15,9 +15,8 @@ use CodeIgniter\CLI\CLI; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; -use Config\Autoload; -use Config\Modules; use PHPUnit\Framework\Attributes\Group; /** @@ -28,136 +27,156 @@ final class ScaffoldGeneratorTest extends CIUnitTestCase { use StreamFilterTrait; + private const TIMESTAMP = '\d{4}-\d{2}-\d{2}-\d{6}'; + protected function setUp(): void { - $this->resetServices(); - CLI::init(); - service('autoloader')->initialize(new Autoload(), new Modules()); - parent::setUp(); - $this->removeGeneratedFiles(); - $this->resetStreamFilterBuffer(); + CLI::reset(); } protected function tearDown(): void { parent::tearDown(); - $this->removeGeneratedFiles(); - } - - private function removeGeneratedFiles(): void - { - preg_match_all('/File (?:created|overwritten): "?(APPPATH[^"\r\n\x1b]+)/', $this->getStreamFilterBuffer(), $matches); + CLI::reset(); - foreach ($matches[1] as $file) { - $path = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $file); - - if (is_file($path)) { - @unlink($path); + foreach (['People', 'User', 'Order', 'Fixer', 'Product'] as $name) { + foreach (['Controllers', 'Models', 'Entities', 'Database/Seeds', 'Database/Migrations'] as $dir) { + foreach (glob(sprintf('%s%s/*%s*.php', APPPATH, $dir, $name)) as $file) { + unlink($file); + } } + } - $dir = dirname($path); - $dirFiles = is_dir($dir) ? scandir($dir) : false; - - if (str_starts_with($dir, APPPATH) && $dirFiles !== false && count($dirFiles) === 2) { - @rmdir($dir); - } + if (is_dir(APPPATH . 'Entities')) { + rmdir(APPPATH . 'Entities'); } } - protected function getFileContents(string $filepath): string + private function getUndecoratedBuffer(): string { - if (! is_file($filepath)) { - return ''; - } + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + private function getContents(string $file): string + { + $contents = file_get_contents(APPPATH . $file); + $this->assertIsString($contents); - return (string) file_get_contents($filepath); + return $contents; } public function testCreateComponentProducesManyFiles(): void { command('make:scaffold people'); - // Files check - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Controllers/People.php'); - $this->assertFileExists(APPPATH . 'Models/People.php'); - $this->assertStringContainsString('_People.php', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Database/Seeds/People.php'); + $this->assertMatchesRegularExpression( + sprintf( + "#^\nFile created: APPPATH/Controllers/People\\.php\nFile created: APPPATH/Models/People\\.php\n" + . "File created: APPPATH/Database/Migrations/%s_People\\.php\nFile created: APPPATH/Database/Seeds/People\\.php\n$#", + self::TIMESTAMP, + ), + $this->getUndecoratedBuffer(), + ); } public function testCreateComponentWithManyOptions(): void { - command('make:scaffold user -restful -return entity'); + command('make:scaffold user --restful --return entity --table members --dbgroup tests'); + + $this->assertStringContainsString('class User extends ResourceController', $this->getContents('Controllers/User.php')); + + $model = $this->getContents('Models/User.php'); + $this->assertStringContainsString("protected \$DBGroup = 'tests';", $model); + $this->assertStringContainsString("protected \$table = 'members';", $model); + $this->assertStringContainsString('protected $returnType = \App\Entities\User::class;', $model); - // Files check - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Controllers/User.php'); - $this->assertStringContainsString('_User.php', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Database/Seeds/User.php'); $this->assertFileExists(APPPATH . 'Entities/User.php'); - $this->assertFileExists(APPPATH . 'Models/User.php'); + $this->assertFileExists(APPPATH . 'Database/Seeds/User.php'); + $this->assertCount(1, glob(APPPATH . 'Database/Migrations/*_User.php')); + } - // Options check - $this->assertStringContainsString('extends ResourceController', $this->getFileContents(APPPATH . 'Controllers/User.php')); + public function testCreateComponentWithRestfulPresenter(): void + { + command('make:scaffold user --restful presenter'); + + $this->assertStringContainsString('class User extends ResourcePresenter', $this->getContents('Controllers/User.php')); } public function testCreateComponentWithOptionSuffix(): void { - command('make:scaffold order -suffix'); + command('make:scaffold order -s'); - // Files check - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); $this->assertFileExists(APPPATH . 'Controllers/OrderController.php'); - $this->assertStringContainsString('_OrderMigration.php', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Database/Seeds/OrderSeeder.php'); $this->assertFileExists(APPPATH . 'Models/OrderModel.php'); + $this->assertFileExists(APPPATH . 'Database/Seeds/OrderSeeder.php'); + $this->assertCount(1, glob(APPPATH . 'Database/Migrations/*_OrderMigration.php')); } public function testCreateComponentWithOptionForce(): void { command('make:controller fixer'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $this->assertStringContainsString('extends BaseController', $this->getFileContents(APPPATH . 'Controllers/Fixer.php')); - $this->assertFileExists(APPPATH . 'Controllers/Fixer.php'); + $this->assertStringContainsString('class Fixer extends BaseController', $this->getContents('Controllers/Fixer.php')); $this->resetStreamFilterBuffer(); - command('make:scaffold fixer -bare -force'); + command('make:scaffold fixer -b -f'); + + $this->assertMatchesRegularExpression( + sprintf( + "#^File overwritten: \"APPPATH/Controllers/Fixer\\.php\"\nFile created: APPPATH/Models/Fixer\\.php\n" + . "File created: APPPATH/Database/Migrations/%s_Fixer\\.php\nFile created: APPPATH/Database/Seeds/Fixer\\.php\n$#", + self::TIMESTAMP, + ), + $this->getUndecoratedBuffer(), + ); + $this->assertStringContainsString('class Fixer extends Controller', $this->getContents('Controllers/Fixer.php')); + } + + public function testExistingFilesFailWithoutForce(): void + { + command('make:scaffold people'); + $this->resetStreamFilterBuffer(); - // Files check - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Controllers/Fixer.php'); - $this->assertStringContainsString('_Fixer.php', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Database/Seeds/Fixer.php'); - $this->assertFileExists(APPPATH . 'Models/Fixer.php'); + command('make:scaffold people'); - // Options check - $this->assertStringContainsString('extends Controller', $this->getFileContents(APPPATH . 'Controllers/Fixer.php')); - $this->assertStringContainsString('File overwritten: ', $this->getStreamFilterBuffer()); + $this->assertMatchesRegularExpression( + sprintf( + "#^File exists: \"APPPATH/Controllers/People\\.php\"\nFile exists: \"APPPATH/Models/People\\.php\"\n" + . "File (?:created: |exists: \")APPPATH/Database/Migrations/%s_People\\.php\"?\nFile exists: \"APPPATH/Database/Seeds/People\\.php\"\n$#", + self::TIMESTAMP, + ), + $this->getUndecoratedBuffer(), + ); } public function testCreateComponentWithOptionNamespace(): void { - command('make:scaffold product -namespace App'); - - $dir = '\\' . DIRECTORY_SEPARATOR; - $migration = "APPPATH{$dir}Database{$dir}Migrations{$dir}(.*)\\.php"; - preg_match('/' . $migration . '/u', $this->getStreamFilterBuffer(), $matches); - $matches[0] = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, $matches[0]); - - // Files check - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Controllers/Product.php'); - $this->assertStringContainsString('_Product.php', $this->getStreamFilterBuffer()); - $this->assertFileExists(APPPATH . 'Database/Seeds/Product.php'); - $this->assertFileExists(APPPATH . 'Models/Product.php'); - - // Options check - $this->assertStringContainsString('namespace App\Controllers;', $this->getFileContents(APPPATH . 'Controllers/Product.php')); - $this->assertStringContainsString('namespace App\Database\Migrations;', $this->getFileContents($matches[0])); - $this->assertStringContainsString('namespace App\Database\Seeds;', $this->getFileContents(APPPATH . 'Database/Seeds/Product.php')); - $this->assertStringContainsString('namespace App\Models;', $this->getFileContents(APPPATH . 'Models/Product.php')); + command('make:scaffold product -n App'); + + $this->assertStringContainsString('namespace App\Controllers;', $this->getContents('Controllers/Product.php')); + $this->assertStringContainsString('namespace App\Models;', $this->getContents('Models/Product.php')); + $this->assertStringContainsString('namespace App\Database\Seeds;', $this->getContents('Database/Seeds/Product.php')); + + $migrations = glob(APPPATH . 'Database/Migrations/*_Product.php'); + $this->assertCount(1, $migrations); + + $migration = file_get_contents($migrations[0]); + $this->assertIsString($migration); + $this->assertStringContainsString('namespace App\Database\Migrations;', $migration); + } + + public function testPromptsOnceForMissingName(): void + { + $io = new MockInputOutput(); + $io->setInputs(['people']); + CLI::setInputOutput($io); + + command('make:scaffold'); + + $this->assertSame(1, substr_count($io->getOutput(), 'Class name')); + $this->assertFileExists(APPPATH . 'Controllers/People.php'); + $this->assertFileExists(APPPATH . 'Database/Seeds/People.php'); } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index a7dac4c93afe..89d0c3c7e154 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -248,6 +248,8 @@ Commands - The ``make:command`` command now accepts ``-c``, ``-t``, and ``-g`` as shortcuts for ``--command``, ``--type``, and ``--group``, and rejects an invalid ``--type`` value on non-interactive runs instead of prompting. - The ``make:migration`` command now accepts ``-t`` and ``-g`` as shortcuts for ``--table`` and ``--dbgroup``. +- The ``make:scaffold`` command now accepts ``-b``, ``-t``, and ``-g`` as shortcuts for ``--bare``, ``--table``, and ``--dbgroup``, and asks for a + missing class name once instead of once per generated file. - **spark** now offers to run the closest matching command when a command name is mistyped on an interactive run. See :ref:`correcting-a-mistyped-command`. - Modern commands can now opt in to prompting for missing required arguments on interactive runs by implementing the new diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index 1a1d4743028b..6021eb4d9bcb 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -376,6 +376,10 @@ will create the following files: To include an ``Entity`` class in the scaffolded files, just include the ``--return entity`` to the command and it will be passed to the model generator. +.. note:: Since v4.8.0, ``make:scaffold`` accepts the ``-b``, ``-t``, ``-g``, ``-n``, ``-s``, and ``-f`` shortcuts. + ``--restful`` and ``--return`` have no shortcut here, since both use ``-r`` in their own commands. + When the class name is omitted on an interactive run, it is asked for once and reused for every generated file. + ************** GeneratorTrait **************