diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2b92f09..6c7ee147 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: - 2.x - 2.next - 3.x + - 3.next pull_request: branches: - '*' diff --git a/composer.json b/composer.json index ce6fe53e..2e718fc1 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "require": { "php": ">=8.1", "brick/varexporter": "^0.4.0", - "cakephp/cakephp": "^5.0.0", + "cakephp/cakephp": "^5.0.3", "cakephp/twig-view": "^2.0.0", "nikic/php-parser": "^4.13.2 || ^5.0.0" }, diff --git a/docs/en/usage.rst b/docs/en/usage.rst index e25b3f02..29da6dcb 100644 --- a/docs/en/usage.rst +++ b/docs/en/usage.rst @@ -31,9 +31,11 @@ You can get the list of available bake command by running ``bin/cake bake --help - bake behavior - bake cell - bake command + - bake command_helper - bake component - bake controller - bake controller all + - bake enum - bake fixture - bake fixture all - bake form diff --git a/src/Command/EnumCommand.php b/src/Command/EnumCommand.php new file mode 100644 index 00000000..c5ff42bc --- /dev/null +++ b/src/Command/EnumCommand.php @@ -0,0 +1,173 @@ + + */ + public function templateData(Arguments $arguments): array + { + $cases = EnumParser::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('Cases do not match requested `int` backing type.'); + } + + $backingType = 'int'; + } + + $data = parent::templateData($arguments); + $data['backingType'] = $backingType; + $data['cases'] = $this->formatCases($cases); + + return $data; + } + + /** + * Gets the option parser instance and configures it. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update. + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser = $this->_setCommonOptions($parser); + + $parser->setDescription( + 'Bake backed enums for use in models.' + )->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 `foo:0,bar:1` for int type.', + ])->addOption('int', [ + 'help' => 'Using backed enums with int instead of string as return type.', + 'boolean' => true, + 'short' => 'i', + ]); + + return $parser; + } + + /** + * @param array $definition + * @return bool + */ + protected function isOfTypeInt(array $definition): bool + { + if (!$definition) { + return false; + } + + foreach ($definition as $value) { + if (!is_int($value)) { + return false; + } + } + + return true; + } + + /** + * @param array $cases + * @return array + */ + protected function formatCases(array $cases): array + { + $formatted = []; + foreach ($cases as $case => $value) { + $case = Inflector::camelize(Inflector::underscore($case)); + if (is_string($value)) { + $value = '\'' . $value . '\''; + } + $formatted[] = 'case ' . $case . ' = ' . $value . ';'; + } + + 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 aa49069d..afcf486d 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; @@ -28,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; /** @@ -111,6 +115,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 +174,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 +188,8 @@ public function getTableContext( 'rulesChecker', 'behaviors', 'connection', - 'hidden' + 'hidden', + 'enumSchema', ); } @@ -984,22 +992,14 @@ public function getRules(Table $model, array $associations, Arguments $args): ar return []; } $schema = $model->getSchema(); - $fields = $schema->columns(); - if (empty($fields)) { + $schemaFields = $schema->columns(); + if (empty($schemaFields)) { return []; } - $uniqueColumns = ['username', 'login']; - if (in_array($model->getAlias(), ['Users', 'Accounts'])) { - $uniqueColumns[] = 'email'; - } + $uniqueRules = []; + $uniqueConstraintsColumns = []; - $rules = []; - foreach ($fields as $fieldName) { - if (in_array($fieldName, $uniqueColumns, true)) { - $rules[$fieldName] = ['name' => 'isUnique', 'fields' => [$fieldName], 'options' => []]; - } - } foreach ($schema->constraints() as $name) { $constraint = $schema->getConstraint($name); if ($constraint['type'] !== TableSchema::CONSTRAINT_UNIQUE) { @@ -1007,8 +1007,11 @@ public function getRules(Table $model, array $associations, Arguments $args): ar } $options = []; - $fields = $constraint['columns']; - foreach ($fields as $field) { + /** @var array $constraintFields */ + $constraintFields = $constraint['columns']; + $uniqueConstraintsColumns = [...$uniqueConstraintsColumns, ...$constraintFields]; + + foreach ($constraintFields as $field) { if ($schema->isNullable($field)) { $allowMultiple = !ConnectionManager::get($this->connection)->getDriver() instanceof Sqlserver; $options['allowMultipleNulls'] = $allowMultiple; @@ -1016,15 +1019,37 @@ public function getRules(Table $model, array $associations, Arguments $args): ar } } - $rules[$constraint['columns'][0]] = ['name' => 'isUnique', 'fields' => $fields, 'options' => $options]; + $uniqueRules[] = ['name' => 'isUnique', 'fields' => $constraintFields, 'options' => $options]; + } + + $possiblyUniqueColumns = ['username', 'login']; + if (in_array($model->getAlias(), ['Users', 'Accounts'])) { + $possiblyUniqueColumns[] = 'email'; + } + + $possiblyUniqueRules = []; + foreach ($schemaFields as $field) { + if ( + !in_array($field, $uniqueConstraintsColumns, true) && + in_array($field, $possiblyUniqueColumns, true) + ) { + $possiblyUniqueRules[] = ['name' => 'isUnique', 'fields' => [$field], 'options' => []]; + } } + $rules = [...$possiblyUniqueRules, ...$uniqueRules]; + if (empty($associations['belongsTo'])) { return $rules; } foreach ($associations['belongsTo'] as $assoc) { - $rules[$assoc['foreignKey']] = ['name' => 'existsIn', 'extra' => $assoc['alias'], 'options' => []]; + $rules[] = [ + 'name' => 'existsIn', + 'fields' => (array)$assoc['foreignKey'], + 'extra' => $assoc['alias'], + 'options' => [], + ]; } return $rules; @@ -1101,7 +1126,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 @@ -1153,7 +1178,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 @@ -1195,6 +1220,7 @@ public function bakeTable(Table $model, array $data, Arguments $args, ConsoleIo 'validation' => [], 'rulesChecker' => [], 'behaviors' => [], + 'enums' => $this->enums($model, $entity, $namespace), 'connection' => $this->connection, 'fileBuilder' => new FileBuilder($io, "{$namespace}\Model\Table", $parsedFile), ]; @@ -1382,4 +1408,142 @@ public function bakeTest(string $className, Arguments $args, ConsoleIo $io): voi ); $test->execute($testArgs, $io); } + + /** + * @param \Cake\ORM\Table $table + * @param string $entity + * @param string $namespace + * @return array + */ + protected function enums(Table $table, string $entity, string $namespace): array + { + $fields = $this->possibleEnumFields($table->getSchema()); + $enumClassNamespace = $namespace . '\Model\Enum\\'; + + $enums = []; + foreach ($fields as $field) { + $enumClassName = $enumClassNamespace . $entity . Inflector::camelize($field); + if (!class_exists($enumClassName)) { + continue; + } + + $enums[$field] = $enumClassName; + } + + return $enums; + } + + /** + * @param \Cake\Database\Schema\TableSchemaInterface $schema + * @return array + */ + protected function possibleEnumFields(TableSchemaInterface $schema): array + { + $fields = []; + + 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; + } + + $fields[] = $column; + } + + 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) + && !str_starts_with($columnSchema['type'], 'enum-') + ) { + continue; + } + + if (empty($columnSchema['comment']) || strpos($columnSchema['comment'], '[enum]') === false) { + continue; + } + + $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(); + $reflectionEnum = new ReflectionEnum($class); + $backingType = (string)$reflectionEnum->getBackingType(); + if ($backingType === 'int') { + $isInt = true; + } + } + } + $enumsDefinition = EnumParser::parseCases($enumsDefinitionString, $isInt); + if (!$enumsDefinition) { + continue; + } + + $enums[$column] = [ + 'type' => $isInt ? 'int' : 'string', + 'cases' => $enumsDefinition, + ]; + } + + return $enums; + } + + /** + * @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 => $data) { + $enumCommand = new EnumCommand(); + + $name = $entity . Inflector::camelize($column); + if ($this->plugin) { + $name = $this->plugin . '.' . $name; + } + + $enumCases = $data['cases']; + + $cases = []; + foreach ($enumCases as $k => $v) { + $cases[] = $k . ':' . $v; + } + + $args = new Arguments( + [$name, implode(',', $cases)], + ['int' => $data['type'] === 'int'] + $args->getOptions(), + ['name', 'cases'] + ); + $enumCommand->execute($args, $io); + } + } } diff --git a/src/Utility/Model/EnumParser.php b/src/Utility/Model/EnumParser.php new file mode 100644 index 00000000..df3ed5dc --- /dev/null +++ b/src/Utility/Model/EnumParser.php @@ -0,0 +1,45 @@ + + */ + 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; + } + + 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; + } + + return $definition; + } +} diff --git a/src/View/Helper/BakeHelper.php b/src/View/Helper/BakeHelper.php index 95572064..52e11dd6 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 ($type && 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/Model/enum.twig b/templates/bake/Model/enum.twig new file mode 100644 index 00000000..83f48243 --- /dev/null +++ b/templates/bake/Model/enum.twig @@ -0,0 +1,38 @@ +{# +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.1.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +#} +{{ element('Bake.file_header', { + namespace: "#{namespace}\\Model\\Enum", + classImports: [ + 'Cake\\Database\\Type\\EnumLabelInterface', + 'Cake\\Utility\\Inflector', + ], +}) }} + +{{ 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)); + } +} diff --git a/templates/bake/Model/table.twig b/templates/bake/Model/table.twig index 5a1b9620..acd786fa 100644 --- a/templates/bake/Model/table.twig +++ b/templates/bake/Model/table.twig @@ -61,6 +61,17 @@ class {{ name }}Table extends Table{{ fileBuilder.classBuilder.implements ? ' im {%- endif %} {% endif %} +{%- if enums %} + +{% endif %} + +{%- if enums %} + +{%- for name, className in enums %} + $this->getSchema()->setColumnType('{{ name }}', \Cake\Database\Type\EnumType::from(\{{ className }}::class)); +{% endfor %} +{% endif %} + {%- if behaviors %} {% endif %} @@ -128,13 +139,13 @@ class {{ name }}Table extends Table{{ fileBuilder.classBuilder.implements ? ' im */ public function buildRules(RulesChecker $rules): RulesChecker { -{% for field, rule in rulesChecker %} -{% set fields = rule.fields is defined ? Bake.exportArray(rule.fields) : Bake.exportVar(field) %} +{% for rule in rulesChecker %} +{% set fields = Bake.exportArray(rule.fields) %} {% set options = '' %} {% for optionName, optionValue in rule.options %} {%~ set options = (loop.first ? '[' : options) ~ "'#{optionName}' => " ~ Bake.exportVar(optionValue) ~ (loop.last ? ']' : ', ') %} {% endfor %} - $rules->add($rules->{{ rule.name }}({{ fields|raw }}{{ (rule.extra|default ? ", '#{rule.extra}'" : '')|raw }}{{ (options ? ', ' ~ options : '')|raw }}), ['errorField' => '{{ field }}']); + $rules->add($rules->{{ rule.name }}({{ fields|raw }}{{ (rule.extra|default ? ", '#{rule.extra}'" : '')|raw }}{{ (options ? ', ' ~ options : '')|raw }}), ['errorField' => '{{ rule.fields[0] }}']); {% endfor %} return $rules; diff --git a/templates/bake/Template/index.twig b/templates/bake/Template/index.twig index 8651c8f2..0c630067 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 }} === null ? '' : h(${{ singularVar }}->{{ 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 %} diff --git a/tests/TestCase/Command/EnumCommandTest.php b/tests/TestCase/Command/EnumCommandTest.php new file mode 100644 index 00000000..bc34dcb1 --- /dev/null +++ b/tests/TestCase/Command/EnumCommandTest.php @@ -0,0 +1,103 @@ +_compareBasePath = Plugin::path('Bake') . 'tests' . DS . 'comparisons' . DS . 'Model' . DS; + $this->setAppNamespace('Bake\Test\App'); + } + + /** + * test baking an enum + * + * @return void + */ + public function testBakeEnum() + { + $this->generatedFile = APP . 'Model/Enum/FooBar.php'; + $this->exec('bake enum FooBar', ['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 int return type + * + * @return void + */ + public function testBakeEnumBackedInt() + { + $this->generatedFile = APP . 'Model/Enum/FooBar.php'; + $this->exec('bake enum FooBar -i', ['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 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/Command/ModelCommandTest.php b/tests/TestCase/Command/ModelCommandTest.php index 47694cb1..731cbd2a 100644 --- a/tests/TestCase/Command/ModelCommandTest.php +++ b/tests/TestCase/Command/ModelCommandTest.php @@ -1453,7 +1453,7 @@ public function testGetRules() ], [ 'alias' => 'Sites', - 'foreignKey' => 'site_id', + 'foreignKey' => ['site_id1', 'site_id2'], ], ], 'hasMany' => [ @@ -1467,18 +1467,20 @@ public function testGetRules() $args = new Arguments([], [], []); $result = $command->getRules($model, $associations, $args); $expected = [ - 'username' => [ + [ 'name' => 'isUnique', 'fields' => ['username'], 'options' => [], ], - 'country_id' => [ + [ 'name' => 'existsIn', + 'fields' => ['country_id'], 'extra' => 'Countries', 'options' => [], ], - 'site_id' => [ + [ 'name' => 'existsIn', + 'fields' => ['site_id1', 'site_id2'], 'extra' => 'Sites', 'options' => [], ], @@ -1489,9 +1491,6 @@ public function testGetRules() /** * Tests the getRules with unique keys. * - * Multi-column constraints are ignored as they would - * require a break in compatibility. - * * @return void */ public function testGetRulesUniqueKeys() @@ -1501,7 +1500,7 @@ public function testGetRulesUniqueKeys() 'type' => 'unique', 'columns' => ['title'], ]); - $model->getSchema()->addConstraint('ignored_constraint', [ + $model->getSchema()->addConstraint('unique_composite', [ 'type' => 'unique', 'columns' => ['title', 'user_id'], ]); @@ -1510,7 +1509,12 @@ public function testGetRulesUniqueKeys() $args = new Arguments([], [], []); $result = $command->getRules($model, [], $args); $expected = [ - 'title' => [ + [ + 'name' => 'isUnique', + 'fields' => ['title'], + 'options' => [], + ], + [ 'name' => 'isUnique', 'fields' => ['title', 'user_id'], 'options' => [], @@ -1519,6 +1523,124 @@ public function testGetRulesUniqueKeys() $this->assertEquals($expected, $result); } + /** + * Tests that there are no conflicts between neither multiple constraints, + * nor with foreign keys that share one or more identical column. + */ + public function testGetRulesNoColumnNameConflictForUniqueConstraints(): void + { + $model = $this->getTableLocator()->get('Users'); + $model->setSchema([ + 'department_id' => ['type' => 'integer', 'null' => false], + 'username' => ['type' => 'string', 'null' => false], + 'email' => ['type' => 'string', 'null' => false], + ]); + + $model->getSchema()->addConstraint('unique_composite_1', [ + 'type' => 'unique', + 'columns' => ['department_id', 'username'], + ]); + $model->getSchema()->addConstraint('unique_composite_2', [ + 'type' => 'unique', + 'columns' => ['department_id', 'email'], + ]); + + $command = new ModelCommand(); + $args = new Arguments([], [], []); + $associations = [ + 'belongsTo' => [ + ['alias' => 'Departments', 'foreignKey' => 'department_id'], + ], + ]; + + $result = $command->getRules($model, $associations, $args); + $expected = [ + [ + 'name' => 'isUnique', + 'fields' => ['department_id', 'username'], + 'options' => [], + ], + [ + 'name' => 'isUnique', + 'fields' => ['department_id', 'email'], + 'options' => [], + ], + [ + 'name' => 'existsIn', + 'fields' => ['department_id'], + 'extra' => 'Departments', + 'options' => [], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests generating unique rules for possibly unique columns based on + * column names instead of on actual unique constraints. + */ + public function testGetRulesForPossiblyUniqueColumns(): void + { + $model = $this->getTableLocator()->get('Users'); + $model->setSchema([ + 'department_id' => ['type' => 'integer', 'null' => false], + 'username' => ['type' => 'string', 'null' => false], + 'login' => ['type' => 'string', 'null' => false], + 'email' => ['type' => 'string', 'null' => false], + ]); + + $command = new ModelCommand(); + $args = new Arguments([], [], []); + $result = $command->getRules($model, [], $args); + $expected = [ + [ + 'name' => 'isUnique', + 'fields' => ['username'], + 'options' => [], + ], + [ + 'name' => 'isUnique', + 'fields' => ['login'], + 'options' => [], + ], + [ + 'name' => 'isUnique', + 'fields' => ['email'], + 'options' => [], + ], + ]; + $this->assertEquals($expected, $result); + + // possibly unique columns should not cause additional rules + // to be generated in case the column is already present in + // an actual unique constraint + + $model->getSchema()->addConstraint('unique_composite', [ + 'type' => 'unique', + 'columns' => ['department_id', 'username'], + ]); + + $result = $command->getRules($model, [], $args); + $expected = [ + [ + 'name' => 'isUnique', + 'fields' => ['login'], + 'options' => [], + ], + [ + 'name' => 'isUnique', + 'fields' => ['email'], + 'options' => [], + ], + [ + 'name' => 'isUnique', + 'fields' => ['department_id', 'username'], + 'options' => [], + ], + ]; + $this->assertEquals($expected, $result); + } + /** * Test that specific behaviors are auto-detected * @@ -1973,7 +2095,53 @@ public function testBakeTableWithPlugin() } /** - * test generation with counter cach + * test generation with enum + * + * @return void + */ + public function testBakeTableWithEnum(): void + { + $this->generatedFile = APP . 'Model/Table/BakeUsersTable.php'; + + $this->exec('bake model --no-validation --no-test --no-fixture --no-entity BakeUsers'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $result = file_get_contents($this->generatedFile); + $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 * * @return void */ 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/testBakeAssociationDetectionCategoriesProductsTable.php b/tests/comparisons/Model/testBakeAssociationDetectionCategoriesProductsTable.php index d1f7d554..c5fff2bf 100644 --- a/tests/comparisons/Model/testBakeAssociationDetectionCategoriesProductsTable.php +++ b/tests/comparisons/Model/testBakeAssociationDetectionCategoriesProductsTable.php @@ -63,8 +63,8 @@ public function initialize(array $config): void */ public function buildRules(RulesChecker $rules): RulesChecker { - $rules->add($rules->existsIn('category_id', 'Categories'), ['errorField' => 'category_id']); - $rules->add($rules->existsIn('product_id', 'Products'), ['errorField' => 'product_id']); + $rules->add($rules->existsIn(['category_id'], 'Categories'), ['errorField' => 'category_id']); + $rules->add($rules->existsIn(['product_id'], 'Products'), ['errorField' => 'product_id']); return $rules; } diff --git a/tests/comparisons/Model/testBakeAssociationDetectionProductVersionsTable.php b/tests/comparisons/Model/testBakeAssociationDetectionProductVersionsTable.php index c4c27056..c6723816 100644 --- a/tests/comparisons/Model/testBakeAssociationDetectionProductVersionsTable.php +++ b/tests/comparisons/Model/testBakeAssociationDetectionProductVersionsTable.php @@ -78,7 +78,7 @@ public function validationDefault(Validator $validator): Validator */ public function buildRules(RulesChecker $rules): RulesChecker { - $rules->add($rules->existsIn('product_id', 'Products'), ['errorField' => 'product_id']); + $rules->add($rules->existsIn(['product_id'], 'Products'), ['errorField' => 'product_id']); return $rules; } diff --git a/tests/comparisons/Model/testBakeAssociationDetectionProductVersionsTableSigned.php b/tests/comparisons/Model/testBakeAssociationDetectionProductVersionsTableSigned.php index 3e07ce94..9e3a4a62 100644 --- a/tests/comparisons/Model/testBakeAssociationDetectionProductVersionsTableSigned.php +++ b/tests/comparisons/Model/testBakeAssociationDetectionProductVersionsTableSigned.php @@ -78,7 +78,7 @@ public function validationDefault(Validator $validator): Validator */ public function buildRules(RulesChecker $rules): RulesChecker { - $rules->add($rules->existsIn('product_id', 'Products'), ['errorField' => 'product_id']); + $rules->add($rules->existsIn(['product_id'], 'Products'), ['errorField' => 'product_id']); return $rules; } diff --git a/tests/comparisons/Model/testBakeEnum.php b/tests/comparisons/Model/testBakeEnum.php new file mode 100644 index 00000000..41c6faea --- /dev/null +++ b/tests/comparisons/Model/testBakeEnum.php @@ -0,0 +1,21 @@ +name)); + } +} diff --git a/tests/comparisons/Model/testBakeEnumBackedInt.php b/tests/comparisons/Model/testBakeEnumBackedInt.php new file mode 100644 index 00000000..c0c0648c --- /dev/null +++ b/tests/comparisons/Model/testBakeEnumBackedInt.php @@ -0,0 +1,21 @@ +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/comparisons/Model/testBakeTableConfig.php b/tests/comparisons/Model/testBakeTableConfig.php index a27066c3..16309d95 100644 --- a/tests/comparisons/Model/testBakeTableConfig.php +++ b/tests/comparisons/Model/testBakeTableConfig.php @@ -113,7 +113,7 @@ public function validationDefault(Validator $validator): Validator */ public function buildRules(RulesChecker $rules): RulesChecker { - $rules->add($rules->existsIn('user_id', 'Users'), ['errorField' => 'user_id']); + $rules->add($rules->existsIn(['user_id'], 'Users'), ['errorField' => 'user_id']); return $rules; } diff --git a/tests/comparisons/Model/testBakeTableWithCounterCache.php b/tests/comparisons/Model/testBakeTableWithCounterCache.php index 5ed0a531..7e5369e0 100644 --- a/tests/comparisons/Model/testBakeTableWithCounterCache.php +++ b/tests/comparisons/Model/testBakeTableWithCounterCache.php @@ -66,7 +66,7 @@ public function initialize(array $config): void */ public function buildRules(RulesChecker $rules): RulesChecker { - $rules->add($rules->existsIn('todo_item_id', 'TodoItems'), ['errorField' => 'todo_item_id']); + $rules->add($rules->existsIn(['todo_item_id'], 'TodoItems'), ['errorField' => 'todo_item_id']); return $rules; } diff --git a/tests/comparisons/Model/testBakeTableWithEnum.php b/tests/comparisons/Model/testBakeTableWithEnum.php new file mode 100644 index 00000000..07a54bbb --- /dev/null +++ b/tests/comparisons/Model/testBakeTableWithEnum.php @@ -0,0 +1,116 @@ + newEntities(array $data, array $options = []) + * @method \Bake\Test\App\Model\Entity\TestBakeArticle get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args) + * @method \Bake\Test\App\Model\Entity\TestBakeArticle findOrCreate($search, ?callable $callback = null, array $options = []) + * @method \Bake\Test\App\Model\Entity\TestBakeArticle patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) + * @method array<\Bake\Test\App\Model\Entity\TestBakeArticle> patchEntities(iterable $entities, array $data, array $options = []) + * @method \Bake\Test\App\Model\Entity\TestBakeArticle|false save(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method \Bake\Test\App\Model\Entity\TestBakeArticle saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method iterable<\Bake\Test\App\Model\Entity\TestBakeArticle>|\Cake\Datasource\ResultSetInterface<\Bake\Test\App\Model\Entity\TestBakeArticle>|false saveMany(iterable $entities, array $options = []) + * @method iterable<\Bake\Test\App\Model\Entity\TestBakeArticle>|\Cake\Datasource\ResultSetInterface<\Bake\Test\App\Model\Entity\TestBakeArticle> saveManyOrFail(iterable $entities, array $options = []) + * @method iterable<\Bake\Test\App\Model\Entity\TestBakeArticle>|\Cake\Datasource\ResultSetInterface<\Bake\Test\App\Model\Entity\TestBakeArticle>|false deleteMany(iterable $entities, array $options = []) + * @method iterable<\Bake\Test\App\Model\Entity\TestBakeArticle>|\Cake\Datasource\ResultSetInterface<\Bake\Test\App\Model\Entity\TestBakeArticle> deleteManyOrFail(iterable $entities, array $options = []) + * + * @mixin \Cake\ORM\Behavior\TimestampBehavior + */ +class TestBakeArticlesTable extends Table +{ + /** + * Initialize method + * + * @param array $config The configuration for the Table. + * @return void + */ + public function initialize(array $config): void + { + parent::initialize($config); + + $this->setTable('bake_articles'); + $this->setDisplayField('title'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + + $this->getSchema()->setColumnType('status', EnumType::from(BakeUserStatus::class)); + + $this->belongsTo('BakeUsers', [ + 'foreignKey' => 'bake_user_id', + 'joinType' => 'INNER', + ]); + $this->hasMany('BakeComments', [ + 'foreignKey' => 'bake_article_id', + ]); + $this->belongsToMany('BakeTags', [ + 'foreignKey' => 'bake_article_id', + 'targetForeignKey' => 'bake_tag_id', + 'joinTable' => 'bake_articles_bake_tags', + ]); + } + + /** + * Default validation rules. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->numeric('id') + ->allowEmptyString('id', 'create'); + + $validator + ->scalar('name') + ->maxLength('name', 100, 'Name must be shorter than 100 characters.') + ->requirePresence('name', 'create') + ->allowEmptyString('name', null, false); + + $validator + ->nonNegativeInteger('count') + ->requirePresence('count', 'create') + ->allowEmptyString('count', null, false); + + $validator + ->greaterThanOrEqual('price', 0) + ->requirePresence('price', 'create') + ->allowEmptyString('price', null, false); + + $validator + ->email('email') + ->add('email', 'unique', ['rule' => 'validateUnique', 'provider' => 'table']) + ->allowEmptyString('email'); + + $validator + ->uploadedFile('image', [ + 'optional' => true, + 'types' => ['image/jpeg'], + ]) + ->allowEmptyFile('image'); + + return $validator; + } + + /** + * Returns the database connection name to use by default. + * + * @return string + */ + public static function defaultConnectionName(): string + { + return 'test'; + } +} 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/comparisons/Model/testBakeUpdateTableNoFile.php b/tests/comparisons/Model/testBakeUpdateTableNoFile.php index 5b037c52..0d262638 100644 --- a/tests/comparisons/Model/testBakeUpdateTableNoFile.php +++ b/tests/comparisons/Model/testBakeUpdateTableNoFile.php @@ -113,7 +113,7 @@ public function validationDefault(Validator $validator): Validator */ public function buildRules(RulesChecker $rules): RulesChecker { - $rules->add($rules->existsIn('user_id', 'Users'), ['errorField' => 'user_id']); + $rules->add($rules->existsIn(['user_id'], 'Users'), ['errorField' => 'user_id']); return $rules; } diff --git a/tests/schema.php b/tests/schema.php index 4075cad5..561ff0f7 100644 --- a/tests/schema.php +++ b/tests/schema.php @@ -208,7 +208,7 @@ 'body' => 'text', 'rating' => ['type' => 'float', 'unsigned' => true, 'default' => 0.0, 'null' => false], 'score' => ['type' => 'decimal', 'unsigned' => true, 'default' => 0.0, 'null' => false], - 'published' => ['type' => 'boolean', 'length' => 1, 'default' => false, 'null' => false], + 'published' => ['type' => 'boolean', 'length' => 1, 'default' => false], 'created' => 'datetime', 'updated' => 'datetime', ], @@ -377,6 +377,7 @@ 'id' => ['type' => 'integer'], 'username' => ['type' => 'string', 'null' => true, 'length' => 255], 'password' => ['type' => 'string', 'null' => true, 'length' => 255], + 'status' => ['type' => 'tinyinteger', 'length' => 2, 'default' => null, 'null' => true], 'created' => ['type' => 'timestamp', 'null' => true], 'updated' => ['type' => 'timestamp', 'null' => true], ], 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)); + } +} diff --git a/tests/test_app/App/Model/Enum/BakeUserStatus.php b/tests/test_app/App/Model/Enum/BakeUserStatus.php new file mode 100644 index 00000000..5dcf0ec7 --- /dev/null +++ b/tests/test_app/App/Model/Enum/BakeUserStatus.php @@ -0,0 +1,32 @@ +name)); + } +}