Skip to content

Commit f2a7bb8

Browse files
committed
Add option to generate entities with concrete properties
1 parent 0cc3474 commit f2a7bb8

12 files changed

Lines changed: 651 additions & 2 deletions

File tree

‎docs/en/usage.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,57 @@ For non-conventional relations, you can use references in constraints or foreign
5757
->addForeignKey('shipping_country_id', 'countries', 'cid')
5858
```
5959
60+
### Concrete Entity Properties
61+
62+
By default entity fields are stored as dynamic fields. Pass `--concrete-properties` to have Bake
63+
declare real PHP properties on the baked entity class, following
64+
[Declaring Concrete Properties](https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties)
65+
in the CakePHP book:
66+
67+
```bash
68+
bin/cake bake model Articles --concrete-properties
69+
```
70+
71+
The generated properties use `public protected(set)` visibility, so they can be read directly
72+
while writes still go through the entity's `set()` API. Class types are referenced by their
73+
short name instead of a fully qualified name:
74+
75+
```php
76+
use App\Model\Enum\Status;
77+
use Cake\I18n\DateTime;
78+
use Cake\ORM\Entity;
79+
80+
class Article extends Entity
81+
{
82+
public protected(set) int $id;
83+
public protected(set) ?string $title;
84+
public protected(set) ?DateTime $created;
85+
public protected(set) ?Status $status;
86+
public protected(set) bool $published;
87+
public protected(set) ?User $author;
88+
public protected(set) ?array $comments;
89+
}
90+
```
91+
92+
Classes outside the entity's namespace, like `Cake\I18n\DateTime` or the `Status` enum, are
93+
imported, with imports kept alphabetically ordered. Classes in the same namespace, like the
94+
`User` association (`App\Model\Entity\User`), don't need an import and are referenced by their
95+
short name directly.
96+
97+
Note that:
98+
99+
- Fields used by `Cake\ORM\Entity` itself, such as `hidden`, `patchable`, `dirty` and `errors`,
100+
are skipped so those remain dynamic fields.
101+
- No property is initialized, not even the nullable ones. Fields which have not been hydrated,
102+
like an association which has not been loaded, are uninitialized, so reading them directly
103+
raises an `Error` about accessing an uninitialized property. The entity API handles this
104+
safely: `$article->get('author')` and `hasValue('author')` return `null` and `false`
105+
respectively for such fields.
106+
- The `@property` annotations in the class docblock are still generated as they can express
107+
types like an array of entities that PHP property types cannot.
108+
- Re-baking an existing entity with the option and `--update` does not duplicate the
109+
declarations.
110+
60111
## Bake Enums
61112
62113
You can use Bake to generate [backed enums](https://www.php.net/manual/en/language.enumerations.backed.php) for use in your models.

‎src/CodeGen/CodeParser.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class CodeParser extends NodeVisitorAbstract
6060
*/
6161
public function __construct()
6262
{
63-
$version = PhpVersion::fromComponents(8, 1);
63+
$version = PhpVersion::fromComponents(8, 4);
6464
$this->parser = new ParserFactory()->createForVersion($version);
6565
$this->traverser = new NodeTraverser();
6666
$this->traverser->addVisitor($this);

‎src/Command/ModelCommand.php‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ public function getTableContext(
168168
$connection = $this->connection;
169169
$hidden = $this->getHiddenFields($tableObject);
170170
$enumSchema = $this->getEnumDefinitions($tableObject->getSchema());
171+
$concreteProperties = (bool)$this->args->getOption('concrete-properties');
171172

172173
return compact(
173174
'associations',
@@ -183,6 +184,7 @@ public function getTableContext(
183184
'connection',
184185
'hidden',
185186
'enumSchema',
187+
'concreteProperties',
186188
);
187189
}
188190

@@ -1366,6 +1368,10 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption
13661368
])->addOption('no-hidden', [
13671369
'boolean' => true,
13681370
'help' => 'Disable generating hidden fields in the entity.',
1371+
])->addOption('concrete-properties', [
1372+
'boolean' => true,
1373+
'help' => 'Declare concrete class properties on the entity for its columns and associated entities. '
1374+
. 'Note that writes to these properties must go through set() as they use `protected(set)` visibility.',
13691375
])->addOption('hidden', [
13701376
'help' => 'A comma separated list of fields to hide.',
13711377
])->addOption('primary-key', [

‎src/View/Helper/DocBlockHelper.php‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33

44
namespace Bake\View\Helper;
55

6+
use Bake\CodeGen\ImportHelper;
67
use Cake\Collection\Collection;
78
use Cake\Core\App;
89
use Cake\Database\Type\EnumType;
910
use Cake\Database\TypeFactory;
1011
use Cake\ORM\Association;
12+
use Cake\ORM\Entity;
1113
use Cake\Utility\Inflector;
1214
use Cake\View\Helper;
15+
use ReflectionProperty;
1316

1417
/**
1518
* DocBlock helper
@@ -165,6 +168,171 @@ public function buildEntityAssociationHintTypeMap(array $propertySchema): array
165168
return $properties;
166169
}
167170

171+
/**
172+
* Builds a map of concrete PHP property declarations for an entity class.
173+
*
174+
* Declarations use `public protected(set)` visibility as recommended by
175+
* the CakePHP 6 documentation on declaring concrete properties. No
176+
* property is initialized, so fields which have not been hydrated, like
177+
* an association which has not been loaded, are uninitialized.
178+
*
179+
* Class types are resolved to their imported name when the class is part
180+
* of `$classImports`, to their short name when the class is part of
181+
* `$namespace`, and to their fully qualified class name otherwise.
182+
*
183+
* Property names used by `Cake\ORM\Entity` itself are skipped as those
184+
* fields have to remain dynamic fields.
185+
*
186+
* @see https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties
187+
* @param array<string, array<string, mixed>> $propertySchema The property schema to use for generating the declarations.
188+
* @param array<string, string> $classImports Class imports as [alias => class name] used for resolving type names.
189+
* @param string $namespace The namespace of the file the properties are generated for.
190+
* @return array<string, string> Map of property name to property declaration.
191+
*/
192+
public function buildEntityPropertyDeclarations(
193+
array $propertySchema,
194+
array $classImports = [],
195+
string $namespace = '',
196+
): array {
197+
$imports = ImportHelper::normalize($classImports);
198+
199+
$declarations = [];
200+
foreach ($this->entityPropertyTypes($propertySchema) as $property => $info) {
201+
$type = $info['type'];
202+
if ($info['class'] !== null) {
203+
$type = $this->resolvePropertyType($info['class'], $type, $imports, $namespace);
204+
}
205+
206+
$declarations[$property] = "public protected(set) {$type} \${$property};";
207+
}
208+
209+
return $declarations;
210+
}
211+
212+
/**
213+
* Builds the list of classes used by the concrete property declarations
214+
* of an entity class so they can be added to the file's imports.
215+
*
216+
* Classes that are part of `$namespace` are not included as they can be
217+
* referenced by their short name without an import.
218+
*
219+
* @see https://book.cakephp.org/6.x/orm/entities.html#declaring-concrete-properties
220+
* @param array<string, array<string, mixed>> $propertySchema The property schema to use for generating the imports.
221+
* @param string $namespace The namespace of the file the properties are generated for.
222+
* @return array<int, string> List of fully qualified class names without a leading backslash.
223+
*/
224+
public function buildEntityPropertyImports(array $propertySchema, string $namespace = ''): array
225+
{
226+
$imports = [];
227+
foreach ($this->entityPropertyTypes($propertySchema) as $info) {
228+
if ($info['class'] === null) {
229+
continue;
230+
}
231+
232+
$class = ltrim($info['class'], '\\');
233+
if ($namespace !== '' && str_starts_with($class, $namespace . '\\')) {
234+
continue;
235+
}
236+
237+
$imports[] = $class;
238+
}
239+
240+
return array_values(array_unique($imports));
241+
}
242+
243+
/**
244+
* Resolves a class based property type to the shortest correct name.
245+
*
246+
* Imported classes are referenced by their alias, classes part of the
247+
* file's namespace by their short name when no import shadows it, and
248+
* all other classes by their fully qualified class name.
249+
*
250+
* @param string $class The fully qualified class name with a leading backslash.
251+
* @param string $type The property type containing the class name.
252+
* @param array<string, string> $imports Class imports as [alias => class name].
253+
* @param string $namespace The namespace of the file the type is used in.
254+
* @return string The resolved property type.
255+
*/
256+
protected function resolvePropertyType(string $class, string $type, array $imports, string $namespace): string
257+
{
258+
$alias = array_search(ltrim($class, '\\'), $imports, true);
259+
if (is_string($alias)) {
260+
return str_replace($class, $alias, $type);
261+
}
262+
263+
$shortName = substr($class, strrpos($class, '\\') + 1);
264+
$isSameNamespace = $namespace !== '' && str_starts_with(ltrim($class, '\\'), $namespace . '\\');
265+
if ($isSameNamespace && !isset($imports[$shortName])) {
266+
return str_replace($class, $shortName, $type);
267+
}
268+
269+
return $type;
270+
}
271+
272+
/**
273+
* Builds the PHP type information used for an entity's concrete properties.
274+
*
275+
* @param array<string, array<string, mixed>> $propertySchema The property schema to use for generating the type information.
276+
* @return array<string, array{type: string, class: string|null}> Map of property name to type information.
277+
*/
278+
protected function entityPropertyTypes(array $propertySchema): array
279+
{
280+
$reserved = $this->reservedEntityPropertyNames();
281+
282+
$types = [];
283+
foreach ($propertySchema as $property => $info) {
284+
if (isset($reserved[$property])) {
285+
continue;
286+
}
287+
288+
if ($info['kind'] === 'column') {
289+
$type = $this->columnTypeToHintType($info['type']) ?? 'string';
290+
if (str_contains($type, '|')) {
291+
// Union types like `string|resource` have no matching PHP type.
292+
$types[$property] = ['type' => 'mixed', 'class' => null];
293+
294+
continue;
295+
}
296+
297+
// Class types like `\Cake\I18n\DateTime` start with a backslash.
298+
$types[$property] = [
299+
'type' => (!empty($info['null']) ? '?' : '') . $type,
300+
'class' => str_starts_with($type, '\\') ? $type : null,
301+
];
302+
303+
continue;
304+
}
305+
306+
$type = $this->associatedEntityTypeToHintType($info['type'], $info['association']);
307+
if (str_ends_with($type, '[]') || str_starts_with($type, 'array<')) {
308+
// PHP types cannot express an array of entities,
309+
// the `@property` annotation keeps that hint.
310+
$types[$property] = ['type' => '?array', 'class' => null];
311+
312+
continue;
313+
}
314+
315+
// Association types are nullable as loading an association
316+
// which has no result sets the field to `null`.
317+
$types[$property] = ['type' => '?' . $type, 'class' => $type];
318+
}
319+
320+
return $types;
321+
}
322+
323+
/**
324+
* Gets the property names that cannot be declared on entity classes as
325+
* they are used by `Cake\ORM\Entity` itself.
326+
*
327+
* @return array<string, true> The reserved property names.
328+
*/
329+
protected function reservedEntityPropertyNames(): array
330+
{
331+
$properties = (new ReflectionProperty(Entity::class, 'restrictedProperties'))->getValue();
332+
333+
return is_array($properties) ? $properties : [];
334+
}
335+
168336
/**
169337
* Converts a column type to its DocBlock type counterpart.
170338
*

‎templates/bake/Model/entity.twig‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
#}
1616
{% set propertyHintMap = DocBlock.buildEntityPropertyHintTypeMap(propertySchema ?: []) %}
1717
{% set associationHintMap = DocBlock.buildEntityAssociationHintTypeMap(propertySchema ?: []) %}
18+
{% set fileNamespace = fileBuilder.namespace %}
19+
{% set propertyImports = concreteProperties|default(false) ? DocBlock.buildEntityPropertyImports(propertySchema ?: [], fileNamespace) : [] %}
20+
{% set classImports = fileBuilder.classImports(['Cake\\ORM\\Entity']|merge(propertyImports)) %}
21+
{% set concreteDeclarations = concreteProperties|default(false) ? DocBlock.buildEntityPropertyDeclarations(propertySchema ?: [], classImports, fileNamespace) : {} %}
1822
{% set annotations = DocBlock.propertyHints(propertyHintMap) %}
1923

2024
{%- if associationHintMap %}
@@ -26,7 +30,7 @@
2630
{%- set generatedProperties = [] %}
2731
{{ element('Bake.file_header', {
2832
namespace: fileBuilder.namespace,
29-
classImports: fileBuilder.classImports(['Cake\\ORM\\Entity']),
33+
classImports: classImports,
3034
}) }}
3135

3236
{{ DocBlock.classDescription(name, 'Entity', annotations)|raw }}
@@ -62,6 +66,15 @@ class {{ name }} extends Entity{{ fileBuilder.classBuilder.implements ? ' implem
6266
*/
6367
protected array $hidden = {{ Bake.exportVar(hidden, 1)|raw }};
6468
{% endif %}
69+
{% if concreteDeclarations and (accessible or hidden) %}
70+
71+
{% endif %}
72+
{% if concreteDeclarations %}
73+
{%~ set generatedProperties = generatedProperties|merge(concreteDeclarations|keys) %}
74+
{%~ for declaration in concreteDeclarations %}
75+
{{ declaration }}
76+
{%~ endfor %}
77+
{% endif %}
6578
{% set userProperties = fileBuilder.classBuilder.userProperties(generatedProperties) %}
6679
{% if userProperties %}
6780

‎tests/TestCase/CodeGen/CodeParserTest.php‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,55 @@ class TestTable extends \Cake\ORM\Table implements IdentityInterface, SomeOther\
146146
);
147147
}
148148

149+
/**
150+
* Test that PHP 8.4 syntax like `protected(set)` properties and
151+
* property hooks can be parsed and round tripped.
152+
*
153+
* @return void
154+
*/
155+
public function testParseConcreteProperties(): void
156+
{
157+
$parser = new CodeParser();
158+
$file = $parser->parseFile(<<<'PARSE'
159+
<?php
160+
161+
namespace Test;
162+
163+
use Cake\ORM\Entity;
164+
165+
class Article extends Entity
166+
{
167+
public protected(set) int $id;
168+
169+
public protected(set) ?string $title = null;
170+
171+
public protected(set) ?string $password {
172+
set (?string $value) {
173+
$this->password = $value === null ? null : password_hash($value, PASSWORD_DEFAULT);
174+
}
175+
}
176+
}
177+
PARSE,);
178+
179+
$this->assertSame(
180+
[
181+
'id',
182+
'title',
183+
'password',
184+
],
185+
array_keys($file->class->properties),
186+
);
187+
188+
$code = <<<'PARSE'
189+
public protected(set) ?string $password {
190+
set (?string $value) {
191+
$this->password = $value === null ? null : password_hash($value, PASSWORD_DEFAULT);
192+
}
193+
}
194+
PARSE;
195+
$this->assertSame($code, $file->class->properties['password']);
196+
}
197+
149198
public function testUseStatements(): void
150199
{
151200
$parser = new CodeParser();

0 commit comments

Comments
 (0)