From 2e2c171d1e2e4adaf000953c6d173e01e9316635 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 26 Sep 2026 19:29:30 +0530 Subject: [PATCH 1/3] 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 cbb0a3e7..880b8196 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 071f0f9d..63536c24 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 4a363a24..cbbaca7e 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 b83eae27..6a3e76d8 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 8b3944f0..f4cf7865 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 e1567130..30ec530f 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 14eb58b8..13b506c3 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 d680bab5..446fbdde 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 d32a8f8d..81e9c04f 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 00000000..87b37dd2 --- /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 00000000..b40bbe62 --- /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 00000000..bae1f2a5 --- /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 741b2c066ac932013ca9e9d26489287775b68b80 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 27 Sep 2026 13:42:22 +0530 Subject: [PATCH 2/3] Update phpunit constraints. PHP 8.3 support is not required as CakePHP 6 is 8.4+ --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2cde5550..05c25b4c 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "require-dev": { "cakephp/cakephp-codesniffer": "^6.0", "cakephp/debug_kit": "^6.0", - "phpunit/phpunit": "^12.2.4 || ^13.0" + "phpunit/phpunit": "^13.0" }, "autoload": { "psr-4": { From 71d7628fceb7efc38ed1019548d2e9ec70f57e31 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sun, 27 Sep 2026 16:35:31 +0530 Subject: [PATCH 3/3] 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 6a3e76d8..535b8c23 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 : []; }