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 @@ + 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/src/functions.php b/src/functions.php index e7f9be0..b7fa214 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,9 +2,16 @@ 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 ReflectionMethod; +use ReflectionProperty; use Traversable; use stdClass; @@ -126,3 +133,107 @@ 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 +{ + 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(); + $supportedFlags = [ + $attr->flags & Attribute::TARGET_PROPERTY, + $attr->flags & Attribute::TARGET_METHOD + ]; + + if ($supportedFlags[0] && ! $attrRef->implementsInterface(PropertyAttribute::class)) { + throw new InvalidArgumentException(sprintf( + 'Class %s does not implement %s', + $attributeClass, + PropertyAttribute::class + )); + } + + if ($supportedFlags[1] && ! $attrRef->implementsInterface(MethodAttribute::class)) { + throw new InvalidArgumentException(sprintf( + 'Class %s does not implement %s', + $attributeClass, + MethodAttribute::class + )); + } + + $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); + } + } +} 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); + } +}