From 985263ae038e24df3ddbe4ee4cd8cc492b38c760 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 26 Sep 2026 19:29:30 +0530 Subject: [PATCH 1/4] Add option to generate entities with concrete properties --- docs/en/usage.md | 51 ++++++ src/CodeGen/CodeParser.php | 2 +- src/Command/ModelCommand.php | 6 + src/View/Helper/DocBlockHelper.php | 168 ++++++++++++++++++ templates/bake/Model/entity.twig | 15 +- tests/TestCase/CodeGen/CodeParserTest.php | 49 +++++ tests/TestCase/Command/ModelCommandTest.php | 69 +++++++ tests/TestCase/TestCase.php | 23 +++ .../View/Helper/DocBlockHelperTest.php | 140 +++++++++++++++ .../testBakeEntityConcreteProperties.php | 65 +++++++ .../testBakeEntityConcretePropertiesEnum.php | 31 ++++ ...stBakeEntityConcretePropertiesNoFields.php | 31 ++++ 12 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 tests/comparisons/Model/testBakeEntityConcreteProperties.php create mode 100644 tests/comparisons/Model/testBakeEntityConcretePropertiesEnum.php create mode 100644 tests/comparisons/Model/testBakeEntityConcretePropertiesNoFields.php diff --git a/docs/en/usage.md b/docs/en/usage.md index cbb0a3e7d..880b8196e 100644 --- a/docs/en/usage.md +++ b/docs/en/usage.md @@ -57,6 +57,57 @@ For non-conventional relations, you can use references in constraints or foreign ->addForeignKey('shipping_country_id', 'countries', 'cid') ``` +### Concrete Entity Properties + +By default entity fields are stored as dynamic fields. Pass `--concrete-properties` to have Bake +declare real PHP properties on the baked entity class, following +[Declaring Concrete Properties](https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties) +in the CakePHP book: + +```bash +bin/cake bake model Articles --concrete-properties +``` + +The generated properties use `public protected(set)` visibility, so they can be read directly +while writes still go through the entity's `set()` API. Class types are referenced by their +short name instead of a fully qualified name: + +```php +use App\Model\Enum\Status; +use Cake\I18n\DateTime; +use Cake\ORM\Entity; + +class Article extends Entity +{ + public protected(set) int $id; + public protected(set) ?string $title; + public protected(set) ?DateTime $created; + public protected(set) ?Status $status; + public protected(set) bool $published; + public protected(set) ?User $author; + public protected(set) ?array $comments; +} +``` + +Classes outside the entity's namespace, like `Cake\I18n\DateTime` or the `Status` enum, are +imported, with imports kept alphabetically ordered. Classes in the same namespace, like the +`User` association (`App\Model\Entity\User`), don't need an import and are referenced by their +short name directly. + +Note that: + +- Fields used by `Cake\ORM\Entity` itself, such as `hidden`, `patchable`, `dirty` and `errors`, + are skipped so those remain dynamic fields. +- No property is initialized, not even the nullable ones. Fields which have not been hydrated, + like an association which has not been loaded, are uninitialized, so reading them directly + raises an `Error` about accessing an uninitialized property. The entity API handles this + safely: `$article->get('author')` and `hasValue('author')` return `null` and `false` + respectively for such fields. +- The `@property` annotations in the class docblock are still generated as they can express + types like an array of entities that PHP property types cannot. +- Re-baking an existing entity with the option and `--update` does not duplicate the + declarations. + ## Bake Enums You can use Bake to generate [backed enums](https://www.php.net/manual/en/language.enumerations.backed.php) for use in your models. diff --git a/src/CodeGen/CodeParser.php b/src/CodeGen/CodeParser.php index 071f0f9dc..63536c249 100644 --- a/src/CodeGen/CodeParser.php +++ b/src/CodeGen/CodeParser.php @@ -60,7 +60,7 @@ class CodeParser extends NodeVisitorAbstract */ public function __construct() { - $version = PhpVersion::fromComponents(8, 1); + $version = PhpVersion::fromComponents(8, 4); $this->parser = new ParserFactory()->createForVersion($version); $this->traverser = new NodeTraverser(); $this->traverser->addVisitor($this); diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index 4a363a24d..cbbaca7eb 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -168,6 +168,7 @@ public function getTableContext( $connection = $this->connection; $hidden = $this->getHiddenFields($tableObject); $enumSchema = $this->getEnumDefinitions($tableObject->getSchema()); + $concreteProperties = (bool)$this->args->getOption('concrete-properties'); return compact( 'associations', @@ -183,6 +184,7 @@ public function getTableContext( 'connection', 'hidden', 'enumSchema', + 'concreteProperties', ); } @@ -1366,6 +1368,10 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption ])->addOption('no-hidden', [ 'boolean' => true, 'help' => 'Disable generating hidden fields in the entity.', + ])->addOption('concrete-properties', [ + 'boolean' => true, + 'help' => 'Declare concrete class properties on the entity for its columns and associated entities. ' + . 'Note that writes to these properties must go through set() as they use `protected(set)` visibility.', ])->addOption('hidden', [ 'help' => 'A comma separated list of fields to hide.', ])->addOption('primary-key', [ diff --git a/src/View/Helper/DocBlockHelper.php b/src/View/Helper/DocBlockHelper.php index b83eae27d..6a3e76d8c 100644 --- a/src/View/Helper/DocBlockHelper.php +++ b/src/View/Helper/DocBlockHelper.php @@ -3,13 +3,16 @@ namespace Bake\View\Helper; +use Bake\CodeGen\ImportHelper; use Cake\Collection\Collection; use Cake\Core\App; use Cake\Database\Type\EnumType; use Cake\Database\TypeFactory; use Cake\ORM\Association; +use Cake\ORM\Entity; use Cake\Utility\Inflector; use Cake\View\Helper; +use ReflectionProperty; /** * DocBlock helper @@ -165,6 +168,171 @@ public function buildEntityAssociationHintTypeMap(array $propertySchema): array return $properties; } + /** + * Builds a map of concrete PHP property declarations for an entity class. + * + * Declarations use `public protected(set)` visibility as recommended by + * the CakePHP 6 documentation on declaring concrete properties. No + * property is initialized, so fields which have not been hydrated, like + * an association which has not been loaded, are uninitialized. + * + * Class types are resolved to their imported name when the class is part + * of `$classImports`, to their short name when the class is part of + * `$namespace`, and to their fully qualified class name otherwise. + * + * Property names used by `Cake\ORM\Entity` itself are skipped as those + * fields have to remain dynamic fields. + * + * @see https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties + * @param array> $propertySchema The property schema to use for generating the declarations. + * @param array $classImports Class imports as [alias => class name] used for resolving type names. + * @param string $namespace The namespace of the file the properties are generated for. + * @return array Map of property name to property declaration. + */ + public function buildEntityPropertyDeclarations( + array $propertySchema, + array $classImports = [], + string $namespace = '', + ): array { + $imports = ImportHelper::normalize($classImports); + + $declarations = []; + foreach ($this->entityPropertyTypes($propertySchema) as $property => $info) { + $type = $info['type']; + if ($info['class'] !== null) { + $type = $this->resolvePropertyType($info['class'], $type, $imports, $namespace); + } + + $declarations[$property] = "public protected(set) {$type} \${$property};"; + } + + return $declarations; + } + + /** + * Builds the list of classes used by the concrete property declarations + * of an entity class so they can be added to the file's imports. + * + * Classes that are part of `$namespace` are not included as they can be + * referenced by their short name without an import. + * + * @see https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties + * @param array> $propertySchema The property schema to use for generating the imports. + * @param string $namespace The namespace of the file the properties are generated for. + * @return array List of fully qualified class names without a leading backslash. + */ + public function buildEntityPropertyImports(array $propertySchema, string $namespace = ''): array + { + $imports = []; + foreach ($this->entityPropertyTypes($propertySchema) as $info) { + if ($info['class'] === null) { + continue; + } + + $class = ltrim($info['class'], '\\'); + if ($namespace !== '' && str_starts_with($class, $namespace . '\\')) { + continue; + } + + $imports[] = $class; + } + + return array_values(array_unique($imports)); + } + + /** + * Resolves a class based property type to the shortest correct name. + * + * Imported classes are referenced by their alias, classes part of the + * file's namespace by their short name when no import shadows it, and + * all other classes by their fully qualified class name. + * + * @param string $class The fully qualified class name with a leading backslash. + * @param string $type The property type containing the class name. + * @param array $imports Class imports as [alias => class name]. + * @param string $namespace The namespace of the file the type is used in. + * @return string The resolved property type. + */ + protected function resolvePropertyType(string $class, string $type, array $imports, string $namespace): string + { + $alias = array_search(ltrim($class, '\\'), $imports, true); + if (is_string($alias)) { + return str_replace($class, $alias, $type); + } + + $shortName = substr($class, strrpos($class, '\\') + 1); + $isSameNamespace = $namespace !== '' && str_starts_with(ltrim($class, '\\'), $namespace . '\\'); + if ($isSameNamespace && !isset($imports[$shortName])) { + return str_replace($class, $shortName, $type); + } + + return $type; + } + + /** + * Builds the PHP type information used for an entity's concrete properties. + * + * @param array> $propertySchema The property schema to use for generating the type information. + * @return array Map of property name to type information. + */ + protected function entityPropertyTypes(array $propertySchema): array + { + $reserved = $this->reservedEntityPropertyNames(); + + $types = []; + foreach ($propertySchema as $property => $info) { + if (isset($reserved[$property])) { + continue; + } + + if ($info['kind'] === 'column') { + $type = $this->columnTypeToHintType($info['type']) ?? 'string'; + if (str_contains($type, '|')) { + // Union types like `string|resource` have no matching PHP type. + $types[$property] = ['type' => 'mixed', 'class' => null]; + + continue; + } + + // Class types like `\Cake\I18n\DateTime` start with a backslash. + $types[$property] = [ + 'type' => (!empty($info['null']) ? '?' : '') . $type, + 'class' => str_starts_with($type, '\\') ? $type : null, + ]; + + continue; + } + + $type = $this->associatedEntityTypeToHintType($info['type'], $info['association']); + if (str_ends_with($type, '[]') || str_starts_with($type, 'array<')) { + // PHP types cannot express an array of entities, + // the `@property` annotation keeps that hint. + $types[$property] = ['type' => '?array', 'class' => null]; + + continue; + } + + // Association types are nullable as loading an association + // which has no result sets the field to `null`. + $types[$property] = ['type' => '?' . $type, 'class' => $type]; + } + + return $types; + } + + /** + * Gets the property names that cannot be declared on entity classes as + * they are used by `Cake\ORM\Entity` itself. + * + * @return array The reserved property names. + */ + protected function reservedEntityPropertyNames(): array + { + $properties = (new ReflectionProperty(Entity::class, 'restrictedProperties'))->getValue(); + + return is_array($properties) ? $properties : []; + } + /** * Converts a column type to its DocBlock type counterpart. * diff --git a/templates/bake/Model/entity.twig b/templates/bake/Model/entity.twig index 8b3944f0a..f4cf78657 100644 --- a/templates/bake/Model/entity.twig +++ b/templates/bake/Model/entity.twig @@ -15,6 +15,10 @@ #} {% set propertyHintMap = DocBlock.buildEntityPropertyHintTypeMap(propertySchema ?: []) %} {% set associationHintMap = DocBlock.buildEntityAssociationHintTypeMap(propertySchema ?: []) %} +{% set fileNamespace = fileBuilder.namespace %} +{% set propertyImports = concreteProperties|default(false) ? DocBlock.buildEntityPropertyImports(propertySchema ?: [], fileNamespace) : [] %} +{% set classImports = fileBuilder.classImports(['Cake\\ORM\\Entity']|merge(propertyImports)) %} +{% set concreteDeclarations = concreteProperties|default(false) ? DocBlock.buildEntityPropertyDeclarations(propertySchema ?: [], classImports, fileNamespace) : {} %} {% set annotations = DocBlock.propertyHints(propertyHintMap) %} {%- if associationHintMap %} @@ -26,7 +30,7 @@ {%- set generatedProperties = [] %} {{ element('Bake.file_header', { namespace: fileBuilder.namespace, - classImports: fileBuilder.classImports(['Cake\\ORM\\Entity']), + classImports: classImports, }) }} {{ DocBlock.classDescription(name, 'Entity', annotations)|raw }} @@ -62,6 +66,15 @@ class {{ name }} extends Entity{{ fileBuilder.classBuilder.implements ? ' implem */ protected array $hidden = {{ Bake.exportVar(hidden, 1)|raw }}; {% endif %} +{% if concreteDeclarations and (accessible or hidden) %} + +{% endif %} +{% if concreteDeclarations %} + {%~ set generatedProperties = generatedProperties|merge(concreteDeclarations|keys) %} + {%~ for declaration in concreteDeclarations %} + {{ declaration }} + {%~ endfor %} +{% endif %} {% set userProperties = fileBuilder.classBuilder.userProperties(generatedProperties) %} {% if userProperties %} diff --git a/tests/TestCase/CodeGen/CodeParserTest.php b/tests/TestCase/CodeGen/CodeParserTest.php index e1567130a..30ec530fb 100644 --- a/tests/TestCase/CodeGen/CodeParserTest.php +++ b/tests/TestCase/CodeGen/CodeParserTest.php @@ -146,6 +146,55 @@ class TestTable extends \Cake\ORM\Table implements IdentityInterface, SomeOther\ ); } + /** + * Test that PHP 8.4 syntax like `protected(set)` properties and + * property hooks can be parsed and round tripped. + * + * @return void + */ + public function testParseConcreteProperties(): void + { + $parser = new CodeParser(); + $file = $parser->parseFile(<<<'PARSE' +password = $value === null ? null : password_hash($value, PASSWORD_DEFAULT); + } + } +} +PARSE,); + + $this->assertSame( + [ + 'id', + 'title', + 'password', + ], + array_keys($file->class->properties), + ); + + $code = <<<'PARSE' + public protected(set) ?string $password { + set (?string $value) { + $this->password = $value === null ? null : password_hash($value, PASSWORD_DEFAULT); + } + } +PARSE; + $this->assertSame($code, $file->class->properties['password']); + } + public function testUseStatements(): void { $parser = new CodeParser(); diff --git a/tests/TestCase/Command/ModelCommandTest.php b/tests/TestCase/Command/ModelCommandTest.php index 14eb58b80..13b506c33 100644 --- a/tests/TestCase/Command/ModelCommandTest.php +++ b/tests/TestCase/Command/ModelCommandTest.php @@ -2180,6 +2180,75 @@ public function testBakeEntityWithPropertyTypeHints(): void $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } + /** + * test baking an entity class with concrete properties for columns and associations. + * + * @return void + */ + public function testBakeEntityConcreteProperties(): void + { + $this->generatedFile = APP . 'Model/Entity/TodoItem.php'; + $this->exec('bake model --no-test --no-fixture --no-table --concrete-properties todo_items'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $this->assertPhpLints($this->generatedFile); + $result = file_get_contents($this->generatedFile); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + + /** + * test baking an entity class with concrete properties and no accessible fields. + * + * @return void + */ + public function testBakeEntityConcretePropertiesNoFields(): void + { + $this->generatedFile = APP . 'Model/Entity/User.php'; + $this->exec('bake model --no-test --no-fixture --no-table --no-fields --no-hidden --concrete-properties users'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $this->assertPhpLints($this->generatedFile); + $result = file_get_contents($this->generatedFile); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + + /** + * test baking an entity with concrete properties over an existing entity does not duplicate them. + * + * @return void + */ + public function testBakeEntityConcretePropertiesUpdate(): void + { + $this->generatedFile = APP . 'Model/Entity/TodoItem.php'; + $expected = file_get_contents($this->compareBasePath . 'testBakeEntityConcreteProperties.php'); + file_put_contents($this->generatedFile, str_replace("\r\n", "\n", $expected)); + + $this->exec('bake model --no-test --no-fixture --no-table --concrete-properties --update todo_items'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $this->assertSame($expected, file_get_contents($this->generatedFile)); + } + + /** + * test baking an entity class with an enum column and concrete properties. + * + * @return void + */ + public function testBakeEntityConcretePropertiesEnum(): void + { + $this->generatedFile = APP . 'Model/Entity/Article.php'; + $this->exec('bake model --no-test --no-fixture --no-table --no-fields --concrete-properties Articles'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $this->assertPhpLints($this->generatedFile); + $result = file_get_contents($this->generatedFile); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + /** * test baking an entity class * diff --git a/tests/TestCase/TestCase.php b/tests/TestCase/TestCase.php index d680bab57..446fbdde6 100644 --- a/tests/TestCase/TestCase.php +++ b/tests/TestCase/TestCase.php @@ -132,4 +132,27 @@ protected function assertFileNotContains($expected, $path, $message = '') $contents = file_get_contents($path); $this->assertStringNotContainsString($expected, $contents, $message); } + + /** + * Assert that a generated PHP file passes the PHP linter. + * + * Useful as generated code can be syntactically valid for the parser + * used when updating existing files, while still being invalid for + * the PHP runtime. For example a non-nullable typed property + * initialized with a `null` default value. + * + * @param string $path The path to check. + * @return void + */ + protected function assertPhpLints($path) + { + $this->assertFileExists($path, 'Cannot lint, file does not exist.'); + + $output = []; + $status = 0; + exec(escapeshellarg(PHP_BINARY) . ' -l ' . escapeshellarg($path) . ' 2>&1', $output, $status); + + $message = "php -l reported errors for `{$path}`:\n" . implode("\n", $output); + $this->assertSame(0, $status, $message); + } } diff --git a/tests/TestCase/View/Helper/DocBlockHelperTest.php b/tests/TestCase/View/Helper/DocBlockHelperTest.php index d32a8f8d4..81e9c04f3 100644 --- a/tests/TestCase/View/Helper/DocBlockHelperTest.php +++ b/tests/TestCase/View/Helper/DocBlockHelperTest.php @@ -228,6 +228,146 @@ public function testBuildEntityAssociationHintTypeMap(): void $this->markTestIncomplete('Not implemented yet'); } + /** + * Tests the buildEntityPropertyDeclarations method + * + * @return void + */ + public function testBuildEntityPropertyDeclarations(): void + { + $namespace = 'App\Model\Entity'; + $imports = [ + 'Date' => 'Cake\I18n\Date', + ]; + + $expected = [ + 'id' => 'public protected(set) int $id;', + 'title' => 'public protected(set) ?string $title;', + 'data' => 'public protected(set) ?array $data;', + // `string|resource` cannot be expressed as a PHP type. + 'file' => 'public protected(set) mixed $file;', + 'published' => 'public protected(set) Date $published;', + // Classes part of the namespace are referenced by their short name. + 'author' => 'public protected(set) ?User $author;', + // An array of entities cannot be expressed as a PHP type. + 'revisions' => 'public protected(set) ?array $revisions;', + // Classes outside the namespace without an import keep their FQCN. + 'editor' => 'public protected(set) ?\App\Other\Entity\Editor $editor;', + ]; + $this->assertSame( + $expected, + $this->DocBlockHelper->buildEntityPropertyDeclarations($this->propertySchema(), $imports, $namespace), + ); + + // Without namespace or imports fully qualified class names are used. + $this->assertSame( + 'public protected(set) ?\App\Model\Entity\User $author;', + $this->DocBlockHelper->buildEntityPropertyDeclarations(['author' => $this->propertySchema()['author']])['author'], + ); + + // Classes part of the namespace shadowed by an import keep their FQCN. + $this->assertSame( + 'public protected(set) ?\App\Model\Entity\User $author;', + $this->DocBlockHelper->buildEntityPropertyDeclarations( + ['author' => $this->propertySchema()['author']], + ['User' => 'App\Other\User'], + $namespace, + )['author'], + ); + } + + /** + * Tests the buildEntityPropertyImports method + * + * @return void + */ + public function testBuildEntityPropertyImports(): void + { + $schema = $this->propertySchema(); + + // Classes part of the namespace don't need an import. + $this->assertSame( + [ + 'Cake\I18n\Date', + 'App\Other\Entity\Editor', + ], + $this->DocBlockHelper->buildEntityPropertyImports($schema, 'App\Model\Entity'), + ); + + $this->assertSame( + [ + 'Cake\I18n\Date', + 'App\Model\Entity\User', + 'App\Other\Entity\Editor', + ], + $this->DocBlockHelper->buildEntityPropertyImports($schema), + ); + } + + /** + * Property schema used for testing the concrete property generation. + * + * @return array> + */ + protected function propertySchema(): array + { + $sourceTable = new Table(['alias' => 'Source']); + + return [ + 'id' => [ + 'kind' => 'column', + 'type' => 'integer', + 'null' => false, + ], + 'title' => [ + 'kind' => 'column', + 'type' => 'string', + 'null' => true, + ], + 'data' => [ + 'kind' => 'column', + 'type' => 'array', + 'null' => true, + ], + 'file' => [ + 'kind' => 'column', + 'type' => 'binary', + 'null' => true, + ], + 'published' => [ + 'kind' => 'column', + 'type' => 'date', + 'null' => false, + ], + // Fields used by Cake\ORM\Entity itself have to stay dynamic fields. + 'hidden' => [ + 'kind' => 'column', + 'type' => 'array', + 'null' => false, + ], + 'patchable' => [ + 'kind' => 'column', + 'type' => 'array', + 'null' => false, + ], + 'author' => [ + 'kind' => 'association', + 'type' => '\App\Model\Entity\User', + 'association' => new BelongsTo('Author', $sourceTable), + ], + 'revisions' => [ + 'kind' => 'association', + 'type' => '\App\Model\Entity\Revision', + 'association' => new HasMany('Revisions', $sourceTable), + ], + 'editor' => [ + 'kind' => 'association', + 'type' => '\App\Other\Entity\Editor', + 'association' => new BelongsTo('Editor', $sourceTable), + ], + ]; + } + /** * Tests the columnTypeToHintType method * diff --git a/tests/comparisons/Model/testBakeEntityConcreteProperties.php b/tests/comparisons/Model/testBakeEntityConcreteProperties.php new file mode 100644 index 000000000..87b37dd2d --- /dev/null +++ b/tests/comparisons/Model/testBakeEntityConcreteProperties.php @@ -0,0 +1,65 @@ + $todo_tasks + * @property array<\Bake\Test\App\Model\Entity\TodoLabel> $todo_labels + */ +class TodoItem extends Entity +{ + /** + * Fields that can be mass assigned using newEntity() or patchEntity(). + * + * Note that when '*' is set to true, this allows all unspecified fields to + * be mass assigned. For security purposes, it is advised to set '*' to false + * (or remove it), and explicitly make individual fields accessible as needed. + * + * @var array + */ + protected array $patchable = [ + 'user_id' => true, + 'title' => true, + 'body' => true, + 'effort' => true, + 'completed' => true, + 'todo_task_count' => true, + 'created' => true, + 'updated' => true, + 'user' => true, + 'todo_reminder' => true, + 'todo_tasks' => true, + 'todo_labels' => true, + ]; + + public protected(set) int $id; + public protected(set) int $user_id; + public protected(set) string $title; + public protected(set) ?string $body; + public protected(set) string $effort; + public protected(set) bool $completed; + public protected(set) int $todo_task_count; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?User $user; + public protected(set) ?TodoReminder $todo_reminder; + public protected(set) ?array $todo_tasks; + public protected(set) ?array $todo_labels; +} diff --git a/tests/comparisons/Model/testBakeEntityConcretePropertiesEnum.php b/tests/comparisons/Model/testBakeEntityConcretePropertiesEnum.php new file mode 100644 index 000000000..b40bbe62e --- /dev/null +++ b/tests/comparisons/Model/testBakeEntityConcretePropertiesEnum.php @@ -0,0 +1,31 @@ + $tags + * @property array<\Bake\Test\App\Model\Entity\ArticlesTag> $articles_tags + */ +class Article extends Entity +{ + public protected(set) int $id; + public protected(set) ?int $author_id; + public protected(set) ?string $title; + public protected(set) ?string $body; + public protected(set) ?ArticleStatus $published; + public protected(set) ?Author $author; + public protected(set) ?array $tags; + public protected(set) ?array $articles_tags; +} diff --git a/tests/comparisons/Model/testBakeEntityConcretePropertiesNoFields.php b/tests/comparisons/Model/testBakeEntityConcretePropertiesNoFields.php new file mode 100644 index 000000000..bae1f2a50 --- /dev/null +++ b/tests/comparisons/Model/testBakeEntityConcretePropertiesNoFields.php @@ -0,0 +1,31 @@ + $comments + * @property array<\Bake\Test\App\Model\Entity\Relation> $relations + * @property array<\Bake\Test\App\Model\Entity\TodoItem> $todo_items + */ +class User extends Entity +{ + public protected(set) int $id; + public protected(set) ?string $username; + public protected(set) ?string $password; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?array $comments; + public protected(set) ?array $relations; + public protected(set) ?array $todo_items; +} From 471a365810d318f4cc01e9589e31604fed9225d6 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 27 Sep 2026 16:35:31 +0530 Subject: [PATCH 2/4] Fix rector error --- src/View/Helper/DocBlockHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/View/Helper/DocBlockHelper.php b/src/View/Helper/DocBlockHelper.php index 6a3e76d8c..535b8c23c 100644 --- a/src/View/Helper/DocBlockHelper.php +++ b/src/View/Helper/DocBlockHelper.php @@ -328,7 +328,7 @@ protected function entityPropertyTypes(array $propertySchema): array */ protected function reservedEntityPropertyNames(): array { - $properties = (new ReflectionProperty(Entity::class, 'restrictedProperties'))->getValue(); + $properties = new ReflectionProperty(Entity::class, 'restrictedProperties')->getValue(); return is_array($properties) ? $properties : []; } From 6631174ff8563378cc0dd15611bc24e3e533973d Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 11 Oct 2026 22:08:52 +0530 Subject: [PATCH 3/4] Adresss PR review - Generate entities with concrete properties by default - Move entity properties generation code into separate class --- docs/en/usage.md | 34 +- src/CodeGen/EntityPropertyBuilder.php | 298 ++++++++++++++++++ src/Command/ModelCommand.php | 55 +++- src/View/Helper/DocBlockHelper.php | 265 ++-------------- templates/bake/Model/entity.twig | 4 - .../CodeGen/EntityPropertyBuilderTest.php | 264 ++++++++++++++++ tests/TestCase/Command/ModelCommandTest.php | 51 +-- .../View/Helper/DocBlockHelperTest.php | 140 -------- .../testBakeEntityConcreteProperties.php | 65 ---- .../testBakeEntityConcretePropertiesEnum.php | 31 -- .../Model/testBakeEntityCustomHidden.php | 10 + ...hp => testBakeEntityDynamicProperties.php} | 9 - .../comparisons/Model/testBakeEntityEnum.php | 9 + .../Model/testBakeEntityFieldsDefaults.php | 15 + .../Model/testBakeEntityFieldsWhiteList.php | 15 + .../Model/testBakeEntityFullContext.php | 10 + .../Model/testBakeEntityHidden.php | 10 + .../Model/testBakeEntityNoFields.php | 14 + .../Model/testBakeEntitySimple.php | 9 + .../Model/testBakeEntitySimpleUnchanged.php | 9 + .../Model/testBakeEntityWithPlugin.php | 10 + .../testBakeEntityWithPropertyTypeHints.php | 17 + .../Model/testBakeUpdateEntity.php | 15 + 23 files changed, 801 insertions(+), 558 deletions(-) create mode 100644 src/CodeGen/EntityPropertyBuilder.php create mode 100644 tests/TestCase/CodeGen/EntityPropertyBuilderTest.php delete mode 100644 tests/comparisons/Model/testBakeEntityConcreteProperties.php delete mode 100644 tests/comparisons/Model/testBakeEntityConcretePropertiesEnum.php rename tests/comparisons/Model/{testBakeEntityConcretePropertiesNoFields.php => testBakeEntityDynamicProperties.php} (58%) diff --git a/docs/en/usage.md b/docs/en/usage.md index 880b8196e..da4c18633 100644 --- a/docs/en/usage.md +++ b/docs/en/usage.md @@ -59,24 +59,38 @@ For non-conventional relations, you can use references in constraints or foreign ### Concrete Entity Properties -By default entity fields are stored as dynamic fields. Pass `--concrete-properties` to have Bake -declare real PHP properties on the baked entity class, following +By default Bake declares real PHP properties on the baked entity class for its +columns and associated entities, following [Declaring Concrete Properties](https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties) -in the CakePHP book: +in the CakePHP book. Pass `--no-concrete-properties` if you want to keep entity +fields as dynamic fields, for example to upgrade a codebase at its own pace: ```bash -bin/cake bake model Articles --concrete-properties +bin/cake bake model Articles --no-concrete-properties ``` The generated properties use `public protected(set)` visibility, so they can be read directly while writes still go through the entity's `set()` API. Class types are referenced by their -short name instead of a fully qualified name: +short name instead of a fully qualified name. The `@property` annotations in the class +docblock are still generated as they can express types like an array of entities that PHP +property types cannot: ```php use App\Model\Enum\Status; use Cake\I18n\DateTime; use Cake\ORM\Entity; +/** + * Article Entity + * + * @property int $id + * @property string|null $title + * @property \Cake\I18n\DateTime|null $created + * @property \App\Model\Enum\Status|null $status + * @property bool $published + * @property \App\Model\Entity\User|null $author + * @property array<\App\Model\Entity\Comment> $comments + */ class Article extends Entity { public protected(set) int $id; @@ -89,11 +103,6 @@ class Article extends Entity } ``` -Classes outside the entity's namespace, like `Cake\I18n\DateTime` or the `Status` enum, are -imported, with imports kept alphabetically ordered. Classes in the same namespace, like the -`User` association (`App\Model\Entity\User`), don't need an import and are referenced by their -short name directly. - Note that: - Fields used by `Cake\ORM\Entity` itself, such as `hidden`, `patchable`, `dirty` and `errors`, @@ -103,10 +112,7 @@ Note that: raises an `Error` about accessing an uninitialized property. The entity API handles this safely: `$article->get('author')` and `hasValue('author')` return `null` and `false` respectively for such fields. -- The `@property` annotations in the class docblock are still generated as they can express - types like an array of entities that PHP property types cannot. -- Re-baking an existing entity with the option and `--update` does not duplicate the - declarations. +- Re-baking an existing entity with `--update` does not duplicate the declarations. ## Bake Enums diff --git a/src/CodeGen/EntityPropertyBuilder.php b/src/CodeGen/EntityPropertyBuilder.php new file mode 100644 index 000000000..51114b492 --- /dev/null +++ b/src/CodeGen/EntityPropertyBuilder.php @@ -0,0 +1,298 @@ +> $propertySchema The property schema to use for generating the declarations. + * @param array $classImports Class imports as [alias => class name] used for resolving type names. + * @param string $namespace The namespace of the file the properties are generated for. + * @return array Map of property name to property declaration. + */ + public function buildEntityPropertyDeclarations( + array $propertySchema, + array $classImports = [], + string $namespace = '', + ): array { + $imports = ImportHelper::normalize($classImports); + + $declarations = []; + foreach ($this->entityPropertyTypes($propertySchema) as $property => $info) { + $type = $info['type']; + if ($info['class'] !== null) { + $type = $this->resolvePropertyType($info['class'], $type, $imports, $namespace); + } + + $declarations[$property] = "public protected(set) {$type} \${$property};"; + } + + return $declarations; + } + + /** + * Builds the list of classes used by the concrete property declarations + * of an entity class so they can be added to the file's imports. + * + * Classes that are part of `$namespace` are not included as they can be + * referenced by their short name without an import. + * + * @see https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties + * @param array> $propertySchema The property schema to use for generating the imports. + * @param string $namespace The namespace of the file the properties are generated for. + * @return array List of fully qualified class names without a leading backslash. + */ + public function buildEntityPropertyImports(array $propertySchema, string $namespace = ''): array + { + $imports = []; + foreach ($this->entityPropertyTypes($propertySchema) as $info) { + if ($info['class'] === null) { + continue; + } + + $class = ltrim($info['class'], '\\'); + if ($namespace !== '' && str_starts_with($class, $namespace . '\\')) { + continue; + } + + $imports[] = $class; + } + + return array_values(array_unique($imports)); + } + + /** + * Converts a column type to its DocBlock type counterpart. + * + * This method only supports the default CakePHP column types, + * for custom column/database types `'string'` will be returned. + * + * @see \Cake\Database\Type + * @param string $type The column type. + * @return string|null The DocBlock type, or `'string'` for unsupported column types. + */ + public function columnTypeToHintType(string $type): ?string + { + switch ($type) { + case 'char': + case 'string': + case 'text': + case 'uuid': + case 'decimal': + return 'string'; + + case 'integer': + case 'biginteger': + case 'smallinteger': + case 'tinyinteger': + return 'int'; + + case 'float': + return 'float'; + + case 'boolean': + return 'bool'; + + case 'array': + case 'json': + return 'array'; + + case 'binary': + return 'string|resource'; + + case 'date': + $dbType = TypeFactory::build($type); + if (method_exists($dbType, 'getDateClassName')) { + // allow custom Date class which should extend \Cake\I18n\Date + return '\\' . $dbType->getDateClassName(); + } + + return '\Cake\I18n\Date'; + + case 'datetime': + case 'datetimefractional': + case 'timestamp': + case 'timestampfractional': + case 'timestamptimezone': + $dbType = TypeFactory::build($type); + if (method_exists($dbType, 'getDateTimeClassName')) { + // allow custom DateTime class which should extend \Cake\I18n\DateTime + return '\\' . $dbType->getDateTimeClassName(); + } + + return '\Cake\I18n\DateTime'; + + case 'time': + $dbType = TypeFactory::build($type); + if (method_exists($dbType, 'getTimeClassName')) { + // allow custom Time class which should extend \Cake\I18n\Time + return '\\' . $dbType->getTimeClassName(); + } + + return '\Cake\I18n\Time'; + + default: + if (str_starts_with($type, 'enum-')) { + $dbType = TypeFactory::build($type); + if ($dbType instanceof EnumType) { + return '\\' . $dbType->getEnumClassName(); + } + } + } + + // Any unique or custom types will have a `string` type hint + return 'string'; + } + + /** + * Converts an entity class type to its DocBlock hint type counterpart. + * + * @param string $type The entity class type (a fully qualified class name). + * @param \Cake\ORM\Association $association The association related to the entity class. + * @return string The DocBlock type + */ + public function associatedEntityTypeToHintType(string $type, Association $association): string + { + $annotationType = $association->type(); + if ( + $annotationType === Association::MANY_TO_MANY || + $annotationType === Association::ONE_TO_MANY + ) { + return 'array<' . $type . '>'; + } + + return $type; + } + + /** + * Resolves a class based property type to the shortest correct name. + * + * Imported classes are referenced by their alias, classes part of the + * file's namespace by their short name when no import shadows it, and + * all other classes by their fully qualified class name. + * + * @param string $class The fully qualified class name with a leading backslash. + * @param string $type The property type containing the class name. + * @param array $imports Class imports as [alias => class name]. + * @param string $namespace The namespace of the file the type is used in. + * @return string The resolved property type. + */ + protected function resolvePropertyType(string $class, string $type, array $imports, string $namespace): string + { + $alias = array_search(ltrim($class, '\\'), $imports, true); + if (is_string($alias)) { + return str_replace($class, $alias, $type); + } + + $shortName = substr($class, strrpos($class, '\\') + 1); + $isSameNamespace = $namespace !== '' && str_starts_with(ltrim($class, '\\'), $namespace . '\\'); + if ($isSameNamespace && !isset($imports[$shortName])) { + return str_replace($class, $shortName, $type); + } + + return $type; + } + + /** + * Builds the PHP type information used for an entity's concrete properties. + * + * @param array> $propertySchema The property schema to use for generating the type information. + * @return array Map of property name to type information. + */ + protected function entityPropertyTypes(array $propertySchema): array + { + $reserved = $this->reservedEntityPropertyNames(); + + $types = []; + foreach ($propertySchema as $property => $info) { + if (isset($reserved[$property])) { + continue; + } + + if ($info['kind'] === 'column') { + $type = $this->columnTypeToHintType($info['type']) ?? 'string'; + if (str_contains($type, '|')) { + // Union types like `string|resource` have no matching PHP type. + $types[$property] = ['type' => 'mixed', 'class' => null]; + + continue; + } + + // Class types like `\Cake\I18n\DateTime` start with a backslash. + $types[$property] = [ + 'type' => (!empty($info['null']) ? '?' : '') . $type, + 'class' => str_starts_with($type, '\\') ? $type : null, + ]; + + continue; + } + + $type = $this->associatedEntityTypeToHintType($info['type'], $info['association']); + if (str_ends_with($type, '[]') || str_starts_with($type, 'array<')) { + // PHP types cannot express an array of entities, + // the `@property` annotation keeps that hint. + $types[$property] = ['type' => '?array', 'class' => null]; + + continue; + } + + // Association types are nullable as loading an association + // which has no result sets the field to `null`. + $types[$property] = ['type' => '?' . $type, 'class' => $type]; + } + + return $types; + } + + /** + * Gets the property names that cannot be declared on entity classes as + * they are used by `Cake\ORM\Entity` itself. + * + * @return array The reserved property names. + */ + protected function reservedEntityPropertyNames(): array + { + $properties = new ReflectionProperty(Entity::class, 'restrictedProperties')->getValue(); + + return is_array($properties) ? $properties : []; + } +} diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index cbbaca7eb..350a3640d 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -17,6 +17,7 @@ namespace Bake\Command; use Bake\CodeGen\ColumnTypeExtractor; +use Bake\CodeGen\EntityPropertyBuilder; use Bake\CodeGen\FileBuilder; use Bake\CodeGen\ParsedFile; use Bake\Utility\Model\EnumParser; @@ -62,6 +63,11 @@ class ModelCommand extends BakeCommand */ protected array $tables = []; + /** + * @var \Bake\CodeGen\EntityPropertyBuilder|null + */ + protected ?EntityPropertyBuilder $propertyBuilder = null; + /** * @inheritDoc */ @@ -168,7 +174,6 @@ public function getTableContext( $connection = $this->connection; $hidden = $this->getHiddenFields($tableObject); $enumSchema = $this->getEnumDefinitions($tableObject->getSchema()); - $concreteProperties = (bool)$this->args->getOption('concrete-properties'); return compact( 'associations', @@ -184,7 +189,6 @@ public function getTableContext( 'connection', 'hidden', 'enumSchema', - 'concreteProperties', ); } @@ -1176,13 +1180,41 @@ public function bakeEntity(Table $model, array $data): void $parsedFile = $this->parseFile($filename); } + $entityNamespace = "{$namespace}\Model\Entity"; + $fileBuilder = new FileBuilder($this->io, $entityNamespace, $parsedFile); + + // Concrete properties are the default; `--no-concrete-properties` is the + // escape hatch that keeps entity fields as dynamic fields. + $propertySchema = $data['propertySchema'] ?? []; + $concreteDeclarations = []; + $classImports = ['Cake\\ORM\\Entity']; + if (!$this->args->getOption('no-concrete-properties')) { + $builder = $this->getPropertyBuilder(); + $classImports = array_merge( + $classImports, + $builder->buildEntityPropertyImports($propertySchema, $entityNamespace), + ); + // Resolve the imports against any existing file imports so that + // declarations use the same aliases as the file header. + $classImports = $fileBuilder->getClassImports($classImports); + $concreteDeclarations = $builder->buildEntityPropertyDeclarations( + $propertySchema, + $classImports, + $entityNamespace, + ); + } else { + $classImports = $fileBuilder->getClassImports($classImports); + } + $data += [ 'name' => $name, 'namespace' => $namespace, 'plugin' => $this->plugin, 'pluginPath' => $pluginPath, 'primaryKey' => [], - 'fileBuilder' => new FileBuilder($this->io, "{$namespace}\Model\Entity", $parsedFile), + 'fileBuilder' => $fileBuilder, + 'classImports' => $classImports, + 'concreteDeclarations' => $concreteDeclarations, ]; $contents = $this->createTemplateRenderer() @@ -1195,6 +1227,17 @@ public function bakeEntity(Table $model, array $data): void $this->deleteEmptyFile($emptyFile); } + /** + * Gets the shared entity property builder used for generating concrete + * entity property declarations. + * + * @return \Bake\CodeGen\EntityPropertyBuilder + */ + protected function getPropertyBuilder(): EntityPropertyBuilder + { + return $this->propertyBuilder ??= new EntityPropertyBuilder(); + } + /** * Bake a table class. * @@ -1368,10 +1411,10 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption ])->addOption('no-hidden', [ 'boolean' => true, 'help' => 'Disable generating hidden fields in the entity.', - ])->addOption('concrete-properties', [ + ])->addOption('no-concrete-properties', [ 'boolean' => true, - 'help' => 'Declare concrete class properties on the entity for its columns and associated entities. ' - . 'Note that writes to these properties must go through set() as they use `protected(set)` visibility.', + 'help' => 'Store entity fields as dynamic fields instead of declaring concrete class properties ' + . 'for its columns and associated entities.', ])->addOption('hidden', [ 'help' => 'A comma separated list of fields to hide.', ])->addOption('primary-key', [ diff --git a/src/View/Helper/DocBlockHelper.php b/src/View/Helper/DocBlockHelper.php index 535b8c23c..3f54fc2c3 100644 --- a/src/View/Helper/DocBlockHelper.php +++ b/src/View/Helper/DocBlockHelper.php @@ -3,16 +3,12 @@ namespace Bake\View\Helper; -use Bake\CodeGen\ImportHelper; +use Bake\CodeGen\EntityPropertyBuilder; use Cake\Collection\Collection; use Cake\Core\App; -use Cake\Database\Type\EnumType; -use Cake\Database\TypeFactory; use Cake\ORM\Association; -use Cake\ORM\Entity; use Cake\Utility\Inflector; use Cake\View\Helper; -use ReflectionProperty; /** * DocBlock helper @@ -26,6 +22,11 @@ class DocBlockHelper extends Helper */ protected bool $annotationSpacing = false; + /** + * @var \Bake\CodeGen\EntityPropertyBuilder|null + */ + protected ?EntityPropertyBuilder $propertyBuilder = null; + /** * Writes the DocBlock header for a class which includes the property and method declarations. Annotations are * sorted and grouped by type and value. If enabled via the `$annotationSpacing` setting of this helper, groups of @@ -79,15 +80,7 @@ public function classDescription(string $className, string $classType, array $an */ public function associatedEntityTypeToHintType(string $type, Association $association): string { - $annotationType = $association->type(); - if ( - $annotationType === Association::MANY_TO_MANY || - $annotationType === Association::ONE_TO_MANY - ) { - return 'array<' . $type . '>'; - } - - return $type; + return $this->getPropertyBuilder()->associatedEntityTypeToHintType($type, $association); } /** @@ -168,171 +161,6 @@ public function buildEntityAssociationHintTypeMap(array $propertySchema): array return $properties; } - /** - * Builds a map of concrete PHP property declarations for an entity class. - * - * Declarations use `public protected(set)` visibility as recommended by - * the CakePHP 6 documentation on declaring concrete properties. No - * property is initialized, so fields which have not been hydrated, like - * an association which has not been loaded, are uninitialized. - * - * Class types are resolved to their imported name when the class is part - * of `$classImports`, to their short name when the class is part of - * `$namespace`, and to their fully qualified class name otherwise. - * - * Property names used by `Cake\ORM\Entity` itself are skipped as those - * fields have to remain dynamic fields. - * - * @see https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties - * @param array> $propertySchema The property schema to use for generating the declarations. - * @param array $classImports Class imports as [alias => class name] used for resolving type names. - * @param string $namespace The namespace of the file the properties are generated for. - * @return array Map of property name to property declaration. - */ - public function buildEntityPropertyDeclarations( - array $propertySchema, - array $classImports = [], - string $namespace = '', - ): array { - $imports = ImportHelper::normalize($classImports); - - $declarations = []; - foreach ($this->entityPropertyTypes($propertySchema) as $property => $info) { - $type = $info['type']; - if ($info['class'] !== null) { - $type = $this->resolvePropertyType($info['class'], $type, $imports, $namespace); - } - - $declarations[$property] = "public protected(set) {$type} \${$property};"; - } - - return $declarations; - } - - /** - * Builds the list of classes used by the concrete property declarations - * of an entity class so they can be added to the file's imports. - * - * Classes that are part of `$namespace` are not included as they can be - * referenced by their short name without an import. - * - * @see https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties - * @param array> $propertySchema The property schema to use for generating the imports. - * @param string $namespace The namespace of the file the properties are generated for. - * @return array List of fully qualified class names without a leading backslash. - */ - public function buildEntityPropertyImports(array $propertySchema, string $namespace = ''): array - { - $imports = []; - foreach ($this->entityPropertyTypes($propertySchema) as $info) { - if ($info['class'] === null) { - continue; - } - - $class = ltrim($info['class'], '\\'); - if ($namespace !== '' && str_starts_with($class, $namespace . '\\')) { - continue; - } - - $imports[] = $class; - } - - return array_values(array_unique($imports)); - } - - /** - * Resolves a class based property type to the shortest correct name. - * - * Imported classes are referenced by their alias, classes part of the - * file's namespace by their short name when no import shadows it, and - * all other classes by their fully qualified class name. - * - * @param string $class The fully qualified class name with a leading backslash. - * @param string $type The property type containing the class name. - * @param array $imports Class imports as [alias => class name]. - * @param string $namespace The namespace of the file the type is used in. - * @return string The resolved property type. - */ - protected function resolvePropertyType(string $class, string $type, array $imports, string $namespace): string - { - $alias = array_search(ltrim($class, '\\'), $imports, true); - if (is_string($alias)) { - return str_replace($class, $alias, $type); - } - - $shortName = substr($class, strrpos($class, '\\') + 1); - $isSameNamespace = $namespace !== '' && str_starts_with(ltrim($class, '\\'), $namespace . '\\'); - if ($isSameNamespace && !isset($imports[$shortName])) { - return str_replace($class, $shortName, $type); - } - - return $type; - } - - /** - * Builds the PHP type information used for an entity's concrete properties. - * - * @param array> $propertySchema The property schema to use for generating the type information. - * @return array Map of property name to type information. - */ - protected function entityPropertyTypes(array $propertySchema): array - { - $reserved = $this->reservedEntityPropertyNames(); - - $types = []; - foreach ($propertySchema as $property => $info) { - if (isset($reserved[$property])) { - continue; - } - - if ($info['kind'] === 'column') { - $type = $this->columnTypeToHintType($info['type']) ?? 'string'; - if (str_contains($type, '|')) { - // Union types like `string|resource` have no matching PHP type. - $types[$property] = ['type' => 'mixed', 'class' => null]; - - continue; - } - - // Class types like `\Cake\I18n\DateTime` start with a backslash. - $types[$property] = [ - 'type' => (!empty($info['null']) ? '?' : '') . $type, - 'class' => str_starts_with($type, '\\') ? $type : null, - ]; - - continue; - } - - $type = $this->associatedEntityTypeToHintType($info['type'], $info['association']); - if (str_ends_with($type, '[]') || str_starts_with($type, 'array<')) { - // PHP types cannot express an array of entities, - // the `@property` annotation keeps that hint. - $types[$property] = ['type' => '?array', 'class' => null]; - - continue; - } - - // Association types are nullable as loading an association - // which has no result sets the field to `null`. - $types[$property] = ['type' => '?' . $type, 'class' => $type]; - } - - return $types; - } - - /** - * Gets the property names that cannot be declared on entity classes as - * they are used by `Cake\ORM\Entity` itself. - * - * @return array The reserved property names. - */ - protected function reservedEntityPropertyNames(): array - { - $properties = new ReflectionProperty(Entity::class, 'restrictedProperties')->getValue(); - - return is_array($properties) ? $properties : []; - } - /** * Converts a column type to its DocBlock type counterpart. * @@ -345,75 +173,18 @@ protected function reservedEntityPropertyNames(): array */ public function columnTypeToHintType(string $type): ?string { - switch ($type) { - case 'char': - case 'string': - case 'text': - case 'uuid': - case 'decimal': - return 'string'; - - case 'integer': - case 'biginteger': - case 'smallinteger': - case 'tinyinteger': - return 'int'; - - case 'float': - return 'float'; - - case 'boolean': - return 'bool'; - - case 'array': - case 'json': - return 'array'; - - case 'binary': - return 'string|resource'; - - case 'date': - $dbType = TypeFactory::build($type); - if (method_exists($dbType, 'getDateClassName')) { - // allow custom Date class which should extend \Cake\I18n\Date - return '\\' . $dbType->getDateClassName(); - } - - return '\Cake\I18n\Date'; - - case 'datetime': - case 'datetimefractional': - case 'timestamp': - case 'timestampfractional': - case 'timestamptimezone': - $dbType = TypeFactory::build($type); - if (method_exists($dbType, 'getDateTimeClassName')) { - // allow custom DateTime class which should extend \Cake\I18n\DateTime - return '\\' . $dbType->getDateTimeClassName(); - } - - return '\Cake\I18n\DateTime'; - - case 'time': - $dbType = TypeFactory::build($type); - if (method_exists($dbType, 'getTimeClassName')) { - // allow custom Time class which should extend \Cake\I18n\Time - return '\\' . $dbType->getTimeClassName(); - } - - return '\Cake\I18n\Time'; - - default: - if (str_starts_with($type, 'enum-')) { - $dbType = TypeFactory::build($type); - if ($dbType instanceof EnumType) { - return '\\' . $dbType->getEnumClassName(); - } - } - } + return $this->getPropertyBuilder()->columnTypeToHintType($type); + } - // Any unique or custom types will have a `string` type hint - return 'string'; + /** + * Gets the shared entity property builder used for resolving column and + * association types. + * + * @return \Bake\CodeGen\EntityPropertyBuilder + */ + protected function getPropertyBuilder(): EntityPropertyBuilder + { + return $this->propertyBuilder ??= new EntityPropertyBuilder(); } /** diff --git a/templates/bake/Model/entity.twig b/templates/bake/Model/entity.twig index f4cf78657..a9c2282c2 100644 --- a/templates/bake/Model/entity.twig +++ b/templates/bake/Model/entity.twig @@ -15,10 +15,6 @@ #} {% set propertyHintMap = DocBlock.buildEntityPropertyHintTypeMap(propertySchema ?: []) %} {% set associationHintMap = DocBlock.buildEntityAssociationHintTypeMap(propertySchema ?: []) %} -{% set fileNamespace = fileBuilder.namespace %} -{% set propertyImports = concreteProperties|default(false) ? DocBlock.buildEntityPropertyImports(propertySchema ?: [], fileNamespace) : [] %} -{% set classImports = fileBuilder.classImports(['Cake\\ORM\\Entity']|merge(propertyImports)) %} -{% set concreteDeclarations = concreteProperties|default(false) ? DocBlock.buildEntityPropertyDeclarations(propertySchema ?: [], classImports, fileNamespace) : {} %} {% set annotations = DocBlock.propertyHints(propertyHintMap) %} {%- if associationHintMap %} diff --git a/tests/TestCase/CodeGen/EntityPropertyBuilderTest.php b/tests/TestCase/CodeGen/EntityPropertyBuilderTest.php new file mode 100644 index 000000000..004ebc331 --- /dev/null +++ b/tests/TestCase/CodeGen/EntityPropertyBuilderTest.php @@ -0,0 +1,264 @@ +builder = new EntityPropertyBuilder(); + } + + /** + * Tests the buildEntityPropertyDeclarations method + * + * @return void + */ + public function testBuildEntityPropertyDeclarations(): void + { + $namespace = 'App\Model\Entity'; + $imports = [ + 'Date' => 'Cake\I18n\Date', + ]; + + $expected = [ + 'id' => 'public protected(set) int $id;', + 'title' => 'public protected(set) ?string $title;', + 'data' => 'public protected(set) ?array $data;', + // `string|resource` cannot be expressed as a PHP type. + 'file' => 'public protected(set) mixed $file;', + 'published' => 'public protected(set) Date $published;', + // Classes part of the namespace are referenced by their short name. + 'author' => 'public protected(set) ?User $author;', + // An array of entities cannot be expressed as a PHP type. + 'revisions' => 'public protected(set) ?array $revisions;', + // Classes outside the namespace without an import keep their FQCN. + 'editor' => 'public protected(set) ?\App\Other\Entity\Editor $editor;', + ]; + $this->assertSame( + $expected, + $this->builder->buildEntityPropertyDeclarations($this->propertySchema(), $imports, $namespace), + ); + + // Without namespace or imports fully qualified class names are used. + $this->assertSame( + 'public protected(set) ?\App\Model\Entity\User $author;', + $this->builder->buildEntityPropertyDeclarations(['author' => $this->propertySchema()['author']])['author'], + ); + + // Classes part of the namespace shadowed by an import keep their FQCN. + $this->assertSame( + 'public protected(set) ?\App\Model\Entity\User $author;', + $this->builder->buildEntityPropertyDeclarations( + ['author' => $this->propertySchema()['author']], + ['User' => 'App\Other\User'], + $namespace, + )['author'], + ); + } + + /** + * Tests the buildEntityPropertyImports method + * + * @return void + */ + public function testBuildEntityPropertyImports(): void + { + $schema = $this->propertySchema(); + + // Classes part of the namespace don't need an import. + $this->assertSame( + [ + 'Cake\I18n\Date', + 'App\Other\Entity\Editor', + ], + $this->builder->buildEntityPropertyImports($schema, 'App\Model\Entity'), + ); + + $this->assertSame( + [ + 'Cake\I18n\Date', + 'App\Model\Entity\User', + 'App\Other\Entity\Editor', + ], + $this->builder->buildEntityPropertyImports($schema), + ); + } + + /** + * Tests the columnTypeToHintType method + * + * @return void + */ + public function testColumnTypeToHintType(): void + { + $map = [ + 'string' => [ + 'char', + 'string', + 'text', + 'uuid', + 'decimal', + ], + 'int' => [ + 'integer', + 'biginteger', + 'smallinteger', + 'tinyinteger', + ], + 'float' => [ + 'float', + ], + 'bool' => [ + 'boolean', + ], + 'array' => [ + 'array', + 'json', + ], + 'string|resource' => [ + 'binary', + ], + '\Cake\I18n\Date' => [ + 'date', + ], + '\Cake\I18n\DateTime' => [ + 'datetime', + 'datetimefractional', + 'timestamp', + 'timestampfractional', + 'timestamptimezone', + ], + '\Cake\I18n\Time' => [ + 'time', + ], + ]; + + foreach ($map as $return => $colTypes) { + foreach ($colTypes as $colType) { + $this->assertSame($return, $this->builder->columnTypeToHintType($colType)); + } + } + } + + /** + * Tests the associatedEntityTypeToHintType method + * + * @return void + */ + public function testAssociatedEntityTypeToHintType(): void + { + $sourceTable = new Table(['alias' => 'Source']); + + $this->assertSame( + 'array', + $this->builder->associatedEntityTypeToHintType('Foo', new HasMany('Foo', $sourceTable)), + ); + $this->assertSame( + 'Pong', + $this->builder->associatedEntityTypeToHintType('Pong', new BelongsTo('Pong', $sourceTable)), + ); + } + + /** + * Property schema used for testing the concrete property generation. + * + * @return array> + */ + protected function propertySchema(): array + { + $sourceTable = new Table(['alias' => 'Source']); + + return [ + 'id' => [ + 'kind' => 'column', + 'type' => 'integer', + 'null' => false, + ], + 'title' => [ + 'kind' => 'column', + 'type' => 'string', + 'null' => true, + ], + 'data' => [ + 'kind' => 'column', + 'type' => 'array', + 'null' => true, + ], + 'file' => [ + 'kind' => 'column', + 'type' => 'binary', + 'null' => true, + ], + 'published' => [ + 'kind' => 'column', + 'type' => 'date', + 'null' => false, + ], + // Fields used by Cake\ORM\Entity itself have to stay dynamic fields. + 'hidden' => [ + 'kind' => 'column', + 'type' => 'array', + 'null' => false, + ], + 'patchable' => [ + 'kind' => 'column', + 'type' => 'array', + 'null' => false, + ], + 'author' => [ + 'kind' => 'association', + 'type' => '\App\Model\Entity\User', + 'association' => new BelongsTo('Author', $sourceTable), + ], + 'revisions' => [ + 'kind' => 'association', + 'type' => '\App\Model\Entity\Revision', + 'association' => new HasMany('Revisions', $sourceTable), + ], + 'editor' => [ + 'kind' => 'association', + 'type' => '\App\Other\Entity\Editor', + 'association' => new BelongsTo('Editor', $sourceTable), + ], + ]; + } +} diff --git a/tests/TestCase/Command/ModelCommandTest.php b/tests/TestCase/Command/ModelCommandTest.php index 13b506c33..ac33c71a4 100644 --- a/tests/TestCase/Command/ModelCommandTest.php +++ b/tests/TestCase/Command/ModelCommandTest.php @@ -2106,6 +2106,7 @@ public function testBakeEntitySimple(): void $this->assertExitCode(CommandInterface::CODE_SUCCESS); $this->assertFileExists($this->generatedFile); + $this->assertPhpLints($this->generatedFile); $result = file_get_contents($this->generatedFile); $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } @@ -2180,52 +2181,18 @@ public function testBakeEntityWithPropertyTypeHints(): void $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } - /** - * test baking an entity class with concrete properties for columns and associations. - * - * @return void - */ - public function testBakeEntityConcreteProperties(): void - { - $this->generatedFile = APP . 'Model/Entity/TodoItem.php'; - $this->exec('bake model --no-test --no-fixture --no-table --concrete-properties todo_items'); - - $this->assertExitCode(CommandInterface::CODE_SUCCESS); - $this->assertFileExists($this->generatedFile); - $this->assertPhpLints($this->generatedFile); - $result = file_get_contents($this->generatedFile); - $this->assertSameAsFile(__FUNCTION__ . '.php', $result); - } - - /** - * test baking an entity class with concrete properties and no accessible fields. - * - * @return void - */ - public function testBakeEntityConcretePropertiesNoFields(): void - { - $this->generatedFile = APP . 'Model/Entity/User.php'; - $this->exec('bake model --no-test --no-fixture --no-table --no-fields --no-hidden --concrete-properties users'); - - $this->assertExitCode(CommandInterface::CODE_SUCCESS); - $this->assertFileExists($this->generatedFile); - $this->assertPhpLints($this->generatedFile); - $result = file_get_contents($this->generatedFile); - $this->assertSameAsFile(__FUNCTION__ . '.php', $result); - } - /** * test baking an entity with concrete properties over an existing entity does not duplicate them. * * @return void */ - public function testBakeEntityConcretePropertiesUpdate(): void + public function testBakeEntityUpdateConcreteProperties(): void { $this->generatedFile = APP . 'Model/Entity/TodoItem.php'; - $expected = file_get_contents($this->compareBasePath . 'testBakeEntityConcreteProperties.php'); + $expected = file_get_contents($this->compareBasePath . 'testBakeEntityFieldsDefaults.php'); file_put_contents($this->generatedFile, str_replace("\r\n", "\n", $expected)); - $this->exec('bake model --no-test --no-fixture --no-table --concrete-properties --update todo_items'); + $this->exec('bake model --no-test --no-fixture --no-table --update todo_items'); $this->assertExitCode(CommandInterface::CODE_SUCCESS); $this->assertFileExists($this->generatedFile); @@ -2233,18 +2200,17 @@ public function testBakeEntityConcretePropertiesUpdate(): void } /** - * test baking an entity class with an enum column and concrete properties. + * test baking an entity class with dynamic fields instead of concrete properties. * * @return void */ - public function testBakeEntityConcretePropertiesEnum(): void + public function testBakeEntityDynamicProperties(): void { - $this->generatedFile = APP . 'Model/Entity/Article.php'; - $this->exec('bake model --no-test --no-fixture --no-table --no-fields --concrete-properties Articles'); + $this->generatedFile = APP . 'Model/Entity/User.php'; + $this->exec('bake model --no-test --no-fixture --no-table --no-fields --no-hidden --no-concrete-properties users'); $this->assertExitCode(CommandInterface::CODE_SUCCESS); $this->assertFileExists($this->generatedFile); - $this->assertPhpLints($this->generatedFile); $result = file_get_contents($this->generatedFile); $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } @@ -2447,6 +2413,7 @@ public function testBakeEntityEnum(): void $this->assertExitCode(CommandInterface::CODE_SUCCESS); $this->assertFileExists($this->generatedFile); + $this->assertPhpLints($this->generatedFile); $result = file_get_contents($this->generatedFile); $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } diff --git a/tests/TestCase/View/Helper/DocBlockHelperTest.php b/tests/TestCase/View/Helper/DocBlockHelperTest.php index 81e9c04f3..d32a8f8d4 100644 --- a/tests/TestCase/View/Helper/DocBlockHelperTest.php +++ b/tests/TestCase/View/Helper/DocBlockHelperTest.php @@ -228,146 +228,6 @@ public function testBuildEntityAssociationHintTypeMap(): void $this->markTestIncomplete('Not implemented yet'); } - /** - * Tests the buildEntityPropertyDeclarations method - * - * @return void - */ - public function testBuildEntityPropertyDeclarations(): void - { - $namespace = 'App\Model\Entity'; - $imports = [ - 'Date' => 'Cake\I18n\Date', - ]; - - $expected = [ - 'id' => 'public protected(set) int $id;', - 'title' => 'public protected(set) ?string $title;', - 'data' => 'public protected(set) ?array $data;', - // `string|resource` cannot be expressed as a PHP type. - 'file' => 'public protected(set) mixed $file;', - 'published' => 'public protected(set) Date $published;', - // Classes part of the namespace are referenced by their short name. - 'author' => 'public protected(set) ?User $author;', - // An array of entities cannot be expressed as a PHP type. - 'revisions' => 'public protected(set) ?array $revisions;', - // Classes outside the namespace without an import keep their FQCN. - 'editor' => 'public protected(set) ?\App\Other\Entity\Editor $editor;', - ]; - $this->assertSame( - $expected, - $this->DocBlockHelper->buildEntityPropertyDeclarations($this->propertySchema(), $imports, $namespace), - ); - - // Without namespace or imports fully qualified class names are used. - $this->assertSame( - 'public protected(set) ?\App\Model\Entity\User $author;', - $this->DocBlockHelper->buildEntityPropertyDeclarations(['author' => $this->propertySchema()['author']])['author'], - ); - - // Classes part of the namespace shadowed by an import keep their FQCN. - $this->assertSame( - 'public protected(set) ?\App\Model\Entity\User $author;', - $this->DocBlockHelper->buildEntityPropertyDeclarations( - ['author' => $this->propertySchema()['author']], - ['User' => 'App\Other\User'], - $namespace, - )['author'], - ); - } - - /** - * Tests the buildEntityPropertyImports method - * - * @return void - */ - public function testBuildEntityPropertyImports(): void - { - $schema = $this->propertySchema(); - - // Classes part of the namespace don't need an import. - $this->assertSame( - [ - 'Cake\I18n\Date', - 'App\Other\Entity\Editor', - ], - $this->DocBlockHelper->buildEntityPropertyImports($schema, 'App\Model\Entity'), - ); - - $this->assertSame( - [ - 'Cake\I18n\Date', - 'App\Model\Entity\User', - 'App\Other\Entity\Editor', - ], - $this->DocBlockHelper->buildEntityPropertyImports($schema), - ); - } - - /** - * Property schema used for testing the concrete property generation. - * - * @return array> - */ - protected function propertySchema(): array - { - $sourceTable = new Table(['alias' => 'Source']); - - return [ - 'id' => [ - 'kind' => 'column', - 'type' => 'integer', - 'null' => false, - ], - 'title' => [ - 'kind' => 'column', - 'type' => 'string', - 'null' => true, - ], - 'data' => [ - 'kind' => 'column', - 'type' => 'array', - 'null' => true, - ], - 'file' => [ - 'kind' => 'column', - 'type' => 'binary', - 'null' => true, - ], - 'published' => [ - 'kind' => 'column', - 'type' => 'date', - 'null' => false, - ], - // Fields used by Cake\ORM\Entity itself have to stay dynamic fields. - 'hidden' => [ - 'kind' => 'column', - 'type' => 'array', - 'null' => false, - ], - 'patchable' => [ - 'kind' => 'column', - 'type' => 'array', - 'null' => false, - ], - 'author' => [ - 'kind' => 'association', - 'type' => '\App\Model\Entity\User', - 'association' => new BelongsTo('Author', $sourceTable), - ], - 'revisions' => [ - 'kind' => 'association', - 'type' => '\App\Model\Entity\Revision', - 'association' => new HasMany('Revisions', $sourceTable), - ], - 'editor' => [ - 'kind' => 'association', - 'type' => '\App\Other\Entity\Editor', - 'association' => new BelongsTo('Editor', $sourceTable), - ], - ]; - } - /** * Tests the columnTypeToHintType method * diff --git a/tests/comparisons/Model/testBakeEntityConcreteProperties.php b/tests/comparisons/Model/testBakeEntityConcreteProperties.php deleted file mode 100644 index 87b37dd2d..000000000 --- a/tests/comparisons/Model/testBakeEntityConcreteProperties.php +++ /dev/null @@ -1,65 +0,0 @@ - $todo_tasks - * @property array<\Bake\Test\App\Model\Entity\TodoLabel> $todo_labels - */ -class TodoItem extends Entity -{ - /** - * Fields that can be mass assigned using newEntity() or patchEntity(). - * - * Note that when '*' is set to true, this allows all unspecified fields to - * be mass assigned. For security purposes, it is advised to set '*' to false - * (or remove it), and explicitly make individual fields accessible as needed. - * - * @var array - */ - protected array $patchable = [ - 'user_id' => true, - 'title' => true, - 'body' => true, - 'effort' => true, - 'completed' => true, - 'todo_task_count' => true, - 'created' => true, - 'updated' => true, - 'user' => true, - 'todo_reminder' => true, - 'todo_tasks' => true, - 'todo_labels' => true, - ]; - - public protected(set) int $id; - public protected(set) int $user_id; - public protected(set) string $title; - public protected(set) ?string $body; - public protected(set) string $effort; - public protected(set) bool $completed; - public protected(set) int $todo_task_count; - public protected(set) ?DateTime $created; - public protected(set) ?DateTime $updated; - public protected(set) ?User $user; - public protected(set) ?TodoReminder $todo_reminder; - public protected(set) ?array $todo_tasks; - public protected(set) ?array $todo_labels; -} diff --git a/tests/comparisons/Model/testBakeEntityConcretePropertiesEnum.php b/tests/comparisons/Model/testBakeEntityConcretePropertiesEnum.php deleted file mode 100644 index b40bbe62e..000000000 --- a/tests/comparisons/Model/testBakeEntityConcretePropertiesEnum.php +++ /dev/null @@ -1,31 +0,0 @@ - $tags - * @property array<\Bake\Test\App\Model\Entity\ArticlesTag> $articles_tags - */ -class Article extends Entity -{ - public protected(set) int $id; - public protected(set) ?int $author_id; - public protected(set) ?string $title; - public protected(set) ?string $body; - public protected(set) ?ArticleStatus $published; - public protected(set) ?Author $author; - public protected(set) ?array $tags; - public protected(set) ?array $articles_tags; -} diff --git a/tests/comparisons/Model/testBakeEntityCustomHidden.php b/tests/comparisons/Model/testBakeEntityCustomHidden.php index f4a99d243..21170a022 100644 --- a/tests/comparisons/Model/testBakeEntityCustomHidden.php +++ b/tests/comparisons/Model/testBakeEntityCustomHidden.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -28,4 +29,13 @@ class User extends Entity 'foo', 'bar', ]; + + public protected(set) int $id; + public protected(set) ?string $username; + public protected(set) ?string $password; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?array $comments; + public protected(set) ?array $relations; + public protected(set) ?array $todo_items; } diff --git a/tests/comparisons/Model/testBakeEntityConcretePropertiesNoFields.php b/tests/comparisons/Model/testBakeEntityDynamicProperties.php similarity index 58% rename from tests/comparisons/Model/testBakeEntityConcretePropertiesNoFields.php rename to tests/comparisons/Model/testBakeEntityDynamicProperties.php index bae1f2a50..819835c67 100644 --- a/tests/comparisons/Model/testBakeEntityConcretePropertiesNoFields.php +++ b/tests/comparisons/Model/testBakeEntityDynamicProperties.php @@ -3,7 +3,6 @@ namespace Bake\Test\App\Model\Entity; -use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -20,12 +19,4 @@ */ class User extends Entity { - public protected(set) int $id; - public protected(set) ?string $username; - public protected(set) ?string $password; - public protected(set) ?DateTime $created; - public protected(set) ?DateTime $updated; - public protected(set) ?array $comments; - public protected(set) ?array $relations; - public protected(set) ?array $todo_items; } diff --git a/tests/comparisons/Model/testBakeEntityEnum.php b/tests/comparisons/Model/testBakeEntityEnum.php index f3bb6c1f2..b40bbe62e 100644 --- a/tests/comparisons/Model/testBakeEntityEnum.php +++ b/tests/comparisons/Model/testBakeEntityEnum.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Bake\Test\App\Model\Enum\ArticleStatus; use Cake\ORM\Entity; /** @@ -19,4 +20,12 @@ */ class Article extends Entity { + public protected(set) int $id; + public protected(set) ?int $author_id; + public protected(set) ?string $title; + public protected(set) ?string $body; + public protected(set) ?ArticleStatus $published; + public protected(set) ?Author $author; + public protected(set) ?array $tags; + public protected(set) ?array $articles_tags; } diff --git a/tests/comparisons/Model/testBakeEntityFieldsDefaults.php b/tests/comparisons/Model/testBakeEntityFieldsDefaults.php index 3a94cf999..87b37dd2d 100644 --- a/tests/comparisons/Model/testBakeEntityFieldsDefaults.php +++ b/tests/comparisons/Model/testBakeEntityFieldsDefaults.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -47,4 +48,18 @@ class TodoItem extends Entity 'todo_tasks' => true, 'todo_labels' => true, ]; + + public protected(set) int $id; + public protected(set) int $user_id; + public protected(set) string $title; + public protected(set) ?string $body; + public protected(set) string $effort; + public protected(set) bool $completed; + public protected(set) int $todo_task_count; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?User $user; + public protected(set) ?TodoReminder $todo_reminder; + public protected(set) ?array $todo_tasks; + public protected(set) ?array $todo_labels; } diff --git a/tests/comparisons/Model/testBakeEntityFieldsWhiteList.php b/tests/comparisons/Model/testBakeEntityFieldsWhiteList.php index c45d5965e..d1102e7f7 100644 --- a/tests/comparisons/Model/testBakeEntityFieldsWhiteList.php +++ b/tests/comparisons/Model/testBakeEntityFieldsWhiteList.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -39,4 +40,18 @@ class TodoItem extends Entity 'body' => true, 'completed' => true, ]; + + public protected(set) int $id; + public protected(set) int $user_id; + public protected(set) string $title; + public protected(set) ?string $body; + public protected(set) string $effort; + public protected(set) bool $completed; + public protected(set) int $todo_task_count; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?User $user; + public protected(set) ?TodoReminder $todo_reminder; + public protected(set) ?array $todo_tasks; + public protected(set) ?array $todo_labels; } diff --git a/tests/comparisons/Model/testBakeEntityFullContext.php b/tests/comparisons/Model/testBakeEntityFullContext.php index 5f2ba7555..b43a48680 100644 --- a/tests/comparisons/Model/testBakeEntityFullContext.php +++ b/tests/comparisons/Model/testBakeEntityFullContext.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -46,4 +47,13 @@ class User extends Entity protected array $hidden = [ 'password', ]; + + public protected(set) int $id; + public protected(set) ?string $username; + public protected(set) ?string $password; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?array $comments; + public protected(set) ?array $relations; + public protected(set) ?array $todo_items; } diff --git a/tests/comparisons/Model/testBakeEntityHidden.php b/tests/comparisons/Model/testBakeEntityHidden.php index 114a63e80..f26618b4f 100644 --- a/tests/comparisons/Model/testBakeEntityHidden.php +++ b/tests/comparisons/Model/testBakeEntityHidden.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -27,4 +28,13 @@ class User extends Entity protected array $hidden = [ 'password', ]; + + public protected(set) int $id; + public protected(set) ?string $username; + public protected(set) ?string $password; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?array $comments; + public protected(set) ?array $relations; + public protected(set) ?array $todo_items; } diff --git a/tests/comparisons/Model/testBakeEntityNoFields.php b/tests/comparisons/Model/testBakeEntityNoFields.php index 77309b638..18237039f 100644 --- a/tests/comparisons/Model/testBakeEntityNoFields.php +++ b/tests/comparisons/Model/testBakeEntityNoFields.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -24,4 +25,17 @@ */ class TodoItem extends Entity { + public protected(set) int $id; + public protected(set) int $user_id; + public protected(set) string $title; + public protected(set) ?string $body; + public protected(set) string $effort; + public protected(set) bool $completed; + public protected(set) int $todo_task_count; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?User $user; + public protected(set) ?TodoReminder $todo_reminder; + public protected(set) ?array $todo_tasks; + public protected(set) ?array $todo_labels; } diff --git a/tests/comparisons/Model/testBakeEntitySimple.php b/tests/comparisons/Model/testBakeEntitySimple.php index 819835c67..bae1f2a50 100644 --- a/tests/comparisons/Model/testBakeEntitySimple.php +++ b/tests/comparisons/Model/testBakeEntitySimple.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -19,4 +20,12 @@ */ class User extends Entity { + public protected(set) int $id; + public protected(set) ?string $username; + public protected(set) ?string $password; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?array $comments; + public protected(set) ?array $relations; + public protected(set) ?array $todo_items; } diff --git a/tests/comparisons/Model/testBakeEntitySimpleUnchanged.php b/tests/comparisons/Model/testBakeEntitySimpleUnchanged.php index 819835c67..bae1f2a50 100644 --- a/tests/comparisons/Model/testBakeEntitySimpleUnchanged.php +++ b/tests/comparisons/Model/testBakeEntitySimpleUnchanged.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -19,4 +20,12 @@ */ class User extends Entity { + public protected(set) int $id; + public protected(set) ?string $username; + public protected(set) ?string $password; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?array $comments; + public protected(set) ?array $relations; + public protected(set) ?array $todo_items; } diff --git a/tests/comparisons/Model/testBakeEntityWithPlugin.php b/tests/comparisons/Model/testBakeEntityWithPlugin.php index 103588f6a..285efbaf3 100644 --- a/tests/comparisons/Model/testBakeEntityWithPlugin.php +++ b/tests/comparisons/Model/testBakeEntityWithPlugin.php @@ -3,6 +3,7 @@ namespace BakeTest\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -46,4 +47,13 @@ class User extends Entity protected array $hidden = [ 'password', ]; + + public protected(set) int $id; + public protected(set) ?string $username; + public protected(set) ?string $password; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?array $comments; + public protected(set) ?array $relations; + public protected(set) ?array $todo_items; } diff --git a/tests/comparisons/Model/testBakeEntityWithPropertyTypeHints.php b/tests/comparisons/Model/testBakeEntityWithPropertyTypeHints.php index 64d2a0b37..630122dc3 100644 --- a/tests/comparisons/Model/testBakeEntityWithPropertyTypeHints.php +++ b/tests/comparisons/Model/testBakeEntityWithPropertyTypeHints.php @@ -3,6 +3,7 @@ namespace Bake\Test\App\Model\Entity; +use Cake\I18n\DateTime; use Cake\ORM\Entity; /** @@ -27,4 +28,20 @@ */ class TodoItem extends Entity { + public protected(set) int $id; + public protected(set) int $user_id; + public protected(set) string $title; + public protected(set) ?string $body; + public protected(set) string $effort; + public protected(set) bool $completed; + public protected(set) int $todo_task_count; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) array $array_type; + public protected(set) array $json_type; + public protected(set) string $unknown_type; + public protected(set) ?User $user; + public protected(set) ?TodoReminder $todo_reminder; + public protected(set) ?array $todo_tasks; + public protected(set) ?array $todo_labels; } diff --git a/tests/comparisons/Model/testBakeUpdateEntity.php b/tests/comparisons/Model/testBakeUpdateEntity.php index 555ef70bd..21f38a113 100644 --- a/tests/comparisons/Model/testBakeUpdateEntity.php +++ b/tests/comparisons/Model/testBakeUpdateEntity.php @@ -4,6 +4,7 @@ namespace Bake\Test\App\Model\Entity; use Authorization\IdentityInterface; +use Cake\I18n\DateTime; use Cake\ORM\Entity; use MyApp\Test; @@ -40,6 +41,20 @@ class TodoItem extends Entity implements IdentityInterface 'user_id', ]; + public protected(set) int $id; + public protected(set) int $user_id; + public protected(set) string $title; + public protected(set) ?string $body; + public protected(set) string $effort; + public protected(set) bool $completed; + public protected(set) int $todo_task_count; + public protected(set) ?DateTime $created; + public protected(set) ?DateTime $updated; + public protected(set) ?User $user; + public protected(set) ?TodoReminder $todo_reminder; + public protected(set) ?array $todo_tasks; + public protected(set) ?array $todo_labels; + protected array $patchable = [ // should not get overwritten ]; From 5d60dd1366a721bb345cb5b01aeb046c7d534c9e Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 11 Oct 2026 22:11:20 +0530 Subject: [PATCH 4/4] Update php version for parser --- src/CodeGen/CodeParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeGen/CodeParser.php b/src/CodeGen/CodeParser.php index 63536c249..55a029d92 100644 --- a/src/CodeGen/CodeParser.php +++ b/src/CodeGen/CodeParser.php @@ -60,7 +60,7 @@ class CodeParser extends NodeVisitorAbstract */ public function __construct() { - $version = PhpVersion::fromComponents(8, 4); + $version = PhpVersion::fromComponents(8, 5); $this->parser = new ParserFactory()->createForVersion($version); $this->traverser = new NodeTraverser(); $this->traverser->addVisitor($this);