From d98d2492e6d7249d12b1becb1e71aa8f0f3fe2f1 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Tue, 3 Mar 2026 08:40:52 -0300 Subject: [PATCH 1/7] feat(helper): Add `Reflector` helper for class, property, type, and attribute metadata introspection. --- CHANGELOG.md | 2 + composer.json | 2 +- src/Exception/Message.php | 14 + src/Reflector.php | 328 +++++++++++++++++++ tests/ReflectorTest.php | 365 ++++++++++++++++++++++ tests/Support/Attribute/Label.php | 13 + tests/Support/Attribute/Marker.php | 10 + tests/Support/Contract/BothContract.php | 7 + tests/Support/Contract/LeftContract.php | 7 + tests/Support/Contract/RightContract.php | 7 + tests/Support/Model/ReflectionFixture.php | 40 +++ 11 files changed, 794 insertions(+), 1 deletion(-) create mode 100644 src/Reflector.php create mode 100644 tests/ReflectorTest.php create mode 100644 tests/Support/Attribute/Label.php create mode 100644 tests/Support/Attribute/Marker.php create mode 100644 tests/Support/Contract/BothContract.php create mode 100644 tests/Support/Contract/LeftContract.php create mode 100644 tests/Support/Contract/RightContract.php create mode 100644 tests/Support/Model/ReflectionFixture.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7379ae7..f5dda22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.2.1 Under development +- Enh #0: Add `Reflector` helper for class, property, type, and attribute metadata introspection (@terabytesoftw) + ## 0.2.0 February 25, 2024 - Enh #6: Refactor codebase to improve performance (@terabytesoftw) diff --git a/composer.json b/composer.json index 8a47861..d8864c2 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "php-forge/helper", "type": "library", - "description": "PHP Helper.", + "description": "PHPForge Helpers for Common PHP Tasks.", "keywords": [ "php-forge", "helper", diff --git a/src/Exception/Message.php b/src/Exception/Message.php index 87e809f..f3c40df 100644 --- a/src/Exception/Message.php +++ b/src/Exception/Message.php @@ -23,6 +23,20 @@ enum Message: string */ case PASSWORD_LENGTH_TOO_SHORT = "Password length must be at least '%d' characters."; + /** + * Error message for missing reflected property. + * + * Format: "Property '%s' does not exist in '%s'." + */ + case REFLECTOR_PROPERTY_NOT_FOUND = "Property '%s' does not exist in '%s'."; + + /** + * Error message for invalid reflection target. + * + * Format: "Invalid reflection target '%s'." + */ + case REFLECTOR_TARGET_INVALID = "Invalid reflection target '%s'."; + /** * Returns the formatted message string for the error case. * diff --git a/src/Reflector.php b/src/Reflector.php new file mode 100644 index 0000000..cb9c311 --- /dev/null +++ b/src/Reflector.php @@ -0,0 +1,328 @@ +> + */ + private static array $reflectionClassCache = []; + + /** + * Returns class attributes, optionally filtered by attribute class. + * + * Usage example: + * ```php + * $attributes = \PHPForge\Helper\Reflector::classAttributes( + * SomeClass::class, + * SomeAttribute::class, + * ReflectionAttribute::IS_INSTANCEOF, + * ); + * ``` + * + * @return array Class attributes. + * + * @phpstan-return list> + */ + public static function classAttributes(object|string $class, string|null $attribute = null, int $flags = 0): array + { + $reflectionClass = self::reflectionClass($class); + + if ($attribute === null) { + return $reflectionClass->getAttributes(); + } + + return $reflectionClass->getAttributes($attribute, $flags); + } + + /** + * Returns the first matching instantiated property attribute. + * + * Usage example: + * ```php + * $attributeInstance = \PHPForge\Helper\Reflector::firstPropertyAttribute( + * SomeClass::class, + * 'someProperty', + * SomeAttribute::class, + * ReflectionAttribute::IS_INSTANCEOF, + * ); + * ``` + * + * @return object|null First matching instantiated attribute, or `null` if no matching attribute is found. + */ + public static function firstPropertyAttribute( + object|string $class, + string $property, + string $attribute, + int $flags = 0, + ): object|null { + $attributes = self::propertyAttributes($class, $property, $attribute, $flags); + + if ($attributes === []) { + return null; + } + + return $attributes[0]->newInstance(); + } + + /** + * Checks whether a property exists on the class. + * + * Usage example: + * ```php + * $hasProperty = \PHPForge\Helper\Reflector::hasProperty(SomeClass::class, 'someProperty'); + * ``` + * + * @return bool `true` if the property exists, `false` otherwise. + */ + public static function hasProperty(object|string $class, string $property): bool + { + return self::reflectionClass($class)->hasProperty($property); + } + + /** + * Returns reflected properties, optionally filtered by visibility flags. + * + * Usage example: + * ```php + * $publicProperties = \PHPForge\Helper\Reflector::properties(SomeClass::class, ReflectionProperty::IS_PUBLIC); + * ``` + * + * @return array Reflected properties. + * + * @phpstan-return list + */ + public static function properties(object|string $class, int|null $filter = null): array + { + $reflectionClass = self::reflectionClass($class); + + if ($filter === null) { + return $reflectionClass->getProperties(); + } + + return $reflectionClass->getProperties($filter); + } + + /** + * Returns a reflected property. + * + * Usage example: + * ```php + * $property = \PHPForge\Helper\Reflector::property(SomeClass::class, 'someProperty'); + * ``` + * + * @throws InvalidArgumentException if the property does not exist. + * + * @return ReflectionProperty Reflected property. + */ + public static function property(object|string $class, string $property): ReflectionProperty + { + $reflectionClass = self::reflectionClass($class); + + if (!$reflectionClass->hasProperty($property)) { + throw new InvalidArgumentException( + Message::REFLECTOR_PROPERTY_NOT_FOUND->getMessage($property, $reflectionClass->getName()), + ); + } + + return $reflectionClass->getProperty($property); + } + + /** + * Returns instantiated property attributes. + * + * Usage example: + * ```php + * $attributeInstances = \PHPForge\Helper\Reflector::propertyAttributeInstances( + * SomeClass::class, + * 'someProperty', + * SomeAttribute::class, + * ReflectionAttribute::IS_INSTANCEOF, + * ); + * + * @return array Instantiated property attributes. + * + * @phpstan-return list + */ + public static function propertyAttributeInstances( + object|string $class, + string $property, + string|null $attribute = null, + int $flags = 0, + ): array { + $attributes = self::propertyAttributes($class, $property, $attribute, $flags); + + return array_map( + static fn(ReflectionAttribute $reflectionAttribute): object => $reflectionAttribute->newInstance(), + $attributes, + ); + } + + /** + * Returns property attributes, optionally filtered by attribute class. + * + * Usage example: + * ```php + * $attributes = \PHPForge\Helper\Reflector::propertyAttributes( + * SomeClass::class, + * 'someProperty', + * SomeAttribute::class, + * ReflectionAttribute::IS_INSTANCEOF, + * ); + * ``` + * + * @return array Property attributes. + * + * @phpstan-return list> + */ + public static function propertyAttributes( + object|string $class, + string $property, + string|null $attribute = null, + int $flags = 0, + ): array { + $reflectionProperty = self::property($class, $property); + + if ($attribute === null) { + return $reflectionProperty->getAttributes(); + } + + return $reflectionProperty->getAttributes($attribute, $flags); + } + + /** + * Returns property type names. + * + * For nullable named types, the list includes `'null'` as an extra element. + * + * Usage example: + * ```php + * $typeNames = \PHPForge\Helper\Reflector::propertyTypeNames(SomeClass::class, 'someProperty'); + * ``` + * + * @return list Type names of the property, or an empty list if the property has no type declaration. + * + * @phpstan-return list + */ + public static function propertyTypeNames(object|string $class, string $property): array + { + $type = self::property($class, $property)->getType(); + + if ($type instanceof ReflectionNamedType) { + $typeName = $type->getName(); + + if ($type->allowsNull() && $typeName !== 'null') { + return [$typeName, 'null']; + } + + return [$typeName]; + } + + if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) { + /** @var list $typeNames */ + $typeNames = []; + + foreach ($type->getTypes() as $nestedType) { + if ($nestedType instanceof ReflectionNamedType) { + $typeNames[] = $nestedType->getName(); + } + } + + return $typeNames; + } + + return []; + } + + /** + * Returns the short class name. + * + * Usage example: + * ```php + * $shortName = \PHPForge\Helper\Reflector::shortName(SomeClass::class); + * ``` + * + * @return string Short name of the class, or an empty string for anonymous classes. + */ + public static function shortName(object|string $class): string + { + $reflectionClass = self::reflectionClass($class); + + return $reflectionClass->isAnonymous() ? '' : $reflectionClass->getShortName(); + } + + /** + * Creates a reflection class from an object or class name. + * + * @param object|string $class Object instance or class name to reflect. + * + * @throws InvalidArgumentException if the class target is invalid. + * + * @return ReflectionClass Reflection class instance. + */ + private static function reflectionClass(object|string $class): ReflectionClass + { + $className = is_object($class) ? $class::class : $class; + + if (!class_exists($className)) { + throw new InvalidArgumentException( + Message::REFLECTOR_TARGET_INVALID->getMessage($className), + ); + } + + if (isset(self::$reflectionClassCache[$className])) { + return self::$reflectionClassCache[$className]; + } + + $reflectionClass = new ReflectionClass($className); + + if (!$reflectionClass->isAnonymous()) { + if (count(self::$reflectionClassCache) >= self::MAX_REFLECTION_CLASS_CACHE_SIZE) { + self::$reflectionClassCache = []; + } + + self::$reflectionClassCache[$className] = $reflectionClass; + } + + return $reflectionClass; + } +} diff --git a/tests/ReflectorTest.php b/tests/ReflectorTest.php new file mode 100644 index 0000000..4124133 --- /dev/null +++ b/tests/ReflectorTest.php @@ -0,0 +1,365 @@ +getName(), + 'Should return the expected attribute class name.', + ); + } + + public function testClassAttributesWithFilterReturnsMatchingAttributesOnly(): void + { + $attributes = Reflector::classAttributes(ReflectionFixture::class, Label::class); + + self::assertCount( + 1, + $attributes, + 'Should return only matching class attributes for the requested filter.', + ); + } + + public function testFirstPropertyAttributeReturnsFirstMatchingAttributeInstance(): void + { + $attribute = Reflector::firstPropertyAttribute(ReflectionFixture::class, 'name', Label::class); + + if (!$attribute instanceof Label) { + self::fail('Should return an instantiated attribute object when the attribute exists.'); + } + + self::assertSame( + 'primary', + $attribute->value, + 'Should return the first declared repeatable attribute instance.', + ); + } + + public function testFirstPropertyAttributeReturnsNullWhenAttributeDoesNotExist(): void + { + self::assertNull( + Reflector::firstPropertyAttribute(ReflectionFixture::class, 'name', self::class), + "Should return 'null' when the requested attribute is not present.", + ); + } + + public function testHasPropertyReturnsExpectedResult(): void + { + self::assertTrue( + Reflector::hasProperty(ReflectionFixture::class, 'name'), + "Should return 'true' for existing properties.", + ); + self::assertFalse( + Reflector::hasProperty(ReflectionFixture::class, 'missing'), + "Should return 'false' for missing properties.", + ); + } + + public function testPropertiesReturnsFilteredListWhenVisibilityFilterIsProvided(): void + { + $publicProperties = Reflector::properties(ReflectionFixture::class, ReflectionProperty::IS_PUBLIC); + + $publicPropertyNames = []; + + foreach ($publicProperties as $publicProperty) { + $publicPropertyNames[] = $publicProperty->getName(); + } + + self::assertContains( + 'name', + $publicPropertyNames, + 'Should include public properties when a public filter is used.', + ); + self::assertNotContains( + 'hidden', + $publicPropertyNames, + 'Should exclude private properties when a public filter is used.', + ); + } + + public function testPropertyAttributeInstancesReturnsInstantiatedAttributes(): void + { + $instances = Reflector::propertyAttributeInstances(ReflectionFixture::class, 'name', Label::class); + + self::assertCount( + 2, + $instances, + 'Should instantiate all matching repeatable attributes.', + ); + + foreach ($instances as $instance) { + self::assertInstanceOf(Label::class, $instance); + } + + /** @phpstan-var list