From 1e7d5c9ce52f370961f4447621f67c3ace7ffaa6 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 5 Sep 2025 13:52:40 +0200 Subject: [PATCH 1/5] Introduce interface `MethodAttribute` --- src/Contract/MethodAttribute.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/Contract/MethodAttribute.php diff --git a/src/Contract/MethodAttribute.php b/src/Contract/MethodAttribute.php new file mode 100644 index 0000000..7bb57a9 --- /dev/null +++ b/src/Contract/MethodAttribute.php @@ -0,0 +1,25 @@ + Date: Fri, 5 Sep 2025 13:52:56 +0200 Subject: [PATCH 2/5] Introduce interface `PropertyAttribute` --- src/Contract/PropertyAttribute.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/Contract/PropertyAttribute.php diff --git a/src/Contract/PropertyAttribute.php b/src/Contract/PropertyAttribute.php new file mode 100644 index 0000000..32bc418 --- /dev/null +++ b/src/Contract/PropertyAttribute.php @@ -0,0 +1,25 @@ + Date: Fri, 5 Sep 2025 13:53:17 +0200 Subject: [PATCH 3/5] Introduce function `resolve_attribute` --- src/functions.php | 73 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/functions.php b/src/functions.php index e7f9be0..64bee4b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,9 +2,14 @@ namespace ipl\Stdlib; +use Attribute; use Generator; use InvalidArgumentException; +use ipl\Stdlib\Contract\MethodAttribute; +use ipl\Stdlib\Contract\PropertyAttribute; use IteratorIterator; +use ReflectionAttribute; +use ReflectionClass; use Traversable; use stdClass; @@ -126,3 +131,71 @@ function yield_groups(Traversable $traversable, callable $groupBy): Generator yield $criterion => $group; } + +/** + * Resolve an attribute on an object + * + * This function will resolve and apply the attribute on the given object. Depending on the attribute's target, + * the attribute needs to implement the appropriate interface: + * + * - {@see PropertyAttribute} for properties + * - {@see MethodAttribute} for methods + * + * Supported attribute flags: + * - {@see Attribute::TARGET_PROPERTY} + * - {@see Attribute::TARGET_METHOD} + * - {@see Attribute::IS_REPEATABLE} + * + * @param class-string $attributeClass The attribute class to resolve. Must be an {@see Attribute} + * @param object $object The object to resolve the attribute on + * @param mixed ...$args Optional arguments to pass to the attribute's methods + * + * @return void + * + * @throws InvalidArgumentException If the given class is not a valid attribute + */ +function resolve_attribute(string $attributeClass, object $object, mixed &...$args): void +{ + $attrRef = new ReflectionClass($attributeClass); + $attrAttributes = $attrRef->getAttributes(Attribute::class); + if (empty($attrAttributes)) { + throw new InvalidArgumentException(sprintf('Class %s is not an attribute', $attributeClass)); + } + + $attr = $attrAttributes[0]->newInstance(); + $objectRef = new ReflectionClass($object); + + if ($attr->flags & Attribute::TARGET_PROPERTY) { + if (! $attrRef->implementsInterface(PropertyAttribute::class)) { + throw new InvalidArgumentException(sprintf( + 'Class %s does not implement %s', + $attributeClass, + PropertyAttribute::class + )); + } + + foreach ($objectRef->getProperties() as $property) { + $attributes = $property->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); + foreach ($attributes as $attribute) { + $attribute->newInstance()->applyToProperty($property, $object, ...$args); + } + } + } + + if ($attr->flags & Attribute::TARGET_METHOD) { + if (! $attrRef->implementsInterface(MethodAttribute::class)) { + throw new InvalidArgumentException(sprintf( + 'Class %s does not implement %s', + $attributeClass, + MethodAttribute::class + )); + } + + foreach ($objectRef->getMethods() as $method) { + $attributes = $method->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); + foreach ($attributes as $attribute) { + $attribute->newInstance()->applyToMethod($method, $object, ...$args); + } + } + } +} From 929338cc4eeb7adbb5a67cbcf40ad39817fc3200 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 5 Sep 2025 13:53:46 +0200 Subject: [PATCH 4/5] Introduce attribute `Option` --- src/Option.php | 132 +++++++++++++++++++++++ tests/OptionTest.php | 244 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 src/Option.php create mode 100644 tests/OptionTest.php diff --git a/src/Option.php b/src/Option.php new file mode 100644 index 0000000..e66ee59 --- /dev/null +++ b/src/Option.php @@ -0,0 +1,132 @@ + if null, the property or method name is used + */ + public ?array $name; + + /** @var bool Whether the option is required */ + public bool $required; + + /** + * Create a new option + * + * @param null|string|string[] $name The name(s) of the option; if null, the property or method name is used + * @param bool $required Whether the option is required + */ + public function __construct(null|string|array $name = null, bool $required = false) + { + $this->name = $name !== null ? (array) $name : null; + $this->required = $required; + } + + public function applyToProperty(ReflectionProperty $property, object $object, mixed &...$args): void + { + [&$values] = $args; + $names = $this->name ?? [$property->getName()]; + foreach ($this->extractValue($names, $values) as $name => $value) { + try { + $property->setValue($object, $value); + unset($values[$name]); + + break; + } catch (Throwable $e) { + throw new RuntimeException('Failed to set property ' . $property->getName(), previous: $e); + } + } + } + + public function applyToMethod(ReflectionMethod $method, object $object, mixed &...$args): void + { + [&$values] = $args; + $names = $this->name; + if ($names === null) { + $methodName = $method->getName(); + if (str_starts_with($methodName, 'set')) { + $methodName = lcfirst(substr($methodName, 3)); + } + + $names = [$methodName]; + } + + foreach ($this->extractValue($names, $values) as $name => $value) { + try { + $method->invoke($object, $value); + unset($values[$name]); + + break; + } catch (Throwable $e) { + throw new RuntimeException('Failed to invoke method ' . $method->getName(), previous: $e); + } + } + } + + /** + * Find and yield a value from the given array + * + * @param array $names + * @param array $values + * + * @return Generator + * + * @throws InvalidArgumentException If a required option is missing or null + */ + protected function extractValue(array $names, array $values): Generator + { + // Using a generator here to distinguish between an actual returned (yield) value and nothing at all (exhaust) + foreach ($names as $name) { + if (array_key_exists($name, $values)) { + if ($this->required && $values[$name] === null) { + throw new InvalidArgumentException("Required option '$name' must not be null"); + } + + yield $name => $values[$name]; + } + } + + if ($this->required) { + throw new InvalidArgumentException("Missing required option '" . $names[0] . "'"); + } + } + + /** + * Resolve and assign values to the given target + * + * @param object $target The target to assign the values to + * @param array $values The values to assign + * + * @return void + * + * @throws InvalidArgumentException If a required option is missing or null + * @throws RuntimeException If method invocation fails or the property could not be set + */ + final public static function resolveOptions(object $target, array &$values): void + { + resolve_attribute(self::class, $target, $values); + } +} diff --git a/tests/OptionTest.php b/tests/OptionTest.php new file mode 100644 index 0000000..a43d9b1 --- /dev/null +++ b/tests/OptionTest.php @@ -0,0 +1,244 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Missing required option 'foo'"); + + $values = []; + Option::resolveOptions($object, $values); + } + + public function testRequiredOptionDoesNotAcceptNull(): void + { + $object = new class { + #[Option(required: true)] + public string $foo; + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Required option 'foo' must not be null"); + + $values = ['foo' => null]; + Option::resolveOptions($object, $values); + } + + public function testRequiredOption(): void + { + $object = new class { + #[Option(required: true)] + public string $foo; + }; + + $values = ['foo' => 'bar']; + Option::resolveOptions($object, $values); + + $this->assertSame('bar', $object->foo); + $this->assertEmpty($values); + } + + public function testOptionalOption(): void + { + $object = new class { + #[Option] + public string $foo = ''; + }; + + $values = []; + Option::resolveOptions($object, $values); + + $this->assertSame('', $object->foo); + } + + public function testNamedOption(): void + { + $object = new class { + #[Option(name: 'bar')] + public string $foo = ''; + }; + + $values = ['bar' => 'baz']; + Option::resolveOptions($object, $values); + + $this->assertSame('baz', $object->foo); + $this->assertEmpty($values); + } + + public function testNamedRequiredOption(): void + { + $object = new class { + #[Option(name: 'bar', required: true)] + public string $foo; + }; + + $values = ['bar' => 'baz']; + Option::resolveOptions($object, $values); + + $this->assertSame('baz', $object->foo); + $this->assertEmpty($values); + } + + public function testOptionWithMultipleNames(): void + { + $object = new class { + #[Option(name: ['foo', 'bar'])] + public string $baz = ''; + }; + + $values = ['foo' => 'baz', 'bar' => 'oof']; + Option::resolveOptions($object, $values); + + $this->assertSame('baz', $object->baz); + $this->assertSame(['bar' => 'oof'], $values); + } + + public function testMethodAnnotation(): void + { + $object = new class { + public string $foo = ''; + + public string $bar = ''; + + #[Option] + public function setFoo(string $value): void + { + $this->foo = $value; + } + + #[Option] + public function bar(string $value): void + { + $this->bar = $value; + } + }; + + $values = ['foo' => 'bar', 'bar' => 'baz']; + Option::resolveOptions($object, $values); + + $this->assertSame('bar', $object->foo); + $this->assertSame('baz', $object->bar); + $this->assertEmpty($values); + } + + public function testErroneousMethodAnnotation(): void + { + $object = new class { + #[Option] + public function setFoo(string $value, string $invalid): void + { + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to invoke method setFoo'); + + $values = ['foo' => 'bar']; + Option::resolveOptions($object, $values); + } + + public function testErroneousPropertyAnnotation(): void + { + $object = new class { + #[Option] + public array $foo = []; + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to set property foo'); + + $values = ['foo' => 'bar']; + Option::resolveOptions($object, $values); + } + + public function testPropertyCasting(): void + { + $object = new class { + #[Option] + public string $string = ''; + + #[Option] + public int $int = 0; + + #[Option] + public float $float = 0.0; + + #[Option] + public bool $bool = false; + }; + + $values = [ + 'string' => 123, + 'int' => '123', + 'float' => '123.456', + 'bool' => '1' + ]; + Option::resolveOptions($object, $values); + + $this->assertSame('123', $object->string); + $this->assertSame(123, $object->int); + $this->assertSame(123.456, $object->float); + $this->assertSame(true, $object->bool); + $this->assertEmpty($values); + } + + public function testMethodParameterCasting(): void + { + $object = new class { + public string $string = ''; + public int $int = 0; + public float $float = 0.0; + public bool $bool = false; + + #[Option] + public function setString(string $value): void + { + $this->string = $value; + } + + #[Option] + public function setInt(int $value): void + { + $this->int = $value; + } + + #[Option] + public function setFloat(float $value): void + { + $this->float = $value; + } + + #[Option] + public function setBool(bool $value): void + { + $this->bool = $value; + } + }; + + $values = [ + 'string' => 123, + 'int' => '123', + 'float' => '123.456', + 'bool' => '1' + ]; + Option::resolveOptions($object, $values); + + $this->assertSame('123', $object->string); + $this->assertSame(123, $object->int); + $this->assertSame(123.456, $object->float); + $this->assertSame(true, $object->bool); + $this->assertEmpty($values); + } +} From a8442520aee0f0d5b45f5570fbd84cdf0d9322ac Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 8 Sep 2025 13:57:57 +0200 Subject: [PATCH 5/5] resolve_attribute(): Introduce cache It's about 40% faster, measured using phpbench. --- src/functions.php | 84 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/src/functions.php b/src/functions.php index 64bee4b..b7fa214 100644 --- a/src/functions.php +++ b/src/functions.php @@ -10,6 +10,8 @@ use IteratorIterator; use ReflectionAttribute; use ReflectionClass; +use ReflectionMethod; +use ReflectionProperty; use Traversable; use stdClass; @@ -156,17 +158,21 @@ function yield_groups(Traversable $traversable, callable $groupBy): Generator */ function resolve_attribute(string $attributeClass, object $object, mixed &...$args): void { - $attrRef = new ReflectionClass($attributeClass); - $attrAttributes = $attrRef->getAttributes(Attribute::class); - if (empty($attrAttributes)) { - throw new InvalidArgumentException(sprintf('Class %s is not an attribute', $attributeClass)); - } + static $cache = []; + if (! isset($cache[$attributeClass])) { + $attrRef = new ReflectionClass($attributeClass); + $attrAttributes = $attrRef->getAttributes(Attribute::class); + if (empty($attrAttributes)) { + throw new InvalidArgumentException(sprintf('Class %s is not an attribute', $attributeClass)); + } - $attr = $attrAttributes[0]->newInstance(); - $objectRef = new ReflectionClass($object); + $attr = $attrAttributes[0]->newInstance(); + $supportedFlags = [ + $attr->flags & Attribute::TARGET_PROPERTY, + $attr->flags & Attribute::TARGET_METHOD + ]; - if ($attr->flags & Attribute::TARGET_PROPERTY) { - if (! $attrRef->implementsInterface(PropertyAttribute::class)) { + if ($supportedFlags[0] && ! $attrRef->implementsInterface(PropertyAttribute::class)) { throw new InvalidArgumentException(sprintf( 'Class %s does not implement %s', $attributeClass, @@ -174,16 +180,7 @@ function resolve_attribute(string $attributeClass, object $object, mixed &...$ar )); } - foreach ($objectRef->getProperties() as $property) { - $attributes = $property->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - foreach ($attributes as $attribute) { - $attribute->newInstance()->applyToProperty($property, $object, ...$args); - } - } - } - - if ($attr->flags & Attribute::TARGET_METHOD) { - if (! $attrRef->implementsInterface(MethodAttribute::class)) { + if ($supportedFlags[1] && ! $attrRef->implementsInterface(MethodAttribute::class)) { throw new InvalidArgumentException(sprintf( 'Class %s does not implement %s', $attributeClass, @@ -191,11 +188,52 @@ function resolve_attribute(string $attributeClass, object $object, mixed &...$ar )); } - foreach ($objectRef->getMethods() as $method) { - $attributes = $method->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - foreach ($attributes as $attribute) { - $attribute->newInstance()->applyToMethod($method, $object, ...$args); + $cache[$attributeClass] = $supportedFlags; + } else { + $supportedFlags = $cache[$attributeClass]; + } + + if (! isset($cache[$object::class])) { + $objectRef = new ReflectionClass($object); + $annotations = [ + 'properties' => [], + 'methods' => [] + ]; + + if ($supportedFlags[0]) { + foreach ($objectRef->getProperties() as $property) { + $attributes = $property->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); + foreach ($attributes as $attribute) { + $annotations['properties'][$property->getName()][] = $attribute->newInstance(); + } + } + } + + if ($supportedFlags[1]) { + foreach ($objectRef->getMethods() as $method) { + $attributes = $method->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); + foreach ($attributes as $attribute) { + $annotations['methods'][$method->getName()][] = $attribute->newInstance(); + } } } + + $cache[$object::class] = $annotations; + } else { + $annotations = $cache[$object::class]; + } + + foreach ($annotations['properties'] as $name => $attributes) { + $property = new ReflectionProperty($object, $name); + foreach ($attributes as $attribute) { + $attribute->applyToProperty($property, $object, ...$args); + } + } + + foreach ($annotations['methods'] as $name => $attributes) { + $method = new ReflectionMethod($object, $name); + foreach ($attributes as $attribute) { + $attribute->applyToMethod($method, $object, ...$args); + } } }