From 03bb38ad1d8bd4e2cf34076ac4d26138901995bf Mon Sep 17 00:00:00 2001 From: Gerrit-Jan Schutten Date: Wed, 27 May 2026 10:19:52 +0200 Subject: [PATCH] HNB-3155: added support for php attributes --- .github/workflows/main.yaml | 2 +- README.md | 164 +++++------ composer.json | 2 +- src/Annotation/Enumerator.php | 58 +--- src/Annotation/Generate.php | 244 +---------------- .../AnnotationProcessorInterface.php | 35 --- .../PropertyInformationInterface.php | 254 ------------------ src/Attribute/Enumerator.php | 72 +++++ src/Attribute/Generate.php | 227 ++++++++++++++++ src/Collection/ConstCollectionInterface.php | 2 - src/Generator/CodeGenerator.php | 25 +- src/Generator/CodeGeneratorInterface.php | 6 +- src/Generator/UniqueImports.php | 10 + src/Plugin.php | 4 +- .../AccessorGenerationProcessor.php} | 26 +- .../DoctrineMappingProcessor.php} | 36 +-- .../EnumItemInformation.php | 4 +- .../InvalidColumnSettingsException.php | 2 +- .../PropertyInformation.php | 160 +++++++---- .../PropertyProcessorInterface.php | 29 ++ src/Reflection/AttributeInstantiator.php | 69 +++++ src/Reflection/ReflectionClass.php | 120 ++++++++- src/Reflection/ReflectionProperty.php | 23 +- src/Reflection/TokenStream.php | 8 +- test/Annotation/EnumeratorTest.php | 6 +- test/Annotation/GenerateTest.php | 35 +-- test/Generator/CodeGeneratorTest.php | 7 +- test/Generator/UniqueImportsTest.php | 89 +++--- test/Generator/fixtures/MixedAnnotations.php | 36 +++ test/Generator/fixtures/NativeAttributes.php | 21 ++ .../expected/MixedAnnotationsMethodsTrait.php | 153 +++++++++++ .../expected/NativeAttributesMethodsTrait.php | 128 +++++++++ .../AccessorGenerationProcessorTest.php} | 136 ++++------ .../DoctrineMappingProcessorTest.php} | 51 ++-- .../EnumItemInformationTest.php | 18 +- .../PropertyInformationTest.php | 16 +- .../fixtures/doc_block.txt | 0 test/Reflection/AttributeInstantiatorTest.php | 72 +++++ test/Reflection/TokenStreamTest.php | 4 +- test/Twig/TestEnvironment.php | 20 +- 40 files changed, 1393 insertions(+), 981 deletions(-) delete mode 100644 src/AnnotationProcessor/AnnotationProcessorInterface.php delete mode 100644 src/AnnotationProcessor/PropertyInformationInterface.php create mode 100644 src/Attribute/Enumerator.php create mode 100644 src/Attribute/Generate.php rename src/{AnnotationProcessor/GenerateAnnotationProcessor.php => PropertyProcessor/AccessorGenerationProcessor.php} (70%) rename src/{AnnotationProcessor/DoctrineAnnotationProcessor.php => PropertyProcessor/DoctrineMappingProcessor.php} (88%) rename src/{AnnotationProcessor => PropertyProcessor}/EnumItemInformation.php (91%) rename src/{AnnotationProcessor => PropertyProcessor}/Exception/InvalidColumnSettingsException.php (64%) rename src/{AnnotationProcessor => PropertyProcessor}/PropertyInformation.php (82%) create mode 100644 src/PropertyProcessor/PropertyProcessorInterface.php create mode 100644 src/Reflection/AttributeInstantiator.php create mode 100644 test/Generator/fixtures/MixedAnnotations.php create mode 100644 test/Generator/fixtures/NativeAttributes.php create mode 100644 test/Generator/fixtures/expected/MixedAnnotationsMethodsTrait.php create mode 100644 test/Generator/fixtures/expected/NativeAttributesMethodsTrait.php rename test/{AnnotationProcessor/GenerateAnnotationProcessorTest.php => PropertyProcessor/AccessorGenerationProcessorTest.php} (53%) rename test/{AnnotationProcessor/DoctrineAnnotationProcessorTest.php => PropertyProcessor/DoctrineMappingProcessorTest.php} (84%) rename test/{AnnotationProcessor => PropertyProcessor}/EnumItemInformationTest.php (79%) rename test/{AnnotationProcessor => PropertyProcessor}/PropertyInformationTest.php (96%) rename test/{AnnotationProcessor => PropertyProcessor}/fixtures/doc_block.txt (100%) create mode 100644 test/Reflection/AttributeInstantiatorTest.php diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ff96fc8..b7896e1 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - php-versions: ['8.1', '8.2', '8.3'] + php-versions: ['8.3', '8.4'] name: PHP ${{ matrix.php-versions }} steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index 93a3cd5..66548c5 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ Welcome to the Accessor Generator composer plugin. ## Goals The goal of this plugin is to provide dynamically generated get, set, add, remove -accessor methods for Classes based on information that we can read from the doc comment. -Currently we can process Doctrine ORM annotations. +accessor methods for Classes based on information that we can read from PHP 8 native +attributes or from doc comments. +Currently we can process Doctrine ORM annotations and their native attribute equivalents. Since the code is automatically generated you do not have to (unit) test it and it will be very consistent with a lot of added boilerplate code that will make your code @@ -28,6 +29,29 @@ Add `-vv` to the dump-autoload command for more verbosity. ") - */ + #[AG\Generate(encryption_alias: '')] private $my_value; - + public function __construct(string $my_value) { $this->my_value = $my_value; // No encryption is taking place. @@ -178,8 +207,7 @@ Example composer.json config: ## Parameters using ENUM classes -Since version 2.8.0, the support of accessor generation of parameterized collections has been added. With this addition, -the requirement of PHP 7.1 has been added due to the need of `ReflectionConstant`, which was added in PHP 7.1. +Since version 2.8.0, the support of accessor generation of parameterized collections has been added. Imagine having an entity that holds an `ArrayCollection` to another entity that holds parameters. For example: ```php @@ -199,17 +227,15 @@ Version 2.8.0 introduces the ability to generate accessors for enum classes. ### Requirements -The owning entity - `Task` in the example above - must implement a property that is of type `ArrayCollection` which \ +The owning entity - `Task` in the example above - must implement a property that is of type `ArrayCollection` which defines a `OneToMany` relationship with a `Parameter`-entity. ```php class Task { // ... - - /** - * @ORM\OneToMany(targetEntity="Parameter", cascade={"persist"}) - */ + + #[ORM\OneToMany(targetEntity: Parameter::class, cascade: ['persist'])] private $parameters; // ... @@ -222,22 +248,16 @@ use Hostnet\Component\AccessorGenerator\Enum\EnumeratorCompatibleEntityInterface class Parameter implements EnumeratorCompatibleEntityInterface { - /** - * @ORM\ManyToOne(targetEntity="Task") - */ + #[ORM\ManyToOne(targetEntity: Task::class)] private $owner; - - /** - * @ORM\Column(type="string") - */ + + #[ORM\Column(type: 'string')] private $name; - - /** - * @ORM\Column(type="string") - * @AG\Generate() - */ + + #[ORM\Column(type: 'string')] + #[AG\Generate] private $value; - + // This signature is a requirement for enum accessor generation. public function __construct($task, string $name, ?string $value) { @@ -272,7 +292,7 @@ class MyTaskParamNames * Represents the client if the task is currently runnnig for. */ public const I_CLIENT_ID = 'CLIENT_ID'; - + /** * An awesome URL. */ @@ -282,40 +302,34 @@ class MyTaskParamNames Now that we have our three classes (`Task`, `Parameter` and `MyTaskParamNames`), we can start generating code. -### The "Enumerator" annotation +### The Enumerator attribute -With version 2.8.0 comes the `Enumerator` annotation which can be used inside the existing `Generate` annotation. +With version 2.8.0 comes the `Enumerator` attribute which can be used inside the existing `Generate` attribute. > **Upgrading from 2.8.0 to 2.8.1:** > The "name" setting in the annotation has been changed to "property" to be more consistent. Since 2.8.1, the ability > to add inline enumerators through other class properties has been added. See below for more information. Taking the code that we just wrote in the examples above, we can generate an accessor method for `MyTaskParamNames` -by modifying the annotation of the `parameters` property of our `Task` class. +by modifying the attribute of the `parameters` property of our `Task` class. ```php class Task { use Generated\TaskMethodsTrait; - /** - * @ORM\OneToMany(targetEntity="Parameter", cascade={"persist"}) - * @AG\Generate(enumerators={ - * @AG\Enumerator("MyTaskParamNames", property="my_params") - * }) - */ - private $property; - - /** - * @var Generated\MyTaskParamNamesEnum - */ + #[ORM\OneToMany(targetEntity: Parameter::class, cascade: ['persist'])] + #[AG\Generate(enumerators: [new AG\Enumerator('MyTaskParamNames', property: 'my_params')])] + private $parameters; + + /** @var Generated\MyTaskParamNamesEnum */ private $my_params; } ``` Once the code is generated, you will now have a newly generated class called `MyTaskParamNamesEnum` in the `Generated` directory (and namespace) relative to the namespace of `MyTaskParamNames`. An accessor for this class is -generated using the `property` settting in the `TaskMethodsTrait`. +generated using the `property` setting in the `TaskMethodsTrait`. The accessor for this enum based on the code above will be called `getMyParams()`. You can give this any name you want as long as it is suitable for a method name. @@ -357,29 +371,23 @@ see an example of the generated code. > specify them explicitly. ### Multiple enumerators -As you might have noticed, the `enumerators` property of the `Generate` annotation accepts a list -of one or more `Enumerator` annotations. You can specify one ore more enum classes that utilize +As you might have noticed, the `enumerators` parameter of the `Generate` attribute accepts a list +of one or more `Enumerator` instances. You can specify one or more enum classes that utilize the same collection for their "storage". -If your annotation looks like this: ```php -/** - * @AG\Generate(enumerators={ - * @AG\Enumerator("MyTaskParamNames", property="my_params"), - * @AG\Enumerator("BetterParamNames", property="better_params") - * }); - */ - private $parameters; - - /** - * @var Generated\MyTaskParamNamesEnum - */ - private $my_params; - - /** - * @var Generated\BetterParamNamesEnum - */ - private $better_params; +#[ORM\OneToMany(targetEntity: Parameter::class, cascade: ['persist'])] +#[AG\Generate(enumerators: [ + new AG\Enumerator('MyTaskParamNames', property: 'my_params'), + new AG\Enumerator('BetterParamNames', property: 'better_params'), +])] +private $parameters; + +/** @var Generated\MyTaskParamNamesEnum */ +private $my_params; + +/** @var Generated\BetterParamNamesEnum */ +private $better_params; ``` The generator will now create two accessors for these parameter enumerators that you can use like @@ -390,26 +398,24 @@ $task->getBetterParams()->setFoobar(1234); // From BetterParamNames ``` ### Separated enumerator accessor generation -You can also define enumerators outside the `@Generate` annotation. If used in combination with the `entity-plugin-lib`, +You can also define enumerators outside the `Generate` attribute. If used in combination with the `entity-plugin-lib`, it is possible to define a `trait` that holds an enumerator property that refers to a collection on your entity. Lets say we want to add an extra enumerator to our - already existing - Task entity that we have written before. ```php -use Hostnet\Component\AccessorGenerator\Annotation as AG; +use Hostnet\Component\AccessorGenerator\Attribute as AG; trait TaskTrait { use Generated\TaskTraitMethodsTrait; - /** - * @AG\Enumerator("\My\Namespace\MyExtraParamName", name="parameters") - * @var \My\Namespace\Generated\MyExtraParamNameEnum - */ + #[AG\Enumerator('\My\Namespace\MyExtraParamName', name: 'parameters')] + /** @var \My\Namespace\Generated\MyExtraParamNameEnum */ private $some_extra_params; } ``` -The `name` setting refers to the ArrayCollection property that holds all parameters owned by that entity. +The `name` parameter refers to the ArrayCollection property that holds all parameters owned by that entity. Once the code is generated, you can now invoke the enumerator like any other: ```php diff --git a/composer.json b/composer.json index 32dd04b..1bf8d08 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "license": "MIT", "minimum-stability": "stable", "require": { - "php": "^8.1", + "php": "^8.3", "composer-plugin-api": "^2.0", "ext-bcmath": "*", "ext-json": "*", diff --git a/src/Annotation/Enumerator.php b/src/Annotation/Enumerator.php index e2c583d..d4e973f 100644 --- a/src/Annotation/Enumerator.php +++ b/src/Annotation/Enumerator.php @@ -6,57 +6,17 @@ namespace Hostnet\Component\AccessorGenerator\Annotation; +use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor; +use Hostnet\Component\AccessorGenerator\Attribute\Enumerator as AttributeEnumerator; + /** * @Annotation(target={"ANNOTATION", "PROPERTY"}) + * @NamedArgumentConstructor + * + * @deprecated Use the native PHP attribute #[AG\Enumerator] instead. + * This class will be removed when docblock annotation support is dropped. */ -class Enumerator +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] +class Enumerator extends AttributeEnumerator { - /** - * References the Enum class for the parameter collection. - * - * @var string - */ - public $value; - - /** - * References the name of the property that holds the parameter collection. - * - * @var string - */ - public $name; - - /** - * References the property to assign the enum accessor to. - * - * @var string - */ - public $property; - - /** - * Specifies the parameter entity that is used to instantiate new parameter instances. - * This information is only required if the Enumerator annotation is used outside the Generator annotation. - * - * @var string - */ - public $type; - - public function getName(): ?string - { - return $this->name; - } - - public function getEnumeratorClass(): ?string - { - return $this->value; - } - - public function getPropertyName(): ?string - { - return $this->property; - } - - public function getType(): ?string - { - return $this->type; - } } diff --git a/src/Annotation/Generate.php b/src/Annotation/Generate.php index 2a1561c..0e78af6 100644 --- a/src/Annotation/Generate.php +++ b/src/Annotation/Generate.php @@ -6,247 +6,19 @@ namespace Hostnet\Component\AccessorGenerator\Annotation; -use Doctrine\Common\Annotations\Annotation\Enum; +use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor; +use Hostnet\Component\AccessorGenerator\Attribute\Generate as AttributeGenerate; /** - * Annotation to activate accessor method generation for a property. You can - * disable generation of certain methods by setting them to false in your - * notation. - * - * The annotation is designed to be used with doctrine/annotations. - * * @Annotation * @Target("PROPERTY") + * @NamedArgumentConstructor * @see http://doctrine-common.readthedocs.org/en/latest/reference/annotations.html + * + * @deprecated Use the native PHP attribute #[AG\Generate] instead. + * This class will be removed when docblock annotation support is dropped. */ -class Generate +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class Generate extends AttributeGenerate { - /** - * No method should be generated. - */ - public const VISIBILITY_NONE = 'none'; - - /** - * A public method should be generated. - */ - public const VISIBILITY_PUBLIC = 'public'; - - /** - * A protected method should be generated. - */ - public const VISIBILITY_PROTECTED = 'protected'; - - /** - * A private method should be generated. - */ - public const VISIBILITY_PRIVATE = 'private'; - - /** - * Will generate a getter of the given visibility. - * - * Default: public. - * - * @Enum({"public", "protected", "private", "none"}) - * - * @var string - */ - public $get; - - /** - * Will generate relevant methods to fully modify the property. - * - * Normally this will result in setXxx, though in case of a OneToMany or - * ManyToMany it will generate an addXxx and removeXxx. The latter can also - * be individually controlled by setting the add / remove properties. - * - * Default: public. - * - * @Enum({"public", "protected", "private", "none"}) - * - * @var string - */ - public $set; - - /** - * Will generate an adder in the case of a OneToMany or ManyToMany - * relation. Might already be disabled with the set property. - * - * Default: public. - * - * @Enum({"public", "protected", "private", "none"}) - * - * @var string - */ - public $add; - - /** - * Will generate a remover in the case of a OneToMany or ManyToMany - * relation. Might already be disabled with the set property. - * - * Default: public. - * - * @Enum({"public", "protected", "private", "none"}) - * - * @var string - */ - public $remove; - - /** - * Will generate a isXxx for a boolean property. Might already be disabled - * with the get property. - * - * Default: public. - * - * @Enum({"public", "protected", "private", "none"}) - * - * @var string - */ - public $is = self::VISIBILITY_PUBLIC; - - /** - * List of enum classes to generate accessor classes for. - * - * @var \Hostnet\Component\AccessorGenerator\Annotation\Enumerator[] - */ - public $enumerators = []; - - /** - * Determine the type hint to use for the setter/adder/remover, and the - * return type of the getter. - * - * Insert the fully qualified class name here. - * - * @var string - */ - public $type; - - /** - * By default a lot of validation is added into the methods. This is - * awesome. - * - * - The setters will ensure your object is never in an invalid state. - * - The getters will assume your object is in a valid state, and throw - * exceptions otherwise. - * - The constructor is up to you, though. - * - * Examples: - * - A setter for a limited length varchar column validates that you don't - * insert a string that is too long. - * - A getter for non-nullable column will validate that the current value - * is not null. - * - * Set this property to false if you want to disable it. Only do this - * though, if you're ok with an *invalid* state of your object. - * - * @var bool - */ - public $strict = true; - - /** - * Determine if the property should be stored encrypted. - * - * Insert the unique name that's used to map the key files to the property. - * - * @var string - */ - public $encryption_alias; - - public function getGet(): ?string - { - return $this->get; - } - - public function getSet(): ?string - { - return $this->set; - } - - public function getAdd(): ?string - { - return $this->add; - } - - public function getRemove(): ?string - { - return $this->remove; - } - - public function getIs(): ?string - { - return $this->is; - } - - public function getType(): ?string - { - return $this->type; - } - - public function isStrict(): bool - { - return $this->strict; - } - - public function getEncryptionAlias(): ?string - { - return $this->encryption_alias; - } - - /** - * @return Enumerator[] - */ - public function getEnumerators(): array - { - return $this->enumerators; - } - - /** - * Resolves the most limited visibility for method generation. - * - * If A defines public and B defines private, the returned visibility - * modifier will be private. Precedence is as following: - * - none - * - private - * - protected - * - public - * - * @param array ...$requirements - */ - public static function getMostLimitedVisibility(...$requirements): string - { - foreach ([self::VISIBILITY_NONE, self::VISIBILITY_PRIVATE, self::VISIBILITY_PROTECTED] as $search_string) { - foreach ($requirements as $requirement) { - if ($requirement === $search_string) { - return $search_string; - } - } - } - - return self::VISIBILITY_PUBLIC; - } - - /** - * Sets the given visibility to all accessors if they are not explicitly defined. - * - * @param string $visibility - */ - public function setDefaultVisibility(string $visibility): void - { - if (null === $this->get) { - $this->get = $visibility; - } - - if (null === $this->set) { - $this->set = $visibility; - } - - if (null === $this->add) { - $this->add = $visibility; - } - - if (null !== $this->remove) { - return; - } - - $this->remove = $visibility; - } } diff --git a/src/AnnotationProcessor/AnnotationProcessorInterface.php b/src/AnnotationProcessor/AnnotationProcessorInterface.php deleted file mode 100644 index 02a7300..0000000 --- a/src/AnnotationProcessor/AnnotationProcessorInterface.php +++ /dev/null @@ -1,35 +0,0 @@ -name = $name; + } + + public function setProperty(?string $property): void + { + $this->property = $property; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getEnumeratorClass(): ?string + { + return $this->value; + } + + public function getPropertyName(): ?string + { + return $this->property; + } + + public function getType(): ?string + { + return $this->type; + } +} diff --git a/src/Attribute/Generate.php b/src/Attribute/Generate.php new file mode 100644 index 0000000..d41cae1 --- /dev/null +++ b/src/Attribute/Generate.php @@ -0,0 +1,227 @@ +get; + } + + public function getSet(): ?string + { + return $this->set; + } + + public function getAdd(): ?string + { + return $this->add; + } + + public function getRemove(): ?string + { + return $this->remove; + } + + public function getIs(): ?string + { + return $this->is; + } + + public function getType(): ?string + { + return $this->type; + } + + public function isStrict(): bool + { + return $this->strict; + } + + public function getEncryptionAlias(): ?string + { + return $this->encryption_alias; + } + + /** + * @return Enumerator[] + */ + public function getEnumerators(): array + { + return $this->enumerators; + } + + /** + * Resolves the most limited visibility for method generation. + * + * If A defines public and B defines private, the returned visibility + * modifier will be private. Precedence is as following: + * - none + * - private + * - protected + * - public + * + * @param array ...$requirements + */ + public static function getMostLimitedVisibility(...$requirements): string + { + foreach ([self::VISIBILITY_NONE, self::VISIBILITY_PRIVATE, self::VISIBILITY_PROTECTED] as $search_string) { + foreach ($requirements as $requirement) { + if ($requirement === $search_string) { + return $search_string; + } + } + } + + return self::VISIBILITY_PUBLIC; + } + + /** + * Sets the given visibility to all accessors if they are not explicitly defined. + * + * @param string $visibility + */ + public function setDefaultVisibility(string $visibility): void + { + if (null === $this->get) { + $this->get = $visibility; + } + + if (null === $this->set) { + $this->set = $visibility; + } + + if (null === $this->add) { + $this->add = $visibility; + } + + if (null !== $this->remove) { + return; + } + + $this->remove = $visibility; + } +} diff --git a/src/Collection/ConstCollectionInterface.php b/src/Collection/ConstCollectionInterface.php index 2387468..554867c 100644 --- a/src/Collection/ConstCollectionInterface.php +++ b/src/Collection/ConstCollectionInterface.php @@ -73,8 +73,6 @@ public function getValues(): array; /** * Returns a native PHP array representation of the collection. The array * is a copy, as is the case for all arrays in PHP. - * - * @return array */ public function toArray(): array; diff --git a/src/Generator/CodeGenerator.php b/src/Generator/CodeGenerator.php index 669153d..f114968 100644 --- a/src/Generator/CodeGenerator.php +++ b/src/Generator/CodeGenerator.php @@ -7,16 +7,15 @@ namespace Hostnet\Component\AccessorGenerator\Generator; use Doctrine\Inflector\InflectorFactory; -use Hostnet\Component\AccessorGenerator\Annotation\Enumerator; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\DoctrineAnnotationProcessor; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\EnumItemInformation; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\GenerateAnnotationProcessor; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\PropertyInformation; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\PropertyInformationInterface; +use Hostnet\Component\AccessorGenerator\Attribute\Enumerator; use Hostnet\Component\AccessorGenerator\Collection\ImmutableCollection; use Hostnet\Component\AccessorGenerator\Enum\EnumeratorCompatibleEntityInterface; use Hostnet\Component\AccessorGenerator\Generator\Exception\ReferencedClassNotFoundException; use Hostnet\Component\AccessorGenerator\Generator\Exception\TypeUnknownException; +use Hostnet\Component\AccessorGenerator\PropertyProcessor\AccessorGenerationProcessor; +use Hostnet\Component\AccessorGenerator\PropertyProcessor\DoctrineMappingProcessor; +use Hostnet\Component\AccessorGenerator\PropertyProcessor\EnumItemInformation; +use Hostnet\Component\AccessorGenerator\PropertyProcessor\PropertyInformation; use Hostnet\Component\AccessorGenerator\Reflection\Exception\ClassDefinitionNotFoundException; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionClass; use Hostnet\Component\AccessorGenerator\Twig\CodeGenerationExtension; @@ -258,17 +257,17 @@ private function getMetadataForClass(ReflectionClass $class): array $imports[] = $class->getNamespace() . '\\' . $class->getName(); - $generate_processor = new GenerateAnnotationProcessor(); - $doctrine_processor = new DoctrineAnnotationProcessor(); + $generate_processor = new AccessorGenerationProcessor(); + $doctrine_processor = new DoctrineMappingProcessor(); $this->metadata_cache[$cache_key]['imports'] = $imports; $this->metadata_cache[$cache_key]['properties'] = []; foreach ($properties as $property) { $info = new PropertyInformation($property); - $info->registerAnnotationProcessor($generate_processor); - $info->registerAnnotationProcessor($doctrine_processor); - $info->processAnnotations(); + $info->registerProcessor($generate_processor); + $info->registerProcessor($doctrine_processor); + $info->process(); $this->metadata_cache[$cache_key]['properties'][$info->getName()] = $info; } @@ -302,7 +301,7 @@ private function linkEnumeratorsToAssociatedCollections(array $metadata): void $info->getClass() )); } - $enumerator->name = $info->getName(); + $enumerator->setName($info->getName()); } $collection = $metadata['properties'][$enumerator->getName()]; @@ -527,7 +526,7 @@ private static function fqcn($name, array $imports): string return ''; } - public function generateAccessors(PropertyInformationInterface $info): string + public function generateAccessors(PropertyInformation $info): string { $code = ''; diff --git a/src/Generator/CodeGeneratorInterface.php b/src/Generator/CodeGeneratorInterface.php index 1d6b947..76b8b50 100644 --- a/src/Generator/CodeGeneratorInterface.php +++ b/src/Generator/CodeGeneratorInterface.php @@ -6,7 +6,7 @@ namespace Hostnet\Component\AccessorGenerator\Generator; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\PropertyInformationInterface; +use Hostnet\Component\AccessorGenerator\PropertyProcessor\PropertyInformation; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionClass; /** @@ -50,9 +50,9 @@ public function generateTraitForClass(ReflectionClass $class): string; * {$info}. The output will consist of generated code for the accessors * separated with line-breaks. * - * @param PropertyInformationInterface $info + * @param PropertyInformation $info */ - public function generateAccessors(PropertyInformationInterface $info): string; + public function generateAccessors(PropertyInformation $info): string; /** * Expects an array of aliases, each alias can contain a public key file and/or a private key file. diff --git a/src/Generator/UniqueImports.php b/src/Generator/UniqueImports.php index f51cf2f..5e222c5 100644 --- a/src/Generator/UniqueImports.php +++ b/src/Generator/UniqueImports.php @@ -23,6 +23,16 @@ final class UniqueImports */ public static function filter(array $imports): array { + // Drop non-compound class names (e.g. `use DateTime;`): they live in + // the global namespace and the statement generates a PHP warning. + // Function/const imports (prefixed "function "/"const ") are kept even + // when non-compound, as `use function sprintf;` is intentional. + $imports = array_filter($imports, static function (string $fqn): bool { + return str_starts_with($fqn, 'function ') + || str_starts_with($fqn, 'const ') + || str_contains($fqn, '\\'); + }); + uksort($imports, function ($a, $b) use ($imports) { $alias_a = is_numeric($a) ? " as $a;" : ''; $alias_b = is_numeric($b) ? " as $b;" : ''; diff --git a/src/Plugin.php b/src/Plugin.php index bd0a955..f0b2e32 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -15,7 +15,7 @@ use Composer\Script\ScriptEvents; use Doctrine\Common\Annotations\AnnotationRegistry; // phpcs:ignore SlevomatCodingStandard.Namespaces.UnusedUses.UnusedUse -use Hostnet\Component\AccessorGenerator\Annotation\Generate; +use Hostnet\Component\AccessorGenerator\Attribute\Generate; use Hostnet\Component\AccessorGenerator\Generator\CodeGenerator; use Hostnet\Component\AccessorGenerator\Generator\CodeGeneratorInterface; use Hostnet\Component\AccessorGenerator\Generator\Exception\ReferencedClassNotFoundException; @@ -40,7 +40,7 @@ */ class Plugin implements PluginInterface, EventSubscriberInterface { - public const NAME = 'hostnet/accessor-generator-plugin-lib'; + public const string NAME = 'hostnet/accessor-generator-plugin-lib'; /** * @var Composer diff --git a/src/AnnotationProcessor/GenerateAnnotationProcessor.php b/src/PropertyProcessor/AccessorGenerationProcessor.php similarity index 70% rename from src/AnnotationProcessor/GenerateAnnotationProcessor.php rename to src/PropertyProcessor/AccessorGenerationProcessor.php index badb074..aaf080f 100644 --- a/src/AnnotationProcessor/GenerateAnnotationProcessor.php +++ b/src/PropertyProcessor/AccessorGenerationProcessor.php @@ -4,30 +4,34 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor; -use Hostnet\Component\AccessorGenerator\Annotation\Enumerator; -use Hostnet\Component\AccessorGenerator\Annotation\Generate; +use Hostnet\Component\AccessorGenerator\Attribute\Enumerator; +use Hostnet\Component\AccessorGenerator\Attribute\Generate; /** - * Processes the @Generate annotation and determines which methods should be - * generated by the code generator. Store everything in a PropertyInformation - * object. + * Processes Generate and Enumerator annotations/attributes and determines which + * accessor methods should be generated. Stores the result in a PropertyInformation object. + * + * This processor is path-agnostic: it receives already-instantiated objects regardless of + * whether they originated from a docblock annotation (parsed by Doctrine's DocParser) or a + * native PHP 8 attribute (evaluated by AttributeInstantiator). Both paths produce the same + * Generate/Enumerator instances, so no branching on the source is needed here. */ -class GenerateAnnotationProcessor implements AnnotationProcessorInterface +class AccessorGenerationProcessor implements PropertyProcessorInterface { /** - * @see AnnotationProcessorInterface::processAnnotation() + * @see PropertyProcessorInterface::apply() * * @param object $annotation * @param PropertyInformation $info */ - public function processAnnotation($annotation, PropertyInformation $info): void + public function apply($annotation, PropertyInformation $info): void { // Standalone Enumerator annotation. if ($annotation instanceof Enumerator) { $info->addEnumeratorToGenerate($annotation); - $annotation->property = $info->getName(); + $annotation->setProperty($info->getName()); if (! $info->getType() && $annotation->getType()) { $info->setType($annotation->getType()); } @@ -78,7 +82,7 @@ public function processAnnotation($annotation, PropertyInformation $info): void $info->setIsGenerator(true); } - public function getProcessableAnnotationNamespace(): string + public function getProcessableNamespace(): string { return 'Hostnet\Component\AccessorGenerator\Annotation'; } diff --git a/src/AnnotationProcessor/DoctrineAnnotationProcessor.php b/src/PropertyProcessor/DoctrineMappingProcessor.php similarity index 88% rename from src/AnnotationProcessor/DoctrineAnnotationProcessor.php rename to src/PropertyProcessor/DoctrineMappingProcessor.php index 46d9a47..28cfc58 100644 --- a/src/AnnotationProcessor/DoctrineAnnotationProcessor.php +++ b/src/PropertyProcessor/DoctrineMappingProcessor.php @@ -4,7 +4,7 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Column; @@ -14,24 +14,26 @@ use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToOne; -use Hostnet\Component\AccessorGenerator\Annotation\Generate; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\Exception\InvalidColumnSettingsException; +use Hostnet\Component\AccessorGenerator\Attribute\Generate; +use Hostnet\Component\AccessorGenerator\PropertyProcessor\Exception\InvalidColumnSettingsException; /** - * Process Column, ManyToMany, OneToOne, ManyToOne, OneToMany and - * GeneratedValue Doctrine ORM annotations and extract the type and - * relationship information. + * Extracts type and relationship metadata from Doctrine ORM mapping objects — + * Column, JoinColumn, GeneratedValue, OneToMany, ManyToMany, OneToOne, ManyToOne. + * + * Works for both docblock annotations (@ORM\Column) and native attributes (#[ORM\Column]) + * because Doctrine's mapping classes are dual-registered and produce the same object either way. */ -class DoctrineAnnotationProcessor implements AnnotationProcessorInterface +class DoctrineMappingProcessor implements PropertyProcessorInterface { - private const ZEROED_DATE_TIME = 'zeroeddatetime'; - private const ZEROED_DATE = 'zeroeddate'; - private const YAML_ARRAY = 'yaml_array'; + private const string ZEROED_DATE_TIME = 'zeroeddatetime'; + private const string ZEROED_DATE = 'zeroeddate'; + private const string YAML_ARRAY = 'yaml_array'; /** * @deprecated since doctrine/dbal:2.6 */ - private const JSON_ARRAY = 'json_array'; - private const NULLABLE_TYPES = [self::ZEROED_DATE, self::ZEROED_DATE_TIME]; + private const string JSON_ARRAY = 'json_array'; + private const array NULLABLE_TYPES = [self::ZEROED_DATE, self::ZEROED_DATE_TIME]; /** * Process annotations of type: @@ -45,15 +47,15 @@ class DoctrineAnnotationProcessor implements AnnotationProcessorInterface * @throws \OutOfBoundsException * @throws \Hostnet\Component\AccessorGenerator\Reflection\Exception\ClassDefinitionNotFoundException * @throws \RangeException - * @throws \Hostnet\Component\AccessorGenerator\AnnotationProcessor\Exception\InvalidColumnSettingsException + * @throws \Hostnet\Component\AccessorGenerator\PropertyProcessor\Exception\InvalidColumnSettingsException * @throws \InvalidArgumentException * @throws \DomainException * - * @param mixed $annotation object of a class annotated with @annotation + * @param mixed $annotation instantiated annotation or attribute object * @param PropertyInformation $information */ - public function processAnnotation($annotation, PropertyInformation $information): void + public function apply($annotation, PropertyInformation $information): void { // Process scalar value (db-wise) columns. if ($annotation instanceof Column) { @@ -96,7 +98,7 @@ public function processAnnotation($annotation, PropertyInformation $information) // Do nothing for other types } - public function getProcessableAnnotationNamespace(): string + public function getProcessableNamespace(): string { return 'Doctrine\ORM\Mapping'; } @@ -107,7 +109,7 @@ public function getProcessableAnnotationNamespace(): string * @throws \DomainException * @throws \InvalidArgumentException * - * @param mixed $annotation with annotation Annotation + * @param mixed $annotation instantiated annotation or attribute object * @param PropertyInformation $information */ private function processBidirectional($annotation, PropertyInformation $information): void diff --git a/src/AnnotationProcessor/EnumItemInformation.php b/src/PropertyProcessor/EnumItemInformation.php similarity index 91% rename from src/AnnotationProcessor/EnumItemInformation.php rename to src/PropertyProcessor/EnumItemInformation.php index 0c05989..2748663 100644 --- a/src/AnnotationProcessor/EnumItemInformation.php +++ b/src/PropertyProcessor/EnumItemInformation.php @@ -4,13 +4,13 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor; use Doctrine\Inflector\InflectorFactory; class EnumItemInformation { - private const TYPE_MAP = ['S_' => 'string', 'I_' => 'int', 'F_' => 'float', 'A_' => 'array', 'B_' => 'bool']; + private const array TYPE_MAP = ['S_' => 'string', 'I_' => 'int', 'F_' => 'float', 'A_' => 'array', 'B_' => 'bool']; /** * @var string diff --git a/src/AnnotationProcessor/Exception/InvalidColumnSettingsException.php b/src/PropertyProcessor/Exception/InvalidColumnSettingsException.php similarity index 64% rename from src/AnnotationProcessor/Exception/InvalidColumnSettingsException.php rename to src/PropertyProcessor/Exception/InvalidColumnSettingsException.php index dcfab2d..dd263aa 100644 --- a/src/AnnotationProcessor/Exception/InvalidColumnSettingsException.php +++ b/src/PropertyProcessor/Exception/InvalidColumnSettingsException.php @@ -4,7 +4,7 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor\Exception; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor\Exception; class InvalidColumnSettingsException extends \Exception { diff --git a/src/AnnotationProcessor/PropertyInformation.php b/src/PropertyProcessor/PropertyInformation.php similarity index 82% rename from src/AnnotationProcessor/PropertyInformation.php rename to src/PropertyProcessor/PropertyInformation.php index 9a95739..9859fe9 100644 --- a/src/AnnotationProcessor/PropertyInformation.php +++ b/src/PropertyProcessor/PropertyInformation.php @@ -4,21 +4,23 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor; use Doctrine\Common\Annotations\DocParser; use Doctrine\ORM\Mapping\Column; -use Hostnet\Component\AccessorGenerator\Annotation\Enumerator; -use Hostnet\Component\AccessorGenerator\Annotation\Generate; +use Hostnet\Component\AccessorGenerator\Attribute\Enumerator; +use Hostnet\Component\AccessorGenerator\Attribute\Generate; +use Hostnet\Component\AccessorGenerator\Reflection\AttributeInstantiator; +use Hostnet\Component\AccessorGenerator\Reflection\ReflectionClass; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionProperty; /** - * Gather all the information needed for code generation for the accessor - * methods. It is possible to register various annotation processors that will - * process information from the doc blocks and add it to the - * PropertyInformation. + * Aggregates all metadata needed to generate accessor methods for a single property. + * + * Register processors via registerProcessor(), then call process() to run them + * against the property's docblock annotations and native attributes. */ -class PropertyInformation implements PropertyInformationInterface +class PropertyInformation { /** * {@inheritdoc} @@ -190,13 +192,9 @@ class PropertyInformation implements PropertyInformationInterface private $parser; /** - * List of registered annotation processors - * that will be used in the parsing of the - * doc blocks. - * - * @var AnnotationProcessorInterface[] + * @var PropertyProcessorInterface[] */ - private $annotation_processors; + private $processors; /** * Create new PropertyInformation object based @@ -213,46 +211,58 @@ public function __construct(ReflectionProperty $property) } /** - * Register an AnnotationParser that will be called for every - * found annotation and may or may not extract information and - * add it to this object. + * Register a processor that will be called for every annotation or attribute found on this property. * - * After all annotation processors are registered call - * processAnnotations(). + * After all processors are registered, call process(). * - * @param AnnotationProcessorInterface $processor + * @param PropertyProcessorInterface $processor */ - public function registerAnnotationProcessor(AnnotationProcessorInterface $processor): void + public function registerProcessor(PropertyProcessorInterface $processor): void { - $this->annotation_processors[] = $processor; + $this->processors[] = $processor; } /** - * Start the processing of processAnnotations + * Run all registered processors against this property's docblock annotations and native attributes. * * @throws \OutOfBoundsException * @throws \Hostnet\Component\AccessorGenerator\Reflection\Exception\ClassDefinitionNotFoundException * @throws \RuntimeException */ - public function processAnnotations(): void + public function process(): void { $class = $this->property->getClass(); $imports = $class ? array_change_key_case($class->getUseStatements()) : []; $filename = $class ? $class->getFilename() : 'memory'; - // Get all the namespaces in which annotations reside. + $known_imports = $this->filterKnownImports($imports); + + [$doc_encrypted, $doc_string] = $this->processDocblockAnnotations($known_imports, $filename); + [$attr_encrypted, $attr_string] = $this->processNativeAttributes($class); + + $this->validateEncryptionColumnType( + $doc_encrypted || $attr_encrypted, + $doc_string && $attr_string, + ); + } + + /** + * Filters the file's use-statement imports down to namespaces known to registered processors. + * This prevents Doctrine's DocParser from throwing on unrecognised annotations. + * + * @param array $imports + * @return array + */ + private function filterKnownImports(array $imports): array + { $namespaces = []; - foreach ($this->annotation_processors as $processor) { - $namespaces[] = $processor->getProcessableAnnotationNamespace(); + foreach ($this->processors as $processor) { + $namespaces[] = $processor->getProcessableNamespace(); } - // Filter all imports that could lead to non loaded annotations, - // this would let the DocParser explode with an Exception, while - // the goal is to ignore other annotations besides the one explicitly - // loaded. - $without_foreign_annotations = array_filter( + return array_filter( $imports, - function ($import) use ($namespaces) { + static function ($import) use ($namespaces) { foreach ($namespaces as $namespace) { if (stripos($namespace, $import) === 0) { return true; @@ -262,34 +272,83 @@ function ($import) use ($namespaces) { return false; } ); + } - $this->parser->setImports($without_foreign_annotations); + /** + * Parses docblock annotations from the property's doc comment and runs all registered processors. + * + * @param array $known_imports Imports filtered to known annotation namespaces. + * @return array{bool, bool} [is_encrypted, is_string_column] + */ + private function processDocblockAnnotations(array $known_imports, string $filename): array + { + $this->parser->setImports($known_imports); $this->parser->setIgnoreNotImportedAnnotations(true); - $annotations = $this->parser->parse($this->property->getDocComment(), $filename); - - // If the property is encrypted, column type MUST be string. + $annotations = $this->parser->parse($this->property->getDocComment(), $filename); $is_encrypted = false; $is_string = true; - foreach ($this->annotation_processors as $processor) { + + foreach ($this->processors as $processor) { foreach ($annotations as $annotation) { - $processor->processAnnotation($annotation, $this); + $processor->apply($annotation, $this); - if ($annotation instanceof Generate && isset($annotation->encryption_alias)) { + if ($annotation instanceof Generate && $annotation->getEncryptionAlias() !== null) { $is_encrypted = true; } - if (!($annotation instanceof Column) - || !isset($annotation->type) - || \in_array($annotation->type, ['string', 'text']) + if ($annotation instanceof Column + && isset($annotation->type) + && !\in_array($annotation->type, ['string', 'text']) ) { - continue; + $is_string = false; + } + } + } + + return [$is_encrypted, $is_string]; + } + + /** + * Instantiates native PHP 8 #[...] attributes via AttributeInstantiator and runs all registered processors. + * + * @return array{bool, bool} [is_encrypted, is_string_column] + */ + private function processNativeAttributes(?ReflectionClass $class): array + { + $imports = $class ? $class->getUseStatements() : []; + $is_encrypted = false; + $is_string = true; + + foreach ($this->property->getAttributes() as $attr_text) { + foreach (AttributeInstantiator::instantiate($attr_text, $imports) as $instance) { + foreach ($this->processors as $processor) { + $processor->apply($instance, $this); + } + + if ($instance instanceof Generate && $instance->getEncryptionAlias() !== null) { + $is_encrypted = true; } - $is_string = false; + if ($instance instanceof Column + && isset($instance->type) + && !\in_array($instance->type, ['string', 'text']) + ) { + $is_string = false; + } } } + return [$is_encrypted, $is_string]; + } + + /** + * Throws if the property has an encryption_alias but its Column type is not string/text. + * + * @throws \RuntimeException + */ + private function validateEncryptionColumnType(bool $is_encrypted, bool $is_string): void + { if ($is_encrypted && !$is_string) { throw new \RuntimeException(sprintf( 'Property %s in class %s\%s has an encryption_alias set, but is not declared as column type \'string\'', @@ -306,7 +365,16 @@ function ($import) use ($namespaces) { */ public function getDocumentation(): string { - $block = strstr($this->property->getDocComment(), '@', true); + $doc_comment = $this->property->getDocComment(); + if (!$doc_comment) { + return ''; + } + + $block = strstr($doc_comment, '@', true); + if ($block === false) { + return ''; + } + $block = preg_replace('/\/\*\*\n/m', '', $block); $block = preg_replace('/\n[ \t]*[ ]?\*\/$/', '', $block); $block = preg_replace('/\n\n/', '', $block); diff --git a/src/PropertyProcessor/PropertyProcessorInterface.php b/src/PropertyProcessor/PropertyProcessorInterface.php new file mode 100644 index 0000000..65f9da8 --- /dev/null +++ b/src/PropertyProcessor/PropertyProcessorInterface.php @@ -0,0 +1,29 @@ + FQCN from the source file's use declarations + * + * @return object[] Instantiated attribute objects; empty when the class is not loadable or args are invalid + */ + public static function instantiate(string $attr_text, array $use_statements): array + { + $hash = substr(md5($attr_text . serialize($use_statements)), 0, 12); + $cls = '_SyntheticAttrHost_' . $hash; + + if (!class_exists($cls, false)) { + $php = " $fqn) { + // Skip non-compound names (e.g. `use DateTime;`): they live in the + // global namespace, need no import, and PHP warns the statement has + // no effect — which aborts the include in strict environments. + if (!str_contains($fqn, '\\')) { + continue; + } + $php .= is_int($alias) ? "use $fqn;\n" : "use $fqn as $alias;\n"; + } + $php .= "class $cls { #[$attr_text] public \$p; }\n"; + + $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $cls . '_' . getmypid() . '.php'; + file_put_contents($tmp, $php); + try { + include $tmp; + } finally { + unlink($tmp); + } + } + + $attrs = (new \ReflectionClass($cls))->getProperty('p')->getAttributes(); + $result = []; + foreach ($attrs as $attr) { + try { + $result[] = $attr->newInstance(); + } catch (\Throwable) { + // Attribute class not loadable or constructor args invalid — skip silently. + } + } + + return $result; + } +} diff --git a/src/Reflection/ReflectionClass.php b/src/Reflection/ReflectionClass.php index 4d6ecf3..197beda 100644 --- a/src/Reflection/ReflectionClass.php +++ b/src/Reflection/ReflectionClass.php @@ -312,11 +312,12 @@ public function getProperties(): array continue; } + $attributes = $this->parseAttributeTexts($vis_loc); // native PHP 8 attribute blocks $doc_comment = $this->parseDocComment($vis_loc); // doc comment $modifiers = $this->parsePropertyModifiers($vis_loc); // public, protected, private, static $name = substr($tokens->value($var_loc), 1); // property name $default = $this->parseDefaultValue($var_loc); // default value - $property = new ReflectionProperty($name, $modifiers, $default, $doc_comment, $this); + $property = new ReflectionProperty($name, $modifiers, $default, $doc_comment, $this, $attributes); $this->properties[] = $property; } @@ -396,6 +397,10 @@ private function parseNamespace($loc): string * stripped of leading whitespaces. Returns an empty string if no doc- * comment or an empty doc comment was found. * + * Skips over any #[...] attribute blocks that appear between the docblock + * and the visibility modifier, so that PHP 8 native attributes do not + * prevent the docblock from being found. + * * @param int $loc location of the visibility modifier or T_CLASS * * @return string the contents of the doc comment @@ -403,21 +408,114 @@ private function parseNamespace($loc): string private function parseDocComment($loc): string { $tokens = $this->getTokenStream(); + $pos = $loc; + + while (true) { + $pos = $tokens->previous($pos, [T_WHITESPACE, T_STATIC, T_FINAL]); + + if ($pos === null) { + return ''; + } + + if ($tokens->type($pos) === T_DOC_COMMENT) { + $doc_comment = $tokens->value($pos); + + return preg_replace('/^[ \t]*\*/m', ' *', $doc_comment); + } + + // Skip over a complete #[...] attribute block going backward. + if ($tokens->value($pos) === ']') { + $pos = $this->findAttributeOpenBracket($pos); + if ($pos === null) { + return ''; + } + continue; + } + + return ''; + } + } + + /** + * Given the position of a ']' that closes a #[...] attribute block, scan + * backward to find the matching T_ATTRIBUTE ('#[') and return its position. + * Handles nested brackets (e.g. array literals inside attribute arguments). + * + * @param int $close_loc position of the closing ']' token + * + * @return int|null position of T_ATTRIBUTE, or null if not found + */ + private function findAttributeOpenBracket(int $close_loc): ?int + { + $tokens = $this->getTokenStream(); + $depth = 1; + $pos = $close_loc; + + while ($depth > 0) { + if ($pos === 0) { + return null; + } - // Look back from T_PUBLIC, T_PROTECTED, T_PRIVATE or T_CLASS - // for the T_DOC_COMMENT token - $loc = $tokens->previous($loc, [T_WHITESPACE, T_STATIC, T_FINAL]); + $pos--; + $val = $tokens->value($pos); + $type = $tokens->type($pos); - // Check for doc comment - if ($loc && $tokens->type($loc) === T_DOC_COMMENT) { - $doc_comment = $tokens->value($loc); - // strip off indentation - $doc_comment = preg_replace('/^[ \t]*\*/m', ' *', $doc_comment); + if ($val === ']') { + $depth++; + } elseif ($val === '[' || $type === T_ATTRIBUTE) { + $depth--; + } + } + + return $pos; + } + + /** + * Collects the raw text of each #[...] attribute block that appears + * immediately before the visibility modifier at $vis_loc (i.e. between + * the docblock and the modifier, skipping whitespace). + * + * Returns an array of strings, each being the content inside one #[...] + * block (without the surrounding #[ and ]). + * + * @param int $vis_loc location of the visibility modifier + * + * @return string[] + */ + private function parseAttributeTexts(int $vis_loc): array + { + $tokens = $this->getTokenStream(); + $attributes = []; + $pos = $vis_loc; + + while (true) { + $pos = $tokens->previous($pos, [T_WHITESPACE, T_STATIC, T_FINAL]); + + if ($pos === null) { + break; + } + + if ($tokens->value($pos) !== ']') { + break; + } + + $close_pos = $pos; + $open_pos = $this->findAttributeOpenBracket($close_pos); + + if ($open_pos === null) { + break; + } + + $text = ''; + for ($i = $open_pos + 1; $i < $close_pos; $i++) { + $text .= $tokens->value($i); + } - return $doc_comment; + $attributes[] = $text; + $pos = $open_pos; } - return ''; + return array_reverse($attributes); } /** diff --git a/src/Reflection/ReflectionProperty.php b/src/Reflection/ReflectionProperty.php index 6479a2a..88629a0 100644 --- a/src/Reflection/ReflectionProperty.php +++ b/src/Reflection/ReflectionProperty.php @@ -37,17 +37,26 @@ class ReflectionProperty */ private $name; + /** + * Raw attribute text blocks extracted from the source (e.g. "ORM\Column(type: 'string')"). + * + * @var string[] + */ + private $attributes; + public function __construct( string $name, ?int $modifiers = null, ?string $default = null, ?string $doc_comment = null, - ?ReflectionClass $class = null + ?ReflectionClass $class = null, + array $attributes = [] ) { $this->name = $name; $this->default = $default; $this->doc_comment = $doc_comment; $this->class = $class; + $this->attributes = $attributes; $this->setModifiers($modifiers); } @@ -103,6 +112,18 @@ public function getDocComment(): ?string return $this->doc_comment; } + /** + * Get the raw attribute text blocks found before this property's visibility modifier. + * + * Each entry is the text inside a #[...] block, e.g. "ORM\Column(type: 'string')". + * + * @return string[] + */ + public function getAttributes(): array + { + return $this->attributes; + } + /** * Get the name of the property. The name is returned without the $-prefix. */ diff --git a/src/Reflection/TokenStream.php b/src/Reflection/TokenStream.php index 2b29ca4..7f9ecad 100644 --- a/src/Reflection/TokenStream.php +++ b/src/Reflection/TokenStream.php @@ -11,22 +11,22 @@ class TokenStream /** * Location of type within the PHP Token. */ - private const TYPE = 0; + private const int TYPE = 0; /** * Location of value within the PHP Token. */ - private const VALUE = 1; + private const int VALUE = 1; /** * Search direction from left to right. */ - private const LTR = 1; + private const int LTR = 1; /** * Search direction from right to left. */ - private const RTL = -1; + private const int RTL = -1; /** * PHP Token Stack. diff --git a/test/Annotation/EnumeratorTest.php b/test/Annotation/EnumeratorTest.php index f1b5b45..e972110 100644 --- a/test/Annotation/EnumeratorTest.php +++ b/test/Annotation/EnumeratorTest.php @@ -9,15 +9,13 @@ use PHPUnit\Framework\TestCase; /** - * @covers \Hostnet\Component\AccessorGenerator\Annotation\Enumerator + * @covers \Hostnet\Component\AccessorGenerator\Attribute\Enumerator */ class EnumeratorTest extends TestCase { public function testGetters(): void { - $enumerator = new Enumerator(); - $enumerator->name = 'Foo'; - $enumerator->value = '\\Some\\Random\\Class'; + $enumerator = new Enumerator(value: '\\Some\\Random\\Class', name: 'Foo'); self::assertEquals('\\Some\\Random\\Class', $enumerator->getEnumeratorClass()); self::assertEquals('Foo', $enumerator->getName()); diff --git a/test/Annotation/GenerateTest.php b/test/Annotation/GenerateTest.php index d06ef0e..b1106ce 100644 --- a/test/Annotation/GenerateTest.php +++ b/test/Annotation/GenerateTest.php @@ -9,35 +9,28 @@ use PHPUnit\Framework\TestCase; /** - * @covers \Hostnet\Component\AccessorGenerator\Annotation\Generate + * @covers \Hostnet\Component\AccessorGenerator\Attribute\Generate */ class GenerateTest extends TestCase { public function testDefaults(): void { $generate = new Generate(); - - // Test default on values and availabillity of - // the Generate Annotation public fields $generate->setDefaultVisibility(Generate::VISIBILITY_PUBLIC); - self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->get); - self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->set); - self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->add); - self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->remove); - self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->is); - self::assertTrue($generate->strict); - self::assertNull($generate->type); - self::assertNull($generate->encryption_alias); + self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->getGet()); + self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->getSet()); + self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->getAdd()); + self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->getRemove()); + self::assertSame(Generate::VISIBILITY_PUBLIC, $generate->getIs()); + self::assertTrue($generate->isStrict()); + self::assertNull($generate->getType()); + self::assertNull($generate->getEncryptionAlias()); } public function testTypeAndStrictnessAndEncryptionAlias(): void { - $generate = new Generate(); - - $generate->strict = false; - $generate->type = \stdClass::class; - $generate->encryption_alias = 'database.table.column'; + $generate = new Generate(strict: false, type: \stdClass::class, encryption_alias: 'database.table.column'); self::assertFalse($generate->isStrict()); self::assertSame(\stdClass::class, $generate->getType()); @@ -49,13 +42,7 @@ public function testTypeAndStrictnessAndEncryptionAlias(): void */ public function testNew($given, $expected): void { - $generate = new Generate(); - - $generate->get = $given; - $generate->set = $given; - $generate->add = $given; - $generate->remove = $given; - $generate->is = $given; + $generate = new Generate(get: $given, set: $given, add: $given, remove: $given, is: $given); self::assertSame($expected, $generate->getGet()); self::assertSame($expected, $generate->getSet()); diff --git a/test/Generator/CodeGeneratorTest.php b/test/Generator/CodeGeneratorTest.php index 4623a4c..7e7b6e1 100644 --- a/test/Generator/CodeGeneratorTest.php +++ b/test/Generator/CodeGeneratorTest.php @@ -6,10 +6,10 @@ namespace Hostnet\Component\AccessorGenerator\Generator; -use Hostnet\Component\AccessorGenerator\Annotation\Enumerator; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\PropertyInformation; +use Hostnet\Component\AccessorGenerator\Attribute\Enumerator; use Hostnet\Component\AccessorGenerator\Generator\Exception\ReferencedClassNotFoundException; use Hostnet\Component\AccessorGenerator\Generator\Exception\TypeUnknownException; +use Hostnet\Component\AccessorGenerator\PropertyProcessor\PropertyInformation; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionClass; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionProperty; use PHPUnit\Framework\TestCase; @@ -117,8 +117,7 @@ public function testGenerateAccessorsTypeUnknown(): void public function testGenerateEnumeratorClassNotFound(): void { - $enumerator = new Enumerator(); - $enumerator->value = '\\This\\Does\\Not\\Exist'; + $enumerator = new Enumerator(value: '\\This\\Does\\Not\\Exist'); $class = new ReflectionClass(__FILE__); $info = new PropertyInformation(new ReflectionProperty('my_prop', null, null, null, $class)); diff --git a/test/Generator/UniqueImportsTest.php b/test/Generator/UniqueImportsTest.php index 2886954..58b82a3 100644 --- a/test/Generator/UniqueImportsTest.php +++ b/test/Generator/UniqueImportsTest.php @@ -21,11 +21,11 @@ public function testFilterEmptySet(): void public function testFilterSortedSet(): void { $sorted_set = [ - 'A', - 'A\A', - 'A\B', - 'B', - 'B\A', + 'A\X', + 'A\X\A', + 'A\X\B', + 'B\X', + 'B\X\A', ]; self::assertSame($sorted_set, UniqueImports::filter($sorted_set)); @@ -35,19 +35,19 @@ public function testFilterUnsortedSet(): void { self::assertSame( [ - 'A', - 'A\A', - 'A\B', - 'B', - 'B\A', + 'A\X', + 'A\X\A', + 'A\X\B', + 'B\X', + 'B\X\A', ], UniqueImports::filter( [ - 'B', - 'A\A', - 'B\A', - 'A', - 'A\B', + 'B\X', + 'A\X\A', + 'B\X\A', + 'A\X', + 'A\X\B', ] ) ); @@ -57,18 +57,18 @@ public function testFilterDuplicatesSet(): void { self::assertEquals( [ - 'A', - 'A\A', - 'B', + 'A\X', + 'A\X\A', + 'B\X', ], UniqueImports::filter( [ - 'A\A', - 'B', - 'B', - 'A', - 'A\A', - 'B', + 'A\X\A', + 'B\X', + 'B\X', + 'A\X', + 'A\X\A', + 'B\X', ] ) ); @@ -78,21 +78,42 @@ public function testFilterDuplicatesWithDifferentKeysSet(): void { self::assertSame( [ - 0 => 'A', - 1 => 'A\A', - 'alias_a' => 'B', - 'alias_b' => 'B', - 2 => 'B', + 0 => 'A\X', + 1 => 'A\X\A', + 'alias_a' => 'B\X', + 'alias_b' => 'B\X', + 2 => 'B\X', ], UniqueImports::filter( [ - 2 => 'B', - 'alias_a' => 'B', - 1 => 'A', - 0 => 'A\A', - 'alias_b' => 'B', + 2 => 'B\X', + 'alias_a' => 'B\X', + 1 => 'A\X', + 0 => 'A\X\A', + 'alias_b' => 'B\X', ] ) ); } + + public function testFilterDropsNonCompoundClassNames(): void + { + // Non-compound class names (no backslash) produce `use DateTime;` which + // PHP warns has no effect. They must be stripped from trait output. + self::assertSame( + ['A\X', 'B\X'], + array_values(UniqueImports::filter(['DateTime', 'A\X', 'B\X'])) + ); + } + + public function testFilterKeepsFunctionAndConstImports(): void + { + // `use function sprintf;` and `use const PHP_EOL;` are intentional even + // when non-compound and must not be removed. + // After filtering and sorting the order is alphabetical. + self::assertSame( + ['A\X', 'const PHP_EOL', 'function sprintf'], + array_values(UniqueImports::filter(['function sprintf', 'const PHP_EOL', 'A\X'])) + ); + } } diff --git a/test/Generator/fixtures/MixedAnnotations.php b/test/Generator/fixtures/MixedAnnotations.php new file mode 100644 index 0000000..125d5f2 --- /dev/null +++ b/test/Generator/fixtures/MixedAnnotations.php @@ -0,0 +1,36 @@ + 0) { + throw new \BadMethodCallException( + sprintf( + 'getName() has no arguments but %d given.', + \func_num_args() + ) + ); + } + if ($this->name === null) { + throw new \LogicException(sprintf( + 'Property name is null, but the column is not nullable, '. + 'make sure your object is initialized in such a way the properties are in '. + 'a valid state, for example by using a proper constructor. If you want to ' . + 'test if an object is new for the database please consult the UnitOfWork.' . + 'It could also be that your column in the code is not set to be nullable ' . + 'and it currently contains a NULL-value in the database.' + )); + } + + return $this->name; + } + + /** + * Sets name + * + * @throws \BadMethodCallException if the number of arguments is not correct + * @throws \InvalidArgumentException if value is not of the right type + * @throws \LengthException if the length of the value is to long + * + * @param string $name + * + * @return $this|MixedAnnotations + */ + public function setName($name) + { + if (\func_num_args() !== 1) { + throw new \BadMethodCallException( + sprintf( + 'setName() has one argument but %d given.', + \func_num_args() + ) + ); + } + + if ($name === null + || \is_scalar($name) + || \is_callable([$name, '__toString']) + ) { + $name = (string)$name; + } else { + throw new \InvalidArgumentException( + 'Parameter name must be convertible to string.' + ); + } + + if (\strlen($name) > 100) { + throw new \LengthException('Parameter \'$name\' should not be longer than 100 characters.'); + } + + $this->name = $name; + + return $this; + } + + /** + * Gets count + * + * @throws \BadMethodCallException + * + * @return int|null + */ + public function getCount(): ?int + { + if (\func_num_args() > 0) { + throw new \BadMethodCallException( + sprintf( + 'getCount() has no arguments but %d given.', + \func_num_args() + ) + ); + } + + if ($this->count === null) { + return null; + } + + if ($this->count < -2147483648|| $this->count > 2147483647) { + throw new \DomainException( + sprintf( + 'Parameter count(%s) is too big for the integer domain [%d,%d]', + $this->count, + -2147483648, + 2147483647 + ) + ); + } + + return (int) $this->count; + } + + /** + * Gets created_at + * + * @throws \BadMethodCallException + * @throws \LogicException + * + * @return \DateTime + */ + public function getCreatedAt(): \DateTime + { + if (\func_num_args() > 0) { + throw new \BadMethodCallException( + sprintf( + 'getCreatedAt() has no arguments but %d given.', + \func_num_args() + ) + ); + } + if ($this->created_at === null) { + throw new \LogicException(sprintf( + 'Property created_at is null, but the column is not nullable, '. + 'make sure your object is initialized in such a way the properties are in '. + 'a valid state, for example by using a proper constructor. If you want to ' . + 'test if an object is new for the database please consult the UnitOfWork.' . + 'It could also be that your column in the code is not set to be nullable ' . + 'and it currently contains a NULL-value in the database.' + )); + } + + return $this->created_at; + } +} diff --git a/test/Generator/fixtures/expected/NativeAttributesMethodsTrait.php b/test/Generator/fixtures/expected/NativeAttributesMethodsTrait.php new file mode 100644 index 0000000..941e55b --- /dev/null +++ b/test/Generator/fixtures/expected/NativeAttributesMethodsTrait.php @@ -0,0 +1,128 @@ + 0) { + throw new \BadMethodCallException( + sprintf( + 'getLabel() has no arguments but %d given.', + \func_num_args() + ) + ); + } + if ($this->label === null) { + throw new \LogicException(sprintf( + 'Property label is null, but the column is not nullable, '. + 'make sure your object is initialized in such a way the properties are in '. + 'a valid state, for example by using a proper constructor. If you want to ' . + 'test if an object is new for the database please consult the UnitOfWork.' . + 'It could also be that your column in the code is not set to be nullable ' . + 'and it currently contains a NULL-value in the database.' + )); + } + + return $this->label; + } + + /** + * Sets label + * + * @throws \BadMethodCallException if the number of arguments is not correct + * @throws \InvalidArgumentException if value is not of the right type + * @throws \LengthException if the length of the value is to long + * + * @param string $label + * + * @return $this|NativeAttributes + */ + public function setLabel($label) + { + if (\func_num_args() !== 1) { + throw new \BadMethodCallException( + sprintf( + 'setLabel() has one argument but %d given.', + \func_num_args() + ) + ); + } + + if ($label === null + || \is_scalar($label) + || \is_callable([$label, '__toString']) + ) { + $label = (string)$label; + } else { + throw new \InvalidArgumentException( + 'Parameter label must be convertible to string.' + ); + } + + if (\strlen($label) > 255) { + throw new \LengthException('Parameter \'$label\' should not be longer than 255 characters.'); + } + + $this->label = $label; + + return $this; + } + + /** + * Gets count + * + * @throws \BadMethodCallException + * @throws \LogicException + * + * @return int + */ + public function getCount(): int + { + if (\func_num_args() > 0) { + throw new \BadMethodCallException( + sprintf( + 'getCount() has no arguments but %d given.', + \func_num_args() + ) + ); + } + if ($this->count === null) { + throw new \LogicException(sprintf( + 'Property count is null, but the column is not nullable, '. + 'make sure your object is initialized in such a way the properties are in '. + 'a valid state, for example by using a proper constructor. If you want to ' . + 'test if an object is new for the database please consult the UnitOfWork.' . + 'It could also be that your column in the code is not set to be nullable ' . + 'and it currently contains a NULL-value in the database.' + )); + } + + if ($this->count < -2147483648|| $this->count > 2147483647) { + throw new \DomainException( + sprintf( + 'Parameter count(%s) is too big for the integer domain [%d,%d]', + $this->count, + -2147483648, + 2147483647 + ) + ); + } + + return (int) $this->count; + } +} diff --git a/test/AnnotationProcessor/GenerateAnnotationProcessorTest.php b/test/PropertyProcessor/AccessorGenerationProcessorTest.php similarity index 53% rename from test/AnnotationProcessor/GenerateAnnotationProcessorTest.php rename to test/PropertyProcessor/AccessorGenerationProcessorTest.php index 2644be6..90cc14a 100644 --- a/test/AnnotationProcessor/GenerateAnnotationProcessorTest.php +++ b/test/PropertyProcessor/AccessorGenerationProcessorTest.php @@ -4,83 +4,60 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor; use Doctrine\ORM\Mapping\Column; -use Hostnet\Component\AccessorGenerator\Annotation\Enumerator; -use Hostnet\Component\AccessorGenerator\Annotation\Generate; +use Hostnet\Component\AccessorGenerator\Attribute\Enumerator; +use Hostnet\Component\AccessorGenerator\Attribute\Generate; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionProperty; use PHPUnit\Framework\TestCase; /** - * @covers \Hostnet\Component\AccessorGenerator\AnnotationProcessor\GenerateAnnotationProcessor + * @covers \Hostnet\Component\AccessorGenerator\PropertyProcessor\AccessorGenerationProcessor */ -class GenerateAnnotationProcessorTest extends TestCase +class AccessorGenerationProcessorTest extends TestCase { // Some constants for better reading of the // parameters parsed into function. - private const GET = true; - private const NO_GET = false; - private const SET = true; - private const NO_SET = false; - private const ADD = true; - private const NO_ADD = false; - private const REMOVE = true; - private const NO_REMOVE = false; - - /** - * Generate TestCases for the parsing - * of the @Generate annotation. - */ - public function processAnnotationProvider(): iterable + private const true GET = true; + private const false NO_GET = false; + private const true SET = true; + private const false NO_SET = false; + private const true ADD = true; + private const false NO_ADD = false; + private const true REMOVE = true; + private const false NO_REMOVE = false; + + public function applyProvider(): iterable { - $all = new Generate(); - $no_get = new Generate(); - $no_is = new Generate(); - $no_set = new Generate(); - $no_add = new Generate(); - $no_remove = new Generate(); - $no_collection = new Generate(); - $nothing = new Generate(); - $type = new Generate(); - $encryption = new Generate(); - $enumerate = new Generate(); - $column = new Column(); - $enumerator = new Enumerator(); - - $no_is->is = 'none'; - $no_get->get = 'none'; - $no_set->set = 'none'; - $no_add->add = 'none'; - $no_remove->remove = 'none'; - - $no_collection->add = 'none'; - $no_collection->remove = 'none'; - - $nothing->get = 'none'; - $nothing->is = 'none'; - $nothing->set = 'none'; - $nothing->add = 'none'; - $nothing->remove = 'none'; + $enumerator = new Enumerator(value: 'SomeClass', name: 'Foo'); - $type->get = 'none'; - $type->is = 'none'; - $type->set = 'none'; - $type->add = 'none'; - $type->remove = 'none'; - $type->type = \ArrayObject::class; - - $encryption->get = 'none'; - $encryption->is = 'none'; - $encryption->set = 'none'; - $encryption->add = 'none'; - $encryption->remove = 'none'; - $encryption->encryption_alias = 'database.table.column'; - - $enumerate->enumerators = [$enumerator]; - $enumerator->name = 'Foo'; - $enumerator->value = 'SomeClass'; + $all = new Generate(); + $no_get = new Generate(get: 'none'); + $no_is = new Generate(is: 'none'); + $no_set = new Generate(set: 'none'); + $no_add = new Generate(add: 'none'); + $no_remove = new Generate(remove: 'none'); + $no_collection = new Generate(add: 'none', remove: 'none'); + $nothing = new Generate(get: 'none', is: 'none', set: 'none', add: 'none', remove: 'none'); + $type = new Generate( + get: 'none', + is: 'none', + set: 'none', + add: 'none', + remove: 'none', + type: \ArrayObject::class + ); + $encryption = new Generate( + get: 'none', + is: 'none', + set: 'none', + add: 'none', + remove: 'none', + encryption_alias: 'database.table.column' + ); + $enumerate = new Generate(enumerators: [$enumerator]); return [ [$column, self::NO_GET, self::NO_SET, self::NO_ADD, self::NO_REMOVE, null, null], @@ -107,7 +84,7 @@ public function processAnnotationProvider(): iterable } /** - * @dataProvider processAnnotationProvider + * @dataProvider applyProvider * @param mixed $annotation * @param bool $get * @param bool $set @@ -116,13 +93,13 @@ public function processAnnotationProvider(): iterable * @param string $type * @param string $encryption */ - public function testProcessAnnotation($annotation, $get, $set, $add, $remove, $type, $encryption): void + public function testApply($annotation, $get, $set, $add, $remove, $type, $encryption): void { // Set up dependencies. $property = new ReflectionProperty('test'); $information = new PropertyInformation($property); - $processor = new GenerateAnnotationProcessor(); - $processor->processAnnotation($annotation, $information); + $processor = new AccessorGenerationProcessor(); + $processor->apply($annotation, $information); // Check if right information was processed. self::assertSame($get, $information->willGenerateGet()); @@ -143,25 +120,18 @@ public function testProcessAnnotation($annotation, $get, $set, $add, $remove, $t public function testEnumeratorVisibilities(): void { - $enumerator = new Enumerator(); - $enumerator->name = 'Foo'; - $enumerator->value = 'SomeClass'; - - $annotation = new Generate(); - $annotation2 = new Generate(); - - $annotation->enumerators = [$enumerator]; - $annotation2->enumerators = [$enumerator]; - $annotation2->get = Generate::VISIBILITY_PUBLIC; + $enumerator = new Enumerator(value: 'SomeClass', name: 'Foo'); + $annotation = new Generate(enumerators: [$enumerator]); + $annotation2 = new Generate(enumerators: [$enumerator], get: Generate::VISIBILITY_PUBLIC); $property = new ReflectionProperty('test'); $property2 = new ReflectionProperty('test2'); $information = new PropertyInformation($property); $information2 = new PropertyInformation($property2); - $processor = new GenerateAnnotationProcessor(); + $processor = new AccessorGenerationProcessor(); - $processor->processAnnotation($annotation, $information); - $processor->processAnnotation($annotation2, $information2); + $processor->apply($annotation, $information); + $processor->apply($annotation2, $information2); self::assertTrue($information->willGenerateEnumeratorAccessors()); self::assertFalse($information->willGenerateGet()); @@ -170,11 +140,11 @@ public function testEnumeratorVisibilities(): void self::assertFalse($information->willGenerateRemove()); } - public function testGetProcessableAnnotationNamespace(): void + public function testGetProcessableNamespace(): void { self::assertSame( 'Hostnet\Component\AccessorGenerator\Annotation', - (new GenerateAnnotationProcessor())->getProcessableAnnotationNamespace() + (new AccessorGenerationProcessor())->getProcessableNamespace() ); } } diff --git a/test/AnnotationProcessor/DoctrineAnnotationProcessorTest.php b/test/PropertyProcessor/DoctrineMappingProcessorTest.php similarity index 84% rename from test/AnnotationProcessor/DoctrineAnnotationProcessorTest.php rename to test/PropertyProcessor/DoctrineMappingProcessorTest.php index 48943c9..bc2aa67 100644 --- a/test/AnnotationProcessor/DoctrineAnnotationProcessorTest.php +++ b/test/PropertyProcessor/DoctrineMappingProcessorTest.php @@ -4,7 +4,7 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Column; @@ -14,14 +14,14 @@ use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\ORM\Mapping\OneToOne; -use Hostnet\Component\AccessorGenerator\AnnotationProcessor\Exception\InvalidColumnSettingsException; +use Hostnet\Component\AccessorGenerator\PropertyProcessor\Exception\InvalidColumnSettingsException; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionProperty; use PHPUnit\Framework\TestCase; /** - * @covers \Hostnet\Component\AccessorGenerator\AnnotationProcessor\DoctrineAnnotationProcessor + * @covers \Hostnet\Component\AccessorGenerator\PropertyProcessor\DoctrineMappingProcessor */ -class DoctrineAnnotationProcessorTest extends TestCase +class DoctrineMappingProcessorTest extends TestCase { /** * @var PropertyInformation @@ -29,26 +29,23 @@ class DoctrineAnnotationProcessorTest extends TestCase private $information; /** - * @var DoctrineAnnotationProcessor + * @var DoctrineMappingProcessor */ private $processor; protected function setUp(): void { $this->information = new PropertyInformation(new ReflectionProperty('test')); - $this->processor = new DoctrineAnnotationProcessor(); + $this->processor = new DoctrineMappingProcessor(); } /** - * Generate TestCases for the parsing - * of the @Column annotation. - * * @return Column|mixed[][] * @throws \RangeException * @throws \InvalidArgumentException * @throws \DomainException */ - public function processColumnAnnotationProvider(): iterable + public function columnProvider(): iterable { $property = new ReflectionProperty('test'); $implicit = new Column(null, 'string'); @@ -92,24 +89,24 @@ public function processColumnAnnotationProvider(): iterable } /** - * @dataProvider processColumnAnnotationProvider + * @dataProvider columnProvider * @param Column $column - * @param PropertyInformationInterface $output + * @param PropertyInformation $output * @param $exception * @throws \DomainException - * @throws \Hostnet\Component\AccessorGenerator\AnnotationProcessor\Exception\InvalidColumnSettingsException + * @throws \Hostnet\Component\AccessorGenerator\PropertyProcessor\Exception\InvalidColumnSettingsException * @throws \InvalidArgumentException * @throws \RangeException * @throws \Hostnet\Component\AccessorGenerator\Reflection\Exception\ClassDefinitionNotFoundException * @throws \OutOfBoundsException */ - public function testProcessColumnAnnotation(Column $column, PropertyInformationInterface $output, $exception): void + public function testProcessColumn(Column $column, PropertyInformation $output, $exception): void { // Set if an explosion is needed. $exception && $this->expectException($exception); // Set up dependencies. - $this->processor->processAnnotation($column, $this->information); + $this->processor->apply($column, $this->information); // Check if right information was processed. self::assertEquals( @@ -154,7 +151,7 @@ public function testProcessColumnAnnotation(Column $column, PropertyInformationI ); } - public function processAssociationAnnotationProvider(): iterable + public function associationProvider(): iterable { $many_to_many = new ManyToMany(); $many_to_one = new ManyToOne(); @@ -192,21 +189,21 @@ public function processAssociationAnnotationProvider(): iterable } /** - * @dataProvider processAssociationAnnotationProvider + * @dataProvider associationProvider * @param $annotation * @throws \DomainException - * @throws \Hostnet\Component\AccessorGenerator\AnnotationProcessor\Exception\InvalidColumnSettingsException + * @throws \Hostnet\Component\AccessorGenerator\PropertyProcessor\Exception\InvalidColumnSettingsException * @throws \InvalidArgumentException * @throws \RangeException * @throws \Hostnet\Component\AccessorGenerator\Reflection\Exception\ClassDefinitionNotFoundException * @throws \OutOfBoundsException */ - public function testAssociationAnnotations($annotation): void + public function testAssociation($annotation): void { // Set up dependencies. - $this->processor->processAnnotation($annotation, $this->information); + $this->processor->apply($annotation, $this->information); - // These annotation should lead to isCollection is is true + // These should lead to isCollection() returning true if ($annotation instanceof ManyToMany || $annotation instanceof OneToMany) { self::assertTrue($this->information->isCollection()); } else { @@ -287,7 +284,7 @@ public function typeConversionDataProvider(): array * @param string $php_type * @param null $exception * @throws \DomainException - * @throws \Hostnet\Component\AccessorGenerator\AnnotationProcessor\Exception\InvalidColumnSettingsException + * @throws \Hostnet\Component\AccessorGenerator\PropertyProcessor\Exception\InvalidColumnSettingsException * @throws \InvalidArgumentException * @throws \RangeException * @throws \Hostnet\Component\AccessorGenerator\Reflection\Exception\ClassDefinitionNotFoundException @@ -310,7 +307,7 @@ public function testTypeConversion($doctrine_type, $php_type, $exception = ''): $annotation->scale = 1; } - $this->processor->processAnnotation($annotation, $this->information); + $this->processor->apply($annotation, $this->information); self::assertSame($php_type, $this->information->getType()); } @@ -318,16 +315,16 @@ public function testTypeConversion($doctrine_type, $php_type, $exception = ''): * @throws InvalidColumnSettingsException * @throws \Hostnet\Component\AccessorGenerator\Reflection\Exception\ClassDefinitionNotFoundException */ - public function testOtherAnnotation(): void + public function testUnrecognisedObjectIsIgnored(): void { $information = clone $this->information; $annotation = new \stdClass(); - $this->processor->processAnnotation($annotation, $this->information); + $this->processor->apply($annotation, $this->information); self::assertEquals($information, $this->information); } - public function testGetProcessableAnnotationNamespace(): void + public function testGetProcessableNamespace(): void { - self::assertSame('Doctrine\ORM\Mapping', $this->processor->getProcessableAnnotationNamespace()); + self::assertSame('Doctrine\ORM\Mapping', $this->processor->getProcessableNamespace()); } } diff --git a/test/AnnotationProcessor/EnumItemInformationTest.php b/test/PropertyProcessor/EnumItemInformationTest.php similarity index 79% rename from test/AnnotationProcessor/EnumItemInformationTest.php rename to test/PropertyProcessor/EnumItemInformationTest.php index 05273e2..331b6b4 100644 --- a/test/AnnotationProcessor/EnumItemInformationTest.php +++ b/test/PropertyProcessor/EnumItemInformationTest.php @@ -4,47 +4,47 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor; use Doctrine\Inflector\InflectorFactory; use PHPUnit\Framework\TestCase; /** - * @covers \Hostnet\Component\AccessorGenerator\AnnotationProcessor\EnumItemInformation + * @covers \Hostnet\Component\AccessorGenerator\PropertyProcessor\EnumItemInformation */ class EnumItemInformationTest extends TestCase { /** * This is of type: array. */ - public const A_TEST_CONSTANT = 'I_TEST_CONSTANT'; + public const string A_TEST_CONSTANT = 'I_TEST_CONSTANT'; /** * This is of type: int. */ - public const I_TEST_CONSTANT = 'I_TEST_CONSTANT'; + public const string I_TEST_CONSTANT = 'I_TEST_CONSTANT'; /** * This is of type: string. */ - public const S_TEST_CONSTANT = 'S_TEST_CONSTANT'; + public const string S_TEST_CONSTANT = 'S_TEST_CONSTANT'; /** * This is of type: float. */ - public const F_TEST_CONSTANT = 'F_TEST_CONSTANT'; + public const string F_TEST_CONSTANT = 'F_TEST_CONSTANT'; /** * This is of type: bool. */ - public const B_TEST_CONSTANT = 'B_TEST_CONSTANT'; + public const string B_TEST_CONSTANT = 'B_TEST_CONSTANT'; /** * This is a broken constant. */ - public const BROKEN_CONSTANT = 'BROKEN_CONSTANT'; + public const string BROKEN_CONSTANT = 'BROKEN_CONSTANT'; - public const S_CONSTANT_WITHOUT_DOCBLOCK = 'S_CONSTANT_WITHOUT_DOCBLOCK'; + public const string S_CONSTANT_WITHOUT_DOCBLOCK = 'S_CONSTANT_WITHOUT_DOCBLOCK'; private $inflector; diff --git a/test/AnnotationProcessor/PropertyInformationTest.php b/test/PropertyProcessor/PropertyInformationTest.php similarity index 96% rename from test/AnnotationProcessor/PropertyInformationTest.php rename to test/PropertyProcessor/PropertyInformationTest.php index e559d91..13f7b80 100644 --- a/test/AnnotationProcessor/PropertyInformationTest.php +++ b/test/PropertyProcessor/PropertyInformationTest.php @@ -4,15 +4,15 @@ */ declare(strict_types=1); -namespace Hostnet\Component\AccessorGenerator\AnnotationProcessor; +namespace Hostnet\Component\AccessorGenerator\PropertyProcessor; -use Hostnet\Component\AccessorGenerator\Annotation\Generate; +use Hostnet\Component\AccessorGenerator\Attribute\Generate; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionClass; use Hostnet\Component\AccessorGenerator\Reflection\ReflectionProperty; use PHPUnit\Framework\TestCase; /** - * @covers \Hostnet\Component\AccessorGenerator\AnnotationProcessor\PropertyInformation + * @covers \Hostnet\Component\AccessorGenerator\PropertyProcessor\PropertyInformation */ class PropertyInformationTest extends TestCase { @@ -47,12 +47,12 @@ protected function setUp(): void public function testProcessAnnotations(): void { - $processor = $this->createMock(AnnotationProcessorInterface::class); - $processor->expects(self::atLeastOnce())->method('processAnnotation'); + $processor = $this->createMock(PropertyProcessorInterface::class); + $processor->expects(self::atLeastOnce())->method('apply'); - /** @var AnnotationProcessorInterface $processor */ - $this->info->registerAnnotationProcessor($processor); - $this->info->processAnnotations(); + /** @var PropertyProcessorInterface $processor */ + $this->info->registerProcessor($processor); + $this->info->process(); } public function testGetDocumentation(): void diff --git a/test/AnnotationProcessor/fixtures/doc_block.txt b/test/PropertyProcessor/fixtures/doc_block.txt similarity index 100% rename from test/AnnotationProcessor/fixtures/doc_block.txt rename to test/PropertyProcessor/fixtures/doc_block.txt diff --git a/test/Reflection/AttributeInstantiatorTest.php b/test/Reflection/AttributeInstantiatorTest.php new file mode 100644 index 0000000..3d3dd79 --- /dev/null +++ b/test/Reflection/AttributeInstantiatorTest.php @@ -0,0 +1,72 @@ + 'Hostnet\Component\AccessorGenerator\Attribute'] + ); + + self::assertCount(1, $result); + self::assertInstanceOf(Generate::class, $result[0]); + self::assertEquals('none', $result[0]->getSet()); + } + + public function testSkipsUnloadableAttributeClass(): void + { + $result = AttributeInstantiator::instantiate( + 'NonExistent\Attr', + [] + ); + + self::assertSame([], $result); + } + + /** + * A file-level `use DateTime;` (non-compound name) must not produce a PHP + * warning that aborts the synthetic-file include. PHP warns because + * `DateTime` lives in the global namespace and `use DateTime;` has no effect. + */ + public function testNonCompoundUseStatementDoesNotError(): void + { + // Simulate a source file that has both a real import and a bare global + // class import (`use DateTime;`), which is what triggers the bug. + $use_statements = [ + 'AG' => 'Hostnet\Component\AccessorGenerator\Attribute', + 'DateTime' => 'DateTime', + ]; + + $result = AttributeInstantiator::instantiate("AG\\Generate(set: 'none')", $use_statements); + + self::assertCount(1, $result); + self::assertInstanceOf(Generate::class, $result[0]); + } + + public function testNonCompoundUseStatementWithoutAlias(): void + { + // Same scenario but stored without an alias key (numeric index). + $use_statements = [ + 'AG' => 'Hostnet\Component\AccessorGenerator\Attribute', + 0 => 'DateTime', + ]; + + $result = AttributeInstantiator::instantiate("AG\\Generate(set: 'none')", $use_statements); + + self::assertCount(1, $result); + self::assertInstanceOf(Generate::class, $result[0]); + } +} diff --git a/test/Reflection/TokenStreamTest.php b/test/Reflection/TokenStreamTest.php index be3fe0c..7051299 100644 --- a/test/Reflection/TokenStreamTest.php +++ b/test/Reflection/TokenStreamTest.php @@ -13,8 +13,8 @@ */ class TokenStreamTest extends TestCase { - private const SOURCE = 'tokens.php'; - private const PHP_8_SIZE = 105; + private const string SOURCE = 'tokens.php'; + private const int PHP_8_SIZE = 105; /** * @var TokenStream diff --git a/test/Twig/TestEnvironment.php b/test/Twig/TestEnvironment.php index 41f87d2..637857b 100644 --- a/test/Twig/TestEnvironment.php +++ b/test/Twig/TestEnvironment.php @@ -8,34 +8,18 @@ use Twig\Environment; use Twig\Extension\AbstractExtension; -use Twig\Extension\ExtensionInterface; use Twig\Loader\ArrayLoader; /** - * Prevents using the default registered extensions by Twig\Environment. + * A minimal Twig environment that adds a single extension for testing. * * The default loader is always Twig\Loader\ArrayLoader. */ class TestEnvironment extends Environment { - /** - * @var AbstractExtension - */ - private $extension; - public function __construct(AbstractExtension $extension) { - $this->extension = $extension; - parent::__construct(new ArrayLoader()); + parent::__construct(new ArrayLoader(), ['autoescape' => false]); $this->addExtension($extension); } - - public function addExtension(ExtensionInterface $extension): void - { - if ($this->extension !== $extension) { - return; - } - - parent::addExtension($extension); - } }