diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php index 55f2cec2ae4c..b3a0f7c056ac 100644 --- a/system/Commands/Generators/ModelGenerator.php +++ b/system/Commands/Generators/ModelGenerator.php @@ -13,132 +13,121 @@ namespace CodeIgniter\Commands\Generators; -use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\AbstractGeneratorCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Attributes\GeneratorCommand; use CodeIgniter\CLI\CLI; -use CodeIgniter\CLI\GeneratorTrait; - -/** - * Generates a skeleton Model file. - */ -class ModelGenerator extends BaseCommand +use CodeIgniter\CLI\Input\Option; + +#[Command(name: 'make:model', description: 'Generates a new model file.', group: 'Generators')] +#[GeneratorCommand( + component: 'Model', + template: 'model.tpl.php', + directory: 'Models', + classNameLang: 'CLI.generator.className.model', +)] +class ModelGenerator extends AbstractGeneratorCommand { - use GeneratorTrait; - - /** - * The Command's Group - * - * @var string - */ - protected $group = 'Generators'; - - /** - * The Command's Name - * - * @var string - */ - protected $name = 'make:model'; - - /** - * The Command's Description - * - * @var string - */ - protected $description = 'Generates a new model file.'; - - /** - * The Command's Usage - * - * @var string - */ - protected $usage = 'make:model [options]'; + private const RETURN_TYPES = ['array', 'object', 'entity']; - /** - * The Command's Arguments - * - * @var array - */ - protected $arguments = [ - 'name' => 'The model class name.', - ]; - - /** - * The Command's Options - * - * @var array - */ - protected $options = [ - '--table' => 'Supply a table name. Default: "the lowercased plural of the class name".', - '--dbgroup' => 'Database group to use. Default: "default".', - '--return' => 'Return type, Options: [array, object, entity]. Default: "array".', - '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', - '--suffix' => 'Append the component title to the class name (e.g. User => UserModel).', - '--force' => 'Force overwrite existing file.', - ]; + protected function configure(): void + { + parent::configure(); + + $this + ->addOption(new Option( + name: 'table', + shortcut: 't', + description: 'Table name. Defaults to the lowercased plural of the class name.', + acceptsValue: true, + valueLabel: 'name', + )) + ->addOption(new Option( + name: 'dbgroup', + shortcut: 'g', + description: 'Database group to use.', + acceptsValue: true, + valueLabel: 'group', + )) + ->addOption(new Option( + name: 'return', + shortcut: 'r', + description: 'Return type: "array", "object", or "entity".', + requiresValue: true, + valueLabel: 'type', + default: 'array', + )); + } - /** - * Actually execute a command. - */ - public function run(array $params) + protected function interact(array &$arguments, array &$options): void { - $this->component = 'Model'; - $this->directory = 'Models'; - $this->template = 'model.tpl.php'; + $return = $this->getUnboundOption('return', $options); - $this->classNameLang = 'CLI.generator.className.model'; - $this->generateClass($params); + if (! is_string($return) || in_array($return, self::RETURN_TYPES, true)) { + return; + } - return EXIT_SUCCESS; + $options['return'] = CLI::prompt(lang('CLI.generator.returnType'), self::RETURN_TYPES, 'required'); } - /** - * Prepare options and do the necessary replacements. - */ - protected function prepare(string $class): string + protected function execute(array $arguments, array $options): int { - $table = $this->getOption('table'); - $dbGroup = $this->getOption('dbgroup'); - $return = $this->getOption('return'); + $return = $this->getValidatedOption('return'); - $baseClass = class_basename($class); + if (! in_array($return, self::RETURN_TYPES, true)) { + CLI::error(lang('CLI.generator.invalidReturnType', [$return])); - if (preg_match('/^(\S+)Model$/i', $baseClass, $match) === 1) { - $baseClass = $match[1]; + return EXIT_ERROR; } - $table = is_string($table) ? $table : plural(strtolower($baseClass)); - $return = is_string($return) ? $return : 'array'; + $exitCode = $this->generateClass(); - if (! in_array($return, ['array', 'object', 'entity'], true)) { - // @codeCoverageIgnoreStart - $return = CLI::prompt(lang('CLI.generator.returnType'), ['array', 'object', 'entity'], 'required'); - CLI::newLine(); - // @codeCoverageIgnoreEnd + if ($exitCode !== EXIT_SUCCESS || $return !== 'entity') { + return $exitCode; } - if ($return === 'entity') { - // Build the fully-qualified entity class from the model class so - // that the generated Entity keeps any sub-namespaces (eg. Admin). - $entityClass = str_replace('Models', 'Entities', $class); + $entityOptions = ['namespace' => $this->getValidatedOption('namespace')]; - if (preg_match('/^(\S+)Model$/i', $entityClass, $match) === 1) { - $entityClass = $match[1]; + if ($this->getValidatedOption('force') === true) { + $entityOptions['force'] = null; + } + + return $this->call('make:entity', [$this->getEntityClass($this->qualifyClassName())], $entityOptions); + } - if ($this->getOption('suffix')) { - $entityClass .= 'Entity'; - } - } + protected function getReplacements(string $class): array + { + $table = $this->getValidatedOption('table'); + $dbGroup = $this->getValidatedOption('dbgroup'); + + $return = $this->getValidatedOption('return') === 'entity' + ? '\\' . $this->getEntityClass($class) . '::class' + : sprintf("'%s'", $this->getValidatedOption('return')); + + return [ + '{dbGroup}' => is_string($dbGroup) ? $dbGroup : '', + '{table}' => is_string($table) ? $table : plural(strtolower($this->stripModelSuffix(class_basename($class)))), + '{return}' => $return, + ]; + } - // Call the entity generator with the fully-qualified class name so - // it ends up under the correct sub-namespace/folder (eg. Admin). - $entityOptions = array_intersect_key($this->params, array_flip(['namespace', 'suffix', 'force'])); + protected function getTemplateData(string $class): array + { + return ['dbGroup' => $this->getValidatedOption('dbgroup')]; + } - $this->call('make:entity', array_merge([trim($entityClass, '\\')], $entityOptions)); + /** + * Derives the entity class from the qualified model class, keeping any sub-namespace. + */ + private function getEntityClass(string $class): string + { + $entity = $this->stripModelSuffix(str_replace('\\Models\\', '\\Entities\\', $class)); - $return = '\\' . trim($entityClass, '\\') . '::class'; - } else { - $return = "'{$return}'"; - } + return $this->shouldAppendSuffix() ? $entity . 'Entity' : $entity; + } - return $this->parseTemplate($class, ['{dbGroup}', '{table}', '{return}'], [$dbGroup, $table, $return], compact('dbGroup')); + private function stripModelSuffix(string $class): string + { + return preg_replace('/^(.+)Model$/i', '$1', $class) ?? $class; } } diff --git a/system/Commands/Generators/ScaffoldGenerator.php b/system/Commands/Generators/ScaffoldGenerator.php index a5d3d62632e1..d6f65060cd82 100644 --- a/system/Commands/Generators/ScaffoldGenerator.php +++ b/system/Commands/Generators/ScaffoldGenerator.php @@ -108,11 +108,11 @@ public function run(array $params) $controllerOpts['restful'] = is_string($restful) ? $restful : null; } - $modelOpts = [ + $modelOpts = array_filter([ 'table' => $this->getOption('table'), 'dbgroup' => $this->getOption('dbgroup'), 'return' => $this->getOption('return'), - ]; + ], is_string(...)); $class = $params[0] ?? CLI::getSegment(2); diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 9a9753fd08d9..11a0cb4b1d39 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -45,6 +45,7 @@ 'fileOverwrite' => 'File overwritten: "{0}"', 'invalidClassName' => 'Class name "{0}" is not valid.', 'invalidParentClass' => 'Parent class "{0}" is not valid.', + 'invalidReturnType' => 'Return type "{0}" is not valid.', 'parentClass' => 'Parent class', 'returnType' => 'Return type', 'tableName' => 'Table name', diff --git a/tests/system/Commands/Generators/ModelGeneratorTest.php b/tests/system/Commands/Generators/ModelGeneratorTest.php index 89932f68111a..3d7a18224e1d 100644 --- a/tests/system/Commands/Generators/ModelGeneratorTest.php +++ b/tests/system/Commands/Generators/ModelGeneratorTest.php @@ -13,7 +13,10 @@ namespace CodeIgniter\Commands\Generators; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Commands; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\Group; @@ -25,140 +28,132 @@ final class ModelGeneratorTest extends CIUnitTestCase { use StreamFilterTrait; + protected function setUp(): void + { + parent::setUp(); + + CLI::reset(); + } + protected function tearDown(): void { parent::tearDown(); - $result = str_replace(["\033[0;32m", "\033[0m", "\n"], '', $this->getStreamFilterBuffer()); - $file = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, trim(substr($result, 14))); + CLI::reset(); + + foreach (['User.php', 'Cars.php', 'UserModel.php', 'MyTableModel.php', 'Bogus.php'] as $file) { + if (is_file(APPPATH . 'Models/' . $file)) { + unlink(APPPATH . 'Models/' . $file); + } + } + + helper('filesystem'); - if (is_file($file)) { - unlink($file); + foreach ([APPPATH . 'Models/Admin', APPPATH . 'Entities'] as $dir) { + if (is_dir($dir)) { + delete_files($dir, true, false, true); + rmdir($dir); + } } } - private function getFileContent(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 testGenerateModel(): void { - command('make:model user --table users'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Models/User.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('extends Model', $this->getFileContent($file)); - $this->assertStringContainsString('protected $table = \'users\';', $this->getFileContent($file)); - $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); + command('make:model user'); + + $this->assertSame("\nFile created: APPPATH/Models/User.php\n", $this->getUndecoratedBuffer()); + + $contents = $this->getContents('Models/User.php'); + $this->assertStringContainsString('class User extends Model', $contents); + $this->assertStringContainsString("protected \$table = 'users';", $contents); + $this->assertStringContainsString("protected \$returnType = 'array';", $contents); + $this->assertStringNotContainsString('$DBGroup', $contents); } public function testGenerateModelWithOptionTable(): void { - command('make:model cars -table utilisateur'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Models/Cars.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('protected $table = \'utilisateur\';', $this->getFileContent($file)); + command('make:model cars --table utilisateur'); + + $this->assertStringContainsString("protected \$table = 'utilisateur';", $this->getContents('Models/Cars.php')); } public function testGenerateModelWithOptionDBGroup(): void { - command('make:model user -dbgroup testing'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Models/User.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('protected $DBGroup = \'testing\';', $this->getFileContent($file)); - } + command('make:model user --dbgroup testing'); - public function testGenerateModelWithOptionReturnArray(): void - { - command('make:model user --return array'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Models/User.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \'array\';', $this->getFileContent($file)); + $this->assertStringContainsString("protected \$DBGroup = 'testing';", $this->getContents('Models/User.php')); } public function testGenerateModelWithOptionReturnObject(): void { command('make:model user --return object'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Models/User.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \'object\';', $this->getFileContent($file)); + + $this->assertStringContainsString("protected \$returnType = 'object';", $this->getContents('Models/User.php')); } public function testGenerateModelWithOptionReturnEntity(): void { command('make:model user --return entity'); - $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); - $file = APPPATH . 'Models/User.php'; - $this->assertFileExists($file); - $this->assertStringContainsString('protected $returnType = \App\Entities\User::class;', $this->getFileContent($file)); + $this->assertSame( + <<<'EOT' - if (is_file($file)) { - unlink($file); - } + File created: APPPATH/Models/User.php + File created: APPPATH/Entities/User.php - $file = APPPATH . 'Entities/User.php'; - $this->assertFileExists($file); - $dir = dirname($file); + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertStringContainsString( + 'protected $returnType = \App\Entities\User::class;', + $this->getContents('Models/User.php'), + ); + $this->assertStringContainsString('class User extends Entity', $this->getContents('Entities/User.php')); + } - if (is_file($file)) { - unlink($file); - } + public function testGenerateModelWithShortcuts(): void + { + command('make:model user -t people -g testing -r object'); - if (is_dir($dir)) { - rmdir($dir); - } + $contents = $this->getContents('Models/User.php'); + $this->assertStringContainsString("protected \$DBGroup = 'testing';", $contents); + $this->assertStringContainsString("protected \$table = 'people';", $contents); + $this->assertStringContainsString("protected \$returnType = 'object';", $contents); } public function testGenerateModelWithOptionSuffix(): void { command('make:model user --suffix --return entity'); - $model = APPPATH . 'Models/UserModel.php'; - $entity = APPPATH . 'Entities/UserEntity.php'; - - $this->assertFileExists($model); - $this->assertFileExists($entity); - - unlink($model); - unlink($entity); - rmdir(dirname($entity)); + $this->assertStringContainsString( + 'protected $returnType = \App\Entities\UserEntity::class;', + $this->getContents('Models/UserModel.php'), + ); + $this->assertFileExists(APPPATH . 'Entities/UserEntity.php'); } public function testGenerateModelWithSubNamespaceAndReturnEntity(): void { command('make:model admin/class --return entity'); - $model = APPPATH . 'Models/Admin/Class.php'; - $entity = APPPATH . 'Entities/Admin/Class.php'; - - $this->assertFileExists($model); - $this->assertFileExists($entity); - - if (is_file($model)) { - unlink($model); - } - $modelDir = dirname($model); - if (is_dir($modelDir)) { - rmdir($modelDir); - } - - if (is_file($entity)) { - unlink($entity); - } - $entityDir = dirname($entity); - if (is_dir($entityDir)) { - rmdir($entityDir); - } + $this->assertStringContainsString( + 'protected $returnType = \App\Entities\Admin\Class::class;', + $this->getContents('Models/Admin/Class.php'), + ); + $this->assertStringContainsString('namespace App\Entities\Admin;', $this->getContents('Entities/Admin/Class.php')); } /** @@ -168,14 +163,57 @@ public function testGenerateModelWithSuffixAndMixedPascalCasedName(): void { command('make:model MyTable --suffix --return entity'); - $model = APPPATH . 'Models/MyTableModel.php'; - $entity = APPPATH . 'Entities/MyTableEntity.php'; + $this->assertFileExists(APPPATH . 'Models/MyTableModel.php'); + $this->assertFileExists(APPPATH . 'Entities/MyTableEntity.php'); + } + + public function testEntityIsNotGeneratedWhenModelExists(): void + { + command('make:model user'); + $this->resetStreamFilterBuffer(); + + command('make:model user --return entity'); + + $this->assertSame("File exists: \"APPPATH/Models/User.php\"\n", $this->getUndecoratedBuffer()); + $this->assertFileDoesNotExist(APPPATH . 'Entities/User.php'); + } + + public function testForceIsForwardedToEntity(): void + { + command('make:model user --return entity'); + $this->resetStreamFilterBuffer(); + + command('make:model user --return entity --force'); + + $this->assertSame( + <<<'EOT' + File overwritten: "APPPATH/Models/User.php" + File overwritten: "APPPATH/Entities/User.php" + + EOT, + $this->getUndecoratedBuffer(), + ); + } + + public function testInvalidReturnTypeIsRejectedWhenNotInteractive(): void + { + command('make:model bogus --return json --no-interaction'); + + $this->assertSame("\nReturn type \"json\" is not valid.\n", $this->getUndecoratedBuffer()); + $this->assertFileDoesNotExist(APPPATH . 'Models/Bogus.php'); + } + + public function testInvalidReturnTypePromptsWhenInteractive(): void + { + $io = new MockInputOutput(); + $io->setInputs(['object']); + CLI::setInputOutput($io); - $this->assertFileExists($model); - $this->assertFileExists($entity); + $command = new ModelGenerator(new Commands()); + $command->setInteractive(true); - unlink($model); - unlink($entity); - rmdir(dirname($entity)); + $this->assertSame(EXIT_SUCCESS, $command->run(['user'], ['return' => 'json'])); + $this->assertStringContainsString('Return type', $io->getOutput()); + $this->assertStringContainsString("protected \$returnType = 'object';", $this->getContents('Models/User.php')); } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index bb597e684bc4..aa991d5e0880 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -238,6 +238,8 @@ Commands options, which the target command validates like any other input. - The ``make:controller`` command now accepts ``-b`` and ``-r`` as shortcuts for ``--bare`` and ``--restful``, and rejects an invalid ``--restful`` value on non-interactive runs instead of prompting. +- The ``make:model`` command now accepts ``-t``, ``-g``, and ``-r`` as shortcuts for ``--table``, ``--dbgroup``, and ``--return``, and rejects an + invalid ``--return`` value on non-interactive runs instead of prompting. - Modern commands can now opt in to prompting for missing required arguments on interactive runs by implementing the new ``PromptsForMissingInputInterface`` marker interface. Prompt labels can be customized via ``getArgumentPromptLabels()``, and an ``afterPrompting()`` hook runs when prompting occurred. Non-interactive runs keep failing fast with the missing-arguments error. @@ -387,6 +389,7 @@ Message Changes - Added new language keys: - ``Cache.unsupportedLockStore`` (``CacheException::forUnsupportedLockStore()``) - ``CLI.commandAlias`` and ``CLI.helpAliases`` (command alias rendering in ``list`` and ``help``) + - ``CLI.generator.invalidReturnType`` (``make:model`` rejecting an invalid ``--return``) - ``Commands.invalidCommandAlias``, ``Commands.commandAliasSameAsName``, ``Commands.duplicateCommandAlias``, ``Commands.aliasClashesWithCommandName``, and ``Commands.aliasClashesWithAlias`` (command alias validation) - Removed deprecated language keys tied to removed exception constructors: diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index a5b1283fa02f..cd72b986d6dd 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -201,12 +201,12 @@ Argument: Options: ======== -* ``--dbgroup``: Database group to use. Defaults to ``default``. -* ``--return``: Set the return type from ``array``, ``object``, or ``entity``. Defaults to ``array``. -* ``--table``: Supply a different table name. Defaults to the pluralized class name. -* ``--namespace``: Set the root namespace. Defaults to value of ``APP_NAMESPACE``. -* ``--suffix``: Append the component suffix to the generated class name. -* ``--force``: Set this flag to overwrite existing files on destination. +* ``--table`` (``-t``): Supply a different table name. Defaults to the pluralized class name. +* ``--dbgroup`` (``-g``): Database group to use. Defaults to ``default``. +* ``--return`` (``-r``): Set the return type from ``array``, ``object``, or ``entity``. Defaults to ``array``. +* ``--namespace`` (``-n``): Set the root namespace. Defaults to value of ``APP_NAMESPACE``. +* ``--suffix`` (``-s``): Append the component suffix to the generated class name. +* ``--force`` (``-f``): Set this flag to overwrite existing files on destination. make:request ------------