Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/Contract/MethodAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace ipl\Stdlib\Contract;

use ReflectionMethod;
use RuntimeException;

/**
* Interface for attributes that target methods
*/
interface MethodAttribute
{
/**
* Apply this attribute to the given method
*
* @param ReflectionMethod $method
* @param object $object
* @param mixed ...$args Additional arguments that may be needed to apply the attribute
*
* @return void
*
* @throws RuntimeException If method invocation fails
*/
public function applyToMethod(ReflectionMethod $method, object $object, mixed &...$args): void;
}
25 changes: 25 additions & 0 deletions src/Contract/PropertyAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace ipl\Stdlib\Contract;

use ReflectionProperty;
use RuntimeException;

/**
* Interface for attributes that target properties
*/
interface PropertyAttribute
{
/**
* Apply this attribute to the given property
*
* @param ReflectionProperty $property
* @param object $object
* @param mixed ...$args Additional arguments that may be needed to apply the attribute
*
* @return void
*
* @throws RuntimeException If the property could not be set
*/
public function applyToProperty(ReflectionProperty $property, object $object, mixed &...$args): void;
}
132 changes: 132 additions & 0 deletions src/Option.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace ipl\Stdlib;

use Attribute;
use Generator;
use InvalidArgumentException;
use ipl\Stdlib\Contract\MethodAttribute;
use ipl\Stdlib\Contract\PropertyAttribute;
use ReflectionMethod;
use ReflectionProperty;
use RuntimeException;
use Throwable;

/**
* Option attribute
*
* Use this class to denote that a property or method should be filled with an option value.
* Options need to be resolved by use of the {@see Option::resolveOptions()} static method.
*/
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
class Option implements PropertyAttribute, MethodAttribute
{
/**
* The name(s) of the option
*
* If multiple names are given, only the first one found is used.
*
* @var ?array<string> 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<string> $names
* @param array<string, mixed> $values
*
* @return Generator<string, mixed>
*
* @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<string, mixed> $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);
}
}
111 changes: 111 additions & 0 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}
}
Loading
Loading