Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
51 changes: 51 additions & 0 deletions docs/en/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/CodeGen/CodeParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/Command/ModelCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -183,6 +184,7 @@ public function getTableContext(
'connection',
'hidden',
'enumSchema',
'concreteProperties',
);
}

Expand Down Expand Up @@ -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', [
Expand Down
168 changes: 168 additions & 0 deletions src/View/Helper/DocBlockHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, array<string, mixed>> $propertySchema The property schema to use for generating the declarations.
* @param array<string, string> $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<string, string> 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<string, array<string, mixed>> $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<int, string> 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<string, string> $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<string, array<string, mixed>> $propertySchema The property schema to use for generating the type information.
* @return array<string, array{type: string, class: string|null}> 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<string, true> 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.
*
Expand Down
15 changes: 14 additions & 1 deletion templates/bake/Model/entity.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand All @@ -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 }}
Expand Down Expand Up @@ -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 %}

Expand Down
49 changes: 49 additions & 0 deletions tests/TestCase/CodeGen/CodeParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
<?php

namespace Test;

use Cake\ORM\Entity;

class Article extends Entity
{
public protected(set) int $id;

public protected(set) ?string $title = null;

public protected(set) ?string $password {
set (?string $value) {
$this->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();
Expand Down
Loading
Loading