|
3 | 3 |
|
4 | 4 | namespace Bake\View\Helper; |
5 | 5 |
|
| 6 | +use Bake\CodeGen\ImportHelper; |
6 | 7 | use Cake\Collection\Collection; |
7 | 8 | use Cake\Core\App; |
8 | 9 | use Cake\Database\Type\EnumType; |
9 | 10 | use Cake\Database\TypeFactory; |
10 | 11 | use Cake\ORM\Association; |
| 12 | +use Cake\ORM\Entity; |
11 | 13 | use Cake\Utility\Inflector; |
12 | 14 | use Cake\View\Helper; |
| 15 | +use ReflectionProperty; |
13 | 16 |
|
14 | 17 | /** |
15 | 18 | * DocBlock helper |
@@ -165,6 +168,171 @@ public function buildEntityAssociationHintTypeMap(array $propertySchema): array |
165 | 168 | return $properties; |
166 | 169 | } |
167 | 170 |
|
| 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 | + |
168 | 336 | /** |
169 | 337 | * Converts a column type to its DocBlock type counterpart. |
170 | 338 | * |
|
0 commit comments