From 07520cd0d6fa6cdf061ea6a1456d141dde97b6a9 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 28 Dec 2023 18:59:45 +0100 Subject: [PATCH 01/10] Complete baking of enums. --- src/Command/EnumCommand.php | 90 +++++++++++++++- src/Command/ModelCommand.php | 100 +++++++++++++++++- templates/bake/Model/enum.twig | 6 +- tests/comparisons/Model/testBakeEnum.php | 2 +- .../Model/testBakeEnumBackedInt.php | 2 +- 5 files changed, 191 insertions(+), 9 deletions(-) diff --git a/src/Command/EnumCommand.php b/src/Command/EnumCommand.php index 2118450e..16d3fa49 100644 --- a/src/Command/EnumCommand.php +++ b/src/Command/EnumCommand.php @@ -18,6 +18,8 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleOptionParser; +use Cake\Utility\Inflector; +use InvalidArgumentException; /** * Enum code generator. @@ -64,8 +66,20 @@ public function template(): string */ public function templateData(Arguments $arguments): array { + $cases = $this->parseCases($arguments->getArgument('cases'), (bool)$arguments->getOption('int')); + $isOfTypeInt = $this->isOfTypeInt($cases); + $backingType = $isOfTypeInt ? 'int' : 'string'; + if ($arguments->getOption('int')) { + if ($cases && !$isOfTypeInt) { + throw new InvalidArgumentException('The cases provided do not seem to match the int type you want to bake'); + } + + $backingType = 'int'; + } + $data = parent::templateData($arguments); - $data['backingType'] = $arguments->getOption('int') ? 'int' : 'string'; + $data['backingType'] = $backingType; + $data['cases'] = $this->formatCases($cases); return $data; } @@ -82,12 +96,82 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar $parser->setDescription( 'Bake backed enums for use in models.' - )->addOption('int', [ - 'help' => 'Using backed enums with int instead of string as return type', + )->addArgument('name', [ + 'help' => 'Name of the enum to bake. You can use Plugin.name to bake plugin enums.', + 'required' => true, + ])->addArgument('cases', [ + 'help' => 'List of either `one,two` for string or `0:foo,1:bar` for int type.', + ])->addOption('int', [ + 'help' => 'Using backed enums with int instead of string as return type.', 'boolean' => true, 'short' => 'i', ]); return $parser; } + + /** + * @param string|null $casesString + * @return array + */ + protected function parseCases(?string $casesString, bool $int): array + { + if ($casesString === null) { + return []; + } + + $enumCases = explode(',', $casesString); + + $definition = []; + foreach ($enumCases as $k => $enumCase) { + $key = $value = trim($enumCase); + if (str_contains($key, ':')) { + $value = trim(mb_substr($key, strpos($key, ':') + 1)); + $key = mb_substr($key, 0, strpos($key, ':')); + } elseif ($int) { + $key = $k; + } + + $definition[$key] = $value; + } + + return $definition; + } + + /** + * @param array $definition + * @return bool + */ + protected function isOfTypeInt(array $definition): bool + { + if (!$definition) { + return false; + } + + foreach ($definition as $key => $value) { + if (!is_int($key)) { + return false; + } + } + + return true; + } + + /** + * @param array $cases + * @return array + */ + protected function formatCases(array $cases): array + { + $formatted = []; + foreach ($cases as $case => $alias) { + $alias = mb_strtoupper(Inflector::underscore($alias)); + if (is_string($case)) { + $case = '\'' . $case . '\''; + } + $formatted[] = 'case ' . $alias . ' = ' . $case . ';'; + } + + return $formatted; + } } diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index c8fd2bae..ec7d8354 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -111,6 +111,8 @@ public function bake(string $name, Arguments $args, ConsoleIo $io): void $tableObject = $this->getTableObject($name, $table); $this->validateNames($tableObject->getSchema(), $io); $data = $this->getTableContext($tableObject, $table, $name, $args, $io); + + $this->bakeEnums($tableObject, $data, $args, $io); $this->bakeTable($tableObject, $data, $args, $io); $this->bakeEntity($tableObject, $data, $args, $io); $this->bakeFixture($tableObject->getAlias(), $tableObject->getTable(), $args, $io); @@ -168,6 +170,7 @@ public function getTableContext( $behaviors = $this->getBehaviors($tableObject); $connection = $this->connection; $hidden = $this->getHiddenFields($tableObject, $args); + $enumSchema = $this->getEnumDefinitions($tableObject->getSchema()); return compact( 'associations', @@ -181,7 +184,8 @@ public function getTableContext( 'rulesChecker', 'behaviors', 'connection', - 'hidden' + 'hidden', + 'enumSchema', ); } @@ -1118,7 +1122,7 @@ public function getCounterCache(Table $model): array * Bake an entity class. * * @param \Cake\ORM\Table $model Model name or object - * @param array $data An array to use to generate the Table + * @param array $data An array to use to generate the Table * @param \Cake\Console\Arguments $args CLI Arguments * @param \Cake\Console\ConsoleIo $io CLI io * @return void @@ -1170,7 +1174,7 @@ public function bakeEntity(Table $model, array $data, Arguments $args, ConsoleIo * Bake a table class. * * @param \Cake\ORM\Table $model Model name or object - * @param array $data An array to use to generate the Table + * @param array $data An array to use to generate the Table * @param \Cake\Console\Arguments $args CLI Arguments * @param \Cake\Console\ConsoleIo $io CLI Arguments * @return void @@ -1444,4 +1448,94 @@ protected function possibleEnumFields(TableSchemaInterface $schema): array return $fields; } + + /** + * @param \Cake\Database\Schema\TableSchemaInterface $schema + * @return array + */ + protected function getEnumDefinitions(TableSchemaInterface $schema): array + { + $enums = []; + + foreach ($schema->columns() as $column) { + $columnSchema = $schema->getColumn($column); + if (!in_array($columnSchema['type'], ['string', 'integer', 'tinyinteger', 'smallinteger'], true)) { + continue; + } + + if (empty($columnSchema['comment']) || strpos($columnSchema['comment'], '[enum]') === false) { + continue; + } + + $enumsDefinitionString = mb_substr($columnSchema['comment'], strpos($columnSchema['comment'], '[enum]') + 6); + $enumsDefinition = $this->parseEnumsDefinition($enumsDefinitionString); + if (!$enumsDefinition) { + continue; + } + + $enums[$column] = $enumsDefinition; + } + + return $enums; + } + + /** + * @param string $enumsDefinitionString + * @return array + */ + protected function parseEnumsDefinition(string $enumsDefinitionString): array + { + $enumCases = explode(',', $enumsDefinitionString); + + $definition = []; + foreach ($enumCases as $enumCase) { + $key = $value = trim($enumCase); + if (str_contains($key, ':')) { + $value = trim(mb_substr($key, strpos($key, ':') + 1)); + $key = mb_substr($key, 0, strpos($key, ':')); + } + + $definition[$key] = mb_strtolower($value); + } + + return $definition; + } + + /** + * @param \Cake\ORM\Table $model + * @param array $data + * @param \Cake\Console\Arguments $args + * @param \Cake\Console\ConsoleIo $io + * @return void + */ + protected function bakeEnums(Table $model, array $data, Arguments $args, ConsoleIo $io): void + { + $enums = $data['enumSchema']; + if (!$enums) { + return; + } + + $entity = $this->_entityName($model->getAlias()); + + foreach ($enums as $column => $enum) { + $enumCommand = new EnumCommand(); + + $name = $entity . Inflector::camelize($column); + if ($this->plugin) { + $name = $this->plugin . '.' . $name; + } + + $cases = []; + foreach ($enum as $k => $v) { + $cases[] = $k . ':' . $v; + } + + $args = new Arguments( + [$name, implode(',', $cases)], + ['int' => false] + $args->getOptions(), + ['name', 'cases'] + ); + $enumCommand->execute($args, $io); + } + } } diff --git a/templates/bake/Model/enum.twig b/templates/bake/Model/enum.twig index fe229b11..315ed2f6 100644 --- a/templates/bake/Model/enum.twig +++ b/templates/bake/Model/enum.twig @@ -24,11 +24,15 @@ {{ DocBlock.classDescription(name, 'Enum', [])|raw }} enum {{ name }}: {{ backingType }} implements EnumLabelInterface { +{% if cases %} + {{ Bake.concat('\n ', cases) }} + +{% endif %} /** * @return string */ public function label(): string { - return Inflector::humanize(Inflector::underscore($this->name)); + return Inflector::humanize(mb_strtolower($this->name)); } } diff --git a/tests/comparisons/Model/testBakeEnum.php b/tests/comparisons/Model/testBakeEnum.php index 41c6faea..4b29bbc9 100644 --- a/tests/comparisons/Model/testBakeEnum.php +++ b/tests/comparisons/Model/testBakeEnum.php @@ -16,6 +16,6 @@ enum FooBar: string implements EnumLabelInterface */ public function label(): string { - return Inflector::humanize(Inflector::underscore($this->name)); + return Inflector::humanize(mb_strtolower($this->name)); } } diff --git a/tests/comparisons/Model/testBakeEnumBackedInt.php b/tests/comparisons/Model/testBakeEnumBackedInt.php index c0c0648c..41a5514b 100644 --- a/tests/comparisons/Model/testBakeEnumBackedInt.php +++ b/tests/comparisons/Model/testBakeEnumBackedInt.php @@ -16,6 +16,6 @@ enum FooBar: int implements EnumLabelInterface */ public function label(): string { - return Inflector::humanize(Inflector::underscore($this->name)); + return Inflector::humanize(mb_strtolower($this->name)); } } From c4f2ab2674377eacccc5f44339b4f501f3466970 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 28 Dec 2023 19:41:19 +0100 Subject: [PATCH 02/10] Update src/Command/EnumCommand.php Co-authored-by: othercorey --- src/Command/EnumCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/EnumCommand.php b/src/Command/EnumCommand.php index 16d3fa49..092bb033 100644 --- a/src/Command/EnumCommand.php +++ b/src/Command/EnumCommand.php @@ -71,7 +71,7 @@ public function templateData(Arguments $arguments): array $backingType = $isOfTypeInt ? 'int' : 'string'; if ($arguments->getOption('int')) { if ($cases && !$isOfTypeInt) { - throw new InvalidArgumentException('The cases provided do not seem to match the int type you want to bake'); + throw new InvalidArgumentException('Cases do not match requested `int` backing type.'); } $backingType = 'int'; From 1df72e6eed471c809c0c6c3733cc99c1856e9f71 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 28 Dec 2023 23:28:45 +0100 Subject: [PATCH 03/10] Fix up index/view for enums. --- src/View/Helper/BakeHelper.php | 4 ++++ templates/bake/Template/index.twig | 4 +++- templates/bake/Template/view.twig | 13 +++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/View/Helper/BakeHelper.php b/src/View/Helper/BakeHelper.php index 95572064..5fe7f33d 100644 --- a/src/View/Helper/BakeHelper.php +++ b/src/View/Helper/BakeHelper.php @@ -233,6 +233,9 @@ public function getViewFieldsData(array $fields, SchemaInterface $schema, array if (isset($associationFields[$field])) { return 'string'; } + if (str_starts_with($type, 'enum-')) { + return 'enum'; + } $numberTypes = ['decimal', 'biginteger', 'integer', 'float', 'smallinteger', 'tinyinteger']; if (in_array($type, $numberTypes, true)) { return 'number'; @@ -258,6 +261,7 @@ public function getViewFieldsData(array $fields, SchemaInterface $schema, array 'number' => [], 'string' => [], 'boolean' => [], + 'enum' => [], 'date' => [], 'text' => [], ]; diff --git a/templates/bake/Template/index.twig b/templates/bake/Template/index.twig index 8651c8f2..2b0210ad 100644 --- a/templates/bake/Template/index.twig +++ b/templates/bake/Template/index.twig @@ -49,7 +49,9 @@ {% endif %} {% if isKey is not same as(true) %} {% set columnData = Bake.columnData(field, schema) %} -{% if columnData.type not in ['integer', 'float', 'decimal', 'biginteger', 'smallinteger', 'tinyinteger'] %} +{% if columnData.type starts with 'enum-' %} + {{ field }}->label()) ?> +{% elseif columnData.type not in ['integer', 'float', 'decimal', 'biginteger', 'smallinteger', 'tinyinteger'] %} {{ field }}) ?> {% elseif columnData.null %} {{ field }} === null ? '' : $this->Number->format(${{ singularVar }}->{{ field }}) ?> diff --git a/templates/bake/Template/view.twig b/templates/bake/Template/view.twig index 6f8f2552..ae63d0e2 100644 --- a/templates/bake/Template/view.twig +++ b/templates/bake/Template/view.twig @@ -76,6 +76,19 @@ {% endfor %} {% endif %} +{% if groupedFields.enum %} +{% for field in groupedFields.enum %} + + +{% set columnData = Bake.columnData(field, schema) %} +{% if columnData.null %} + {{ field }} === null ? '' : h(${{ singularVar }}->{{ field }}->label()) ?> +{% else %} + {{ field }}->label()) ?> +{% endif %} + +{% endfor %} +{% endif %} {% if groupedFields.date %} {% for field in groupedFields.date %} From 36afd65e24e43254b797b6003ac01442a999cf72 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 28 Dec 2023 23:51:35 +0100 Subject: [PATCH 04/10] Fix up index/view for enums. --- src/Command/EnumCommand.php | 23 +++++++++++++++++++++++ src/Command/ModelCommand.php | 6 ++++++ src/View/Helper/BakeHelper.php | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Command/EnumCommand.php b/src/Command/EnumCommand.php index 092bb033..42ad11f1 100644 --- a/src/Command/EnumCommand.php +++ b/src/Command/EnumCommand.php @@ -17,6 +17,7 @@ namespace Bake\Command; use Cake\Console\Arguments; +use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; use Cake\Utility\Inflector; use InvalidArgumentException; @@ -174,4 +175,26 @@ protected function formatCases(array $cases): array return $formatted; } + + /** + * Generate a class stub + * + * @param string $name The class name + * @param \Cake\Console\Arguments $args The console arguments + * @param \Cake\Console\ConsoleIo $io The console io + * @return void + */ + protected function bake(string $name, Arguments $args, ConsoleIo $io): void + { + parent::bake($name, $args, $io); + + $path = $this->getPath($args); + $filename = $path . $name . '.php'; + + // Work around composer caching that classes/files do not exist. + // Check for the file as it might not exist in tests. + if (file_exists($filename)) { + require_once $filename; + } + } } diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index ec7d8354..d495cfc0 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -1439,6 +1439,12 @@ protected function possibleEnumFields(TableSchemaInterface $schema): array foreach ($schema->columns() as $column) { $columnSchema = $schema->getColumn($column); + if (str_starts_with($columnSchema['type'], 'enum-')) { + $fields[] = $column; + + continue; + } + if (!in_array($columnSchema['type'], ['string', 'integer', 'tinyinteger', 'smallinteger'], true)) { continue; } diff --git a/src/View/Helper/BakeHelper.php b/src/View/Helper/BakeHelper.php index 5fe7f33d..52e11dd6 100644 --- a/src/View/Helper/BakeHelper.php +++ b/src/View/Helper/BakeHelper.php @@ -233,7 +233,7 @@ public function getViewFieldsData(array $fields, SchemaInterface $schema, array if (isset($associationFields[$field])) { return 'string'; } - if (str_starts_with($type, 'enum-')) { + if ($type && str_starts_with($type, 'enum-')) { return 'enum'; } $numberTypes = ['decimal', 'biginteger', 'integer', 'float', 'smallinteger', 'tinyinteger']; From f5a37663997eb2f01cf375b69402feaad10e8508 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 31 Dec 2023 16:17:03 +0100 Subject: [PATCH 05/10] Switch key/value, use CamelCase --- src/Command/EnumCommand.php | 51 +++++-------------- src/Command/ModelCommand.php | 6 ++- src/Utility/Model/EnumParser.php | 36 +++++++++++++ templates/bake/Model/enum.twig | 2 +- tests/TestCase/Command/EnumCommandTest.php | 32 ++++++++++++ .../TestCase/Utility/Model/EnumParserTest.php | 47 +++++++++++++++++ tests/comparisons/Model/testBakeEnum.php | 2 +- .../Model/testBakeEnumBackedInt.php | 2 +- .../Model/testBakeEnumBackedIntWithCases.php | 25 +++++++++ .../Model/testBakeEnumBackedWithCases.php | 25 +++++++++ .../App/Model/Enum/BakeUserStatus.php | 6 +-- 11 files changed, 187 insertions(+), 47 deletions(-) create mode 100644 src/Utility/Model/EnumParser.php create mode 100644 tests/TestCase/Utility/Model/EnumParserTest.php create mode 100644 tests/comparisons/Model/testBakeEnumBackedIntWithCases.php create mode 100644 tests/comparisons/Model/testBakeEnumBackedWithCases.php diff --git a/src/Command/EnumCommand.php b/src/Command/EnumCommand.php index 42ad11f1..c5ff42bc 100644 --- a/src/Command/EnumCommand.php +++ b/src/Command/EnumCommand.php @@ -16,6 +16,7 @@ */ namespace Bake\Command; +use Bake\Utility\Model\EnumParser; use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; @@ -67,7 +68,7 @@ public function template(): string */ public function templateData(Arguments $arguments): array { - $cases = $this->parseCases($arguments->getArgument('cases'), (bool)$arguments->getOption('int')); + $cases = EnumParser::parseCases($arguments->getArgument('cases'), (bool)$arguments->getOption('int')); $isOfTypeInt = $this->isOfTypeInt($cases); $backingType = $isOfTypeInt ? 'int' : 'string'; if ($arguments->getOption('int')) { @@ -101,7 +102,7 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar 'help' => 'Name of the enum to bake. You can use Plugin.name to bake plugin enums.', 'required' => true, ])->addArgument('cases', [ - 'help' => 'List of either `one,two` for string or `0:foo,1:bar` for int type.', + 'help' => 'List of either `one,two` for string or `foo:0,bar:1` for int type.', ])->addOption('int', [ 'help' => 'Using backed enums with int instead of string as return type.', 'boolean' => true, @@ -112,35 +113,7 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar } /** - * @param string|null $casesString - * @return array - */ - protected function parseCases(?string $casesString, bool $int): array - { - if ($casesString === null) { - return []; - } - - $enumCases = explode(',', $casesString); - - $definition = []; - foreach ($enumCases as $k => $enumCase) { - $key = $value = trim($enumCase); - if (str_contains($key, ':')) { - $value = trim(mb_substr($key, strpos($key, ':') + 1)); - $key = mb_substr($key, 0, strpos($key, ':')); - } elseif ($int) { - $key = $k; - } - - $definition[$key] = $value; - } - - return $definition; - } - - /** - * @param array $definition + * @param array $definition * @return bool */ protected function isOfTypeInt(array $definition): bool @@ -149,8 +122,8 @@ protected function isOfTypeInt(array $definition): bool return false; } - foreach ($definition as $key => $value) { - if (!is_int($key)) { + foreach ($definition as $value) { + if (!is_int($value)) { return false; } } @@ -159,18 +132,18 @@ protected function isOfTypeInt(array $definition): bool } /** - * @param array $cases + * @param array $cases * @return array */ protected function formatCases(array $cases): array { $formatted = []; - foreach ($cases as $case => $alias) { - $alias = mb_strtoupper(Inflector::underscore($alias)); - if (is_string($case)) { - $case = '\'' . $case . '\''; + foreach ($cases as $case => $value) { + $case = Inflector::camelize(Inflector::underscore($case)); + if (is_string($value)) { + $value = '\'' . $value . '\''; } - $formatted[] = 'case ' . $alias . ' = ' . $case . ';'; + $formatted[] = 'case ' . $case . ' = ' . $value . ';'; } return $formatted; diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index d495cfc0..e378c808 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -17,6 +17,7 @@ namespace Bake\Command; use Bake\CodeGen\FileBuilder; +use Bake\Utility\Model\EnumParser; use Bake\Utility\TableScanner; use Cake\Console\Arguments; use Cake\Console\ConsoleIo; @@ -1473,8 +1474,9 @@ protected function getEnumDefinitions(TableSchemaInterface $schema): array continue; } - $enumsDefinitionString = mb_substr($columnSchema['comment'], strpos($columnSchema['comment'], '[enum]') + 6); - $enumsDefinition = $this->parseEnumsDefinition($enumsDefinitionString); + $enumsDefinitionString = trim(mb_substr($columnSchema['comment'], strpos($columnSchema['comment'], '[enum]') + 6)); + $isInt = in_array($columnSchema['type'], ['integer', 'tinyinteger', 'smallinteger', true]); + $enumsDefinition = EnumParser::parseCases($enumsDefinitionString, $isInt); if (!$enumsDefinition) { continue; } diff --git a/src/Utility/Model/EnumParser.php b/src/Utility/Model/EnumParser.php new file mode 100644 index 00000000..92f10080 --- /dev/null +++ b/src/Utility/Model/EnumParser.php @@ -0,0 +1,36 @@ + + */ + public static function parseCases(?string $casesString, bool $int): array + { + if ($casesString === null || $casesString === '') { + return []; + } + + $enumCases = explode(',', $casesString); + + $definition = []; + foreach ($enumCases as $k => $enumCase) { + $case = $value = trim($enumCase); + if (str_contains($case, ':')) { + $value = trim(mb_substr($case, strpos($case, ':') + 1)); + $case = mb_substr($case, 0, strpos($case, ':')); + } elseif ($int) { + $value = $k; + } + + $definition[$case] = $int ? (int)$value : $value; + } + + return $definition; + } +} diff --git a/templates/bake/Model/enum.twig b/templates/bake/Model/enum.twig index 315ed2f6..83f48243 100644 --- a/templates/bake/Model/enum.twig +++ b/templates/bake/Model/enum.twig @@ -33,6 +33,6 @@ enum {{ name }}: {{ backingType }} implements EnumLabelInterface */ public function label(): string { - return Inflector::humanize(mb_strtolower($this->name)); + return Inflector::humanize(Inflector::underscore($this->name)); } } diff --git a/tests/TestCase/Command/EnumCommandTest.php b/tests/TestCase/Command/EnumCommandTest.php index 678aaf40..bc34dcb1 100644 --- a/tests/TestCase/Command/EnumCommandTest.php +++ b/tests/TestCase/Command/EnumCommandTest.php @@ -68,4 +68,36 @@ public function testBakeEnumBackedInt() $result = file_get_contents($this->generatedFile); $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } + + /** + * test baking an enum with string return type and cases + * + * @return void + */ + public function testBakeEnumBackedWithCases() + { + $this->generatedFile = APP . 'Model/Enum/FooBar.php'; + $this->exec('bake enum FooBar foo,bar:b,bar_baz', ['y']); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $result = file_get_contents($this->generatedFile); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + + /** + * test baking an enum with string return type and cases + * + * @return void + */ + public function testBakeEnumBackedIntWithCases() + { + $this->generatedFile = APP . 'Model/Enum/FooBar.php'; + $this->exec('bake enum FooBar foo,bar,bar_baz:9 -i', ['y']); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $result = file_get_contents($this->generatedFile); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } } diff --git a/tests/TestCase/Utility/Model/EnumParserTest.php b/tests/TestCase/Utility/Model/EnumParserTest.php new file mode 100644 index 00000000..bff2c71c --- /dev/null +++ b/tests/TestCase/Utility/Model/EnumParserTest.php @@ -0,0 +1,47 @@ +assertSame([], $cases); + + $cases = EnumParser::parseCases('foo, bar', false); + $this->assertSame(['foo' => 'foo', 'bar' => 'bar'], $cases); + + $cases = EnumParser::parseCases('foo:f, bar:b', false); + $this->assertSame(['foo' => 'f', 'bar' => 'b'], $cases); + + $cases = EnumParser::parseCases('foo:0, bar:1', true); + $this->assertSame(['foo' => 0, 'bar' => 1], $cases); + + $cases = EnumParser::parseCases('foo, bar', true); + $this->assertSame(['foo' => 0, 'bar' => 1], $cases); + } +} diff --git a/tests/comparisons/Model/testBakeEnum.php b/tests/comparisons/Model/testBakeEnum.php index 4b29bbc9..41c6faea 100644 --- a/tests/comparisons/Model/testBakeEnum.php +++ b/tests/comparisons/Model/testBakeEnum.php @@ -16,6 +16,6 @@ enum FooBar: string implements EnumLabelInterface */ public function label(): string { - return Inflector::humanize(mb_strtolower($this->name)); + return Inflector::humanize(Inflector::underscore($this->name)); } } diff --git a/tests/comparisons/Model/testBakeEnumBackedInt.php b/tests/comparisons/Model/testBakeEnumBackedInt.php index 41a5514b..c0c0648c 100644 --- a/tests/comparisons/Model/testBakeEnumBackedInt.php +++ b/tests/comparisons/Model/testBakeEnumBackedInt.php @@ -16,6 +16,6 @@ enum FooBar: int implements EnumLabelInterface */ public function label(): string { - return Inflector::humanize(mb_strtolower($this->name)); + return Inflector::humanize(Inflector::underscore($this->name)); } } diff --git a/tests/comparisons/Model/testBakeEnumBackedIntWithCases.php b/tests/comparisons/Model/testBakeEnumBackedIntWithCases.php new file mode 100644 index 00000000..fcd88e07 --- /dev/null +++ b/tests/comparisons/Model/testBakeEnumBackedIntWithCases.php @@ -0,0 +1,25 @@ +name)); + } +} diff --git a/tests/comparisons/Model/testBakeEnumBackedWithCases.php b/tests/comparisons/Model/testBakeEnumBackedWithCases.php new file mode 100644 index 00000000..403e8fda --- /dev/null +++ b/tests/comparisons/Model/testBakeEnumBackedWithCases.php @@ -0,0 +1,25 @@ +name)); + } +} diff --git a/tests/test_app/App/Model/Enum/BakeUserStatus.php b/tests/test_app/App/Model/Enum/BakeUserStatus.php index be156019..5dcf0ec7 100644 --- a/tests/test_app/App/Model/Enum/BakeUserStatus.php +++ b/tests/test_app/App/Model/Enum/BakeUserStatus.php @@ -19,14 +19,14 @@ enum BakeUserStatus: int implements EnumLabelInterface { - case INACTIVE = 0; - case ACTIVE = 1; + case Inactive = 0; + case Active = 1; /** * @return string */ public function label(): string { - return Inflector::humanize(mb_strtolower($this->name)); + return Inflector::humanize(Inflector::underscore($this->name)); } } From b38aab930af26ee1ad49768cbc340abefe46bde9 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 31 Dec 2023 17:07:23 +0100 Subject: [PATCH 06/10] Stricter validation --- src/Utility/Model/EnumParser.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Utility/Model/EnumParser.php b/src/Utility/Model/EnumParser.php index 92f10080..df3ed5dc 100644 --- a/src/Utility/Model/EnumParser.php +++ b/src/Utility/Model/EnumParser.php @@ -3,6 +3,8 @@ namespace Bake\Utility\Model; +use InvalidArgumentException; + enum EnumParser { /** @@ -28,6 +30,13 @@ public static function parseCases(?string $casesString, bool $int): array $value = $k; } + if (!preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $case)) { + throw new InvalidArgumentException(sprintf('`%s` is not a valid enum case', $case)); + } + if (is_string($value) && str_contains($value, '\'')) { + throw new InvalidArgumentException(sprintf('`%s` value cannot contain `\'` character', $case)); + } + $definition[$case] = $int ? (int)$value : $value; } From 1a1b5b87a80e7a834972097aa520a35678440fe0 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 31 Dec 2023 18:16:11 +0100 Subject: [PATCH 07/10] Update src/Command/ModelCommand.php Co-authored-by: Mark Story --- src/Command/ModelCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index e378c808..535e0239 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -1475,7 +1475,7 @@ protected function getEnumDefinitions(TableSchemaInterface $schema): array } $enumsDefinitionString = trim(mb_substr($columnSchema['comment'], strpos($columnSchema['comment'], '[enum]') + 6)); - $isInt = in_array($columnSchema['type'], ['integer', 'tinyinteger', 'smallinteger', true]); + $isInt = in_array($columnSchema['type'], ['integer', 'tinyinteger', 'smallinteger'], true); $enumsDefinition = EnumParser::parseCases($enumsDefinitionString, $isInt); if (!$enumsDefinition) { continue; From d9808c1ac193450d0ab3f42b19b0b018a22a2265 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 1 Jan 2024 20:07:09 +0100 Subject: [PATCH 08/10] Cleanup --- src/Command/ModelCommand.php | 56 ++++++++++++++++-------------- templates/bake/Template/index.twig | 2 +- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index 535e0239..331702c3 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -29,9 +29,12 @@ use Cake\Database\Schema\CachedCollection; use Cake\Database\Schema\TableSchema; use Cake\Database\Schema\TableSchemaInterface; +use Cake\Database\Type\EnumType; +use Cake\Database\TypeFactory; use Cake\Datasource\ConnectionManager; use Cake\ORM\Table; use Cake\Utility\Inflector; +use ReflectionEnum; use function Cake\Core\pluginSplit; /** @@ -1466,7 +1469,10 @@ protected function getEnumDefinitions(TableSchemaInterface $schema): array foreach ($schema->columns() as $column) { $columnSchema = $schema->getColumn($column); - if (!in_array($columnSchema['type'], ['string', 'integer', 'tinyinteger', 'smallinteger'], true)) { + if ( + !in_array($columnSchema['type'], ['string', 'integer', 'tinyinteger', 'smallinteger'], true) + && !str_starts_with($columnSchema['type'], 'enum-') + ) { continue; } @@ -1476,39 +1482,33 @@ protected function getEnumDefinitions(TableSchemaInterface $schema): array $enumsDefinitionString = trim(mb_substr($columnSchema['comment'], strpos($columnSchema['comment'], '[enum]') + 6)); $isInt = in_array($columnSchema['type'], ['integer', 'tinyinteger', 'smallinteger'], true); + if (str_starts_with($columnSchema['type'], 'enum-')) { + $dbType = TypeFactory::build($columnSchema['type']); + if ($dbType instanceof EnumType) { + $class = $dbType->getEnumClassName(); + /** @var \BackedEnum $enum */ + $rEnum = new ReflectionEnum($class); + $rBackingType = $rEnum->getBackingType(); + $type = (string)$rBackingType; + if ($type === 'int') { + $isInt = true; + } + } + } $enumsDefinition = EnumParser::parseCases($enumsDefinitionString, $isInt); if (!$enumsDefinition) { continue; } - $enums[$column] = $enumsDefinition; + $enums[$column] = [ + 'type' => $isInt ? 'int' : 'string', + 'cases' => $enumsDefinition, + ]; } return $enums; } - /** - * @param string $enumsDefinitionString - * @return array - */ - protected function parseEnumsDefinition(string $enumsDefinitionString): array - { - $enumCases = explode(',', $enumsDefinitionString); - - $definition = []; - foreach ($enumCases as $enumCase) { - $key = $value = trim($enumCase); - if (str_contains($key, ':')) { - $value = trim(mb_substr($key, strpos($key, ':') + 1)); - $key = mb_substr($key, 0, strpos($key, ':')); - } - - $definition[$key] = mb_strtolower($value); - } - - return $definition; - } - /** * @param \Cake\ORM\Table $model * @param array $data @@ -1525,7 +1525,7 @@ protected function bakeEnums(Table $model, array $data, Arguments $args, Console $entity = $this->_entityName($model->getAlias()); - foreach ($enums as $column => $enum) { + foreach ($enums as $column => $data) { $enumCommand = new EnumCommand(); $name = $entity . Inflector::camelize($column); @@ -1533,14 +1533,16 @@ protected function bakeEnums(Table $model, array $data, Arguments $args, Console $name = $this->plugin . '.' . $name; } + $enumCases = $data['cases']; + $cases = []; - foreach ($enum as $k => $v) { + foreach ($enumCases as $k => $v) { $cases[] = $k . ':' . $v; } $args = new Arguments( [$name, implode(',', $cases)], - ['int' => false] + $args->getOptions(), + ['int' => $data['type'] === 'int'] + $args->getOptions(), ['name', 'cases'] ); $enumCommand->execute($args, $io); diff --git a/templates/bake/Template/index.twig b/templates/bake/Template/index.twig index 2b0210ad..0c630067 100644 --- a/templates/bake/Template/index.twig +++ b/templates/bake/Template/index.twig @@ -50,7 +50,7 @@ {% if isKey is not same as(true) %} {% set columnData = Bake.columnData(field, schema) %} {% if columnData.type starts with 'enum-' %} - {{ field }}->label()) ?> + {{ field }} === null ? '' : h(${{ singularVar }}->{{ field }}->label()) ?> {% elseif columnData.type not in ['integer', 'float', 'decimal', 'biginteger', 'smallinteger', 'tinyinteger'] %} {{ field }}) ?> {% elseif columnData.null %} From 54015a52a28e792e70ec1cbc17d4cf1a91f0c059 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 1 Jan 2024 20:10:17 +0100 Subject: [PATCH 09/10] Cleanup --- src/Command/ModelCommand.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index 331702c3..afcf486d 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -1486,11 +1486,9 @@ protected function getEnumDefinitions(TableSchemaInterface $schema): array $dbType = TypeFactory::build($columnSchema['type']); if ($dbType instanceof EnumType) { $class = $dbType->getEnumClassName(); - /** @var \BackedEnum $enum */ - $rEnum = new ReflectionEnum($class); - $rBackingType = $rEnum->getBackingType(); - $type = (string)$rBackingType; - if ($type === 'int') { + $reflectionEnum = new ReflectionEnum($class); + $backingType = (string)$reflectionEnum->getBackingType(); + if ($backingType === 'int') { $isInt = true; } } From 3e44abcf7cb0ac217dd8ff4f28dcbcc1d3e878f3 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 2 Jan 2024 14:28:19 +0100 Subject: [PATCH 10/10] Add more tests. --- tests/TestCase/Command/ModelCommandTest.php | 29 +++++++++++++++++++ .../Model/testBakeTableWithEnumConfig.php | 25 ++++++++++++++++ .../App/Model/Enum/BakeUserNullableGender.php | 25 ++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 tests/comparisons/Model/testBakeTableWithEnumConfig.php create mode 100644 tests/test_app/App/Model/Enum/BakeUserNullableGender.php diff --git a/tests/TestCase/Command/ModelCommandTest.php b/tests/TestCase/Command/ModelCommandTest.php index 49ec8f45..731cbd2a 100644 --- a/tests/TestCase/Command/ModelCommandTest.php +++ b/tests/TestCase/Command/ModelCommandTest.php @@ -2111,6 +2111,35 @@ public function testBakeTableWithEnum(): void $this->assertStringContainsString('$this->getSchema()->setColumnType(\'status\', \Cake\Database\Type\EnumType::from(\Bake\Test\App\Model\Enum\BakeUserStatus::class));', $result); } + /** + * test generation with enum config in column comment + * + * @return void + */ + public function testBakeTableWithEnumConfig(): void + { + $this->generatedFile = APP . 'Model/Table/BakeUsersTable.php'; + + $bakeUsers = $this->getTableLocator()->get('BakeUsers'); + $attributes = [ + 'type' => 'string', + 'null' => true, + 'comment' => '[enum]male,female,diverse', + ]; + $bakeUsers->setSchema($bakeUsers->getSchema()->addColumn('nullable_gender', $attributes)); + + $this->exec('bake model --no-validation --no-test --no-fixture --no-entity BakeUsers', ['y']); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $result = file_get_contents($this->generatedFile); + $this->assertStringContainsString('$this->getSchema()->setColumnType(\'nullable_gender\', \Cake\Database\Type\EnumType::from(\Bake\Test\App\Model\Enum\BakeUserNullableGender::class));', $result); + + $generatedEnumFile = APP . 'Model/Enum/BakeUserNullableGender.php'; + $result = file_get_contents($generatedEnumFile); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + /** * test generation with counter cache * diff --git a/tests/comparisons/Model/testBakeTableWithEnumConfig.php b/tests/comparisons/Model/testBakeTableWithEnumConfig.php new file mode 100644 index 00000000..1287d1ac --- /dev/null +++ b/tests/comparisons/Model/testBakeTableWithEnumConfig.php @@ -0,0 +1,25 @@ +name)); + } +} diff --git a/tests/test_app/App/Model/Enum/BakeUserNullableGender.php b/tests/test_app/App/Model/Enum/BakeUserNullableGender.php new file mode 100644 index 00000000..1287d1ac --- /dev/null +++ b/tests/test_app/App/Model/Enum/BakeUserNullableGender.php @@ -0,0 +1,25 @@ +name)); + } +}