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/README.md b/README.md index aa393bb..3f15918 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@

Small, focused helpers for common PHP tasks
- Convert word casing, generate passwords, and list time zones with predictable output. + Convert word casing, inspect metadata, generate passwords, and list time zones with predictable output.

## Features @@ -35,7 +35,7 @@ ### Installation ```bash -composer require php-forge/helper:^0.1 +composer require php-forge/helper:^0.2 ``` ### Quick start @@ -115,12 +115,32 @@ $timezones = TimeZoneList::all(); // [['timezone' => 'Pacific/Midway', 'name' => 'Pacific/Midway (UTC -11:00)', 'offset' => -39600], ...] ``` +#### Inspect class metadata with Reflector + +```php + 'Pacific/Midway', 'name' => 'Pacific/Midway (UTC -11:00)', 'offset' => -39600], ...] +``` + +## Inspect class metadata with Reflector + +```php + 400) { + Reflector::clearCache(); +} +``` + +## Next steps + +- ๐Ÿ“š [Installation guide](installation.md) +- ๐Ÿงช [Testing guide](testing.md) +- ๐Ÿ› ๏ธ [Development guide](development.md) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..8561bd6 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,40 @@ +# Installation guide + +## System requirements + +- [`PHP`](https://www.php.net/downloads) 8.1 or higher. +- [`Composer`](https://getcomposer.org/download/) for dependency management. + +## Installation + +### Method 1: Using [Composer](https://getcomposer.org/download/) (recommended) + +Install the package. + +```bash +composer require php-forge/helper:^0.2 +``` + +### Method 2: Manual installation + +Add to your `composer.json`. + +```json +{ + "require": { + "php-forge/helper": "^0.2" + } +} +``` + +Then run. + +```bash +composer update +``` + +## Next steps + +- ๐Ÿ’ก [Usage examples](examples.md) +- ๐Ÿงช [Testing guide](testing.md) +- ๐Ÿ› ๏ธ [Development guide](development.md) diff --git a/docs/svgs/features-mobile.svg b/docs/svgs/features-mobile.svg index 3bc6ccf..185274f 100644 --- a/docs/svgs/features-mobile.svg +++ b/docs/svgs/features-mobile.svg @@ -1,4 +1,4 @@ - +
+
+

Bounded Metadata Cache

+

Reuse reflection metadata with bounded FIFO eviction and explicit cache lifecycle control for long-running workers.

+

Password Generation

Generate secure passwords with lowercase, uppercase, digits, and special characters.

+
+

Reflection Metadata

+

Inspect class short names, properties, attributes, and property types including union, intersection, and DNF declarations.

+

Predictable Output

Helpers are deterministic in shape and formatting, making them safe for APIs, forms, and data pipelines.

diff --git a/docs/svgs/features.svg b/docs/svgs/features.svg index ff6f26a..ae75e5c 100644 --- a/docs/svgs/features.svg +++ b/docs/svgs/features.svg @@ -1,4 +1,4 @@ - +
+
+

Bounded Metadata Cache

+

Reuse reflection metadata with bounded FIFO eviction and explicit cache lifecycle control for long-running workers.

+

Password Generation

Generate secure passwords with lowercase, uppercase, digits, and special characters.

+
+

Reflection Metadata

+

Inspect class short names, properties, attributes, and property types including union, intersection, and DNF declarations.

+

Predictable Output

Helpers are deterministic in shape and formatting, making them safe for APIs, forms, and data pipelines.

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..c05e4ed --- /dev/null +++ b/src/Reflector.php @@ -0,0 +1,389 @@ +> + */ + private static array $reflectionClassCache = []; + + /** + * Returns the current number of cached reflection classes. + * + * Usage example: + * ```php + * $cacheSize = \PHPForge\Helper\Reflector::cacheSize(); + * ``` + * + * @return int Number of cached reflection classes. + */ + public static function cacheSize(): int + { + return count(self::$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); + } + + /** + * Clears the internal reflection class cache. + * + * Usage example: + * ```php + * \PHPForge\Helper\Reflector::clearCache(); + * ``` + */ + public static function clearCache(): void + { + self::$reflectionClassCache = []; + } + + /** + * 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($filter); + } + + return $reflectionClass->getProperties(); + } + + /** + * 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' && $typeName !== 'mixed') { + return [$typeName, 'null']; + } + + return [$typeName]; + } + + if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) { + return self::collectNestedTypeNames($type); + } + + 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(); + } + + /** + * Collects all named type names from nested union/intersection type structures. + * + * @return array Type names collected from the nested type structure. + * + * @phpstan-return list + */ + private static function collectNestedTypeNames(ReflectionIntersectionType|ReflectionUnionType $type): array + { + $typeNames = []; + + $queue = $type->getTypes(); + + while ($queue !== []) { + $nestedType = array_shift($queue); + + if ($nestedType instanceof ReflectionNamedType) { + $typeNames[] = $nestedType->getName(); + + continue; + } + + if ($nestedType instanceof ReflectionUnionType || $nestedType instanceof ReflectionIntersectionType) { + foreach ($nestedType->getTypes() as $innerType) { + $queue[] = $innerType; + } + } + } + + return $typeNames; + } + + /** + * 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) + && !interface_exists($className) + && !trait_exists($className) + && !enum_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) { + array_shift(self::$reflectionClassCache); + } + + self::$reflectionClassCache[$className] = $reflectionClass; + } + + return $reflectionClass; + } +} diff --git a/tests/ReflectorTest.php b/tests/ReflectorTest.php new file mode 100644 index 0000000..f150288 --- /dev/null +++ b/tests/ReflectorTest.php @@ -0,0 +1,509 @@ +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 testClearCacheEmptiesReflectionClassCache(): void + { + Reflector::clearCache(); + Reflector::shortName(ReflectionFixture::class); + + self::assertGreaterThan( + 0, + Reflector::cacheSize(), + 'Should populate cache after reflection usage.', + ); + + Reflector::clearCache(); + + self::assertSame( + 0, + Reflector::cacheSize(), + 'Should clear all cached reflection classes.', + ); + } + + 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 testPropertiesReturnsAllPropertiesWhenFilterIsNull(): void + { + $allProperties = Reflector::properties(ReflectionFixture::class); + $allPropertyNames = []; + + foreach ($allProperties as $property) { + $allPropertyNames[] = $property->getName(); + } + + self::assertContains( + 'name', + $allPropertyNames, + 'Should include public properties when no filter is provided.', + ); + self::assertContains( + 'hidden', + $allPropertyNames, + 'Should include private properties when no filter is provided.', + ); + } + + 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