Skip to content

Commit 9f95631

Browse files
committed
Introduce attribute Option
1 parent 4145e1f commit 9f95631

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed

src/Option.php

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
namespace ipl\Stdlib;
4+
5+
use Attribute;
6+
use Generator;
7+
use InvalidArgumentException;
8+
use ipl\Stdlib\Contract\MethodAttribute;
9+
use ipl\Stdlib\Contract\PropertyAttribute;
10+
use ReflectionMethod;
11+
use ReflectionProperty;
12+
use RuntimeException;
13+
use Throwable;
14+
15+
/**
16+
* Option attribute
17+
*
18+
* Use this class to denote that a property or method should be filled with an option value.
19+
* Options need to be resolved by use of the {@see Option::resolveOptions()} static method.
20+
*/
21+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
22+
class Option implements PropertyAttribute, MethodAttribute
23+
{
24+
/**
25+
* The name(s) of the option
26+
*
27+
* If multiple names are given, only the first one found is used.
28+
*
29+
* @var ?array<string> if null, the property or method name is used
30+
*/
31+
public ?array $name;
32+
33+
/** @var bool Whether the option is required */
34+
public bool $required;
35+
36+
/**
37+
* Create a new option
38+
*
39+
* @param null|string|string[] $name The name(s) of the option; if null, the property or method name is used
40+
* @param bool $required Whether the option is required
41+
*/
42+
public function __construct(null|string|array $name = null, bool $required = false)
43+
{
44+
$this->name = $name !== null ? (array) $name : null;
45+
$this->required = $required;
46+
}
47+
48+
public function applyToProperty(ReflectionProperty $property, object $object, mixed &...$args): void
49+
{
50+
[&$values] = $args;
51+
$names = $this->name ?? [$property->getName()];
52+
foreach ($this->extractValue($names, $values) as $name => $value) {
53+
$property->setValue($object, $value);
54+
unset($values[$name]);
55+
56+
break;
57+
}
58+
}
59+
60+
public function applyToMethod(ReflectionMethod $method, object $object, mixed &...$args): void
61+
{
62+
[&$values] = $args;
63+
$names = $this->name;
64+
if ($names === null) {
65+
$methodName = $method->getName();
66+
if (str_starts_with($methodName, 'set')) {
67+
$methodName = lcfirst(substr($methodName, 3));
68+
}
69+
70+
$names = [$methodName];
71+
}
72+
73+
foreach ($this->extractValue($names, $values) as $name => $value) {
74+
try {
75+
$method->invoke($object, $value);
76+
unset($values[$name]);
77+
78+
break;
79+
} catch (Throwable $e) {
80+
throw new RuntimeException('Failed to invoke method ' . $method->getName(), previous: $e);
81+
}
82+
}
83+
}
84+
85+
/**
86+
* Find and yield a value from the given array
87+
*
88+
* @param array<string> $names
89+
* @param array<string, mixed> $values
90+
*
91+
* @return Generator<string, mixed>
92+
*
93+
* @throws InvalidArgumentException If a required option is missing or null
94+
*/
95+
protected function extractValue(array $names, array $values): Generator
96+
{
97+
// Using a generator here to distinguish between an actual returned (yield) value and nothing at all (exhaust)
98+
foreach ($names as $name) {
99+
if (array_key_exists($name, $values)) {
100+
if ($this->required && $values[$name] === null) {
101+
throw new InvalidArgumentException("Required option '$name' must not be null");
102+
}
103+
104+
yield $name => $values[$name];
105+
}
106+
}
107+
108+
if ($this->required) {
109+
throw new InvalidArgumentException("Missing required option '" . $names[0] . "'");
110+
}
111+
}
112+
113+
/**
114+
* Resolve and assign values to the given target
115+
*
116+
* @param object $target The target to assign the values to
117+
* @param array<string, mixed> $values The values to assign
118+
*
119+
* @return void
120+
*
121+
* @throws InvalidArgumentException If a required option is missing or null
122+
* @throws RuntimeException If method invocation fails
123+
*/
124+
final public static function resolveOptions(object $target, array &$values): void
125+
{
126+
resolve_attribute(self::class, $target, $values);
127+
}
128+
}

tests/OptionTest.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
namespace ipl\Tests\Stdlib;
4+
5+
use InvalidArgumentException;
6+
use ipl\Stdlib\Option;
7+
use RuntimeException;
8+
9+
class OptionTest extends TestCase
10+
{
11+
public function testMissingOption(): void
12+
{
13+
$object = new class {
14+
#[Option(required: true)]
15+
public string $foo;
16+
};
17+
18+
$this->expectException(InvalidArgumentException::class);
19+
$this->expectExceptionMessage("Missing required option 'foo'");
20+
21+
$values = [];
22+
Option::resolveOptions($object, $values);
23+
}
24+
25+
public function testRequiredOptionDoesNotAcceptNull(): void
26+
{
27+
$object = new class {
28+
#[Option(required: true)]
29+
public string $foo;
30+
};
31+
32+
$this->expectException(InvalidArgumentException::class);
33+
$this->expectExceptionMessage("Required option 'foo' must not be null");
34+
35+
$values = ['foo' => null];
36+
Option::resolveOptions($object, $values);
37+
}
38+
39+
public function testRequiredOption(): void
40+
{
41+
$object = new class {
42+
#[Option(required: true)]
43+
public string $foo;
44+
};
45+
46+
$values = ['foo' => 'bar'];
47+
Option::resolveOptions($object, $values);
48+
49+
$this->assertSame('bar', $object->foo);
50+
$this->assertEmpty($values);
51+
}
52+
53+
public function testOptionalOption(): void
54+
{
55+
$object = new class {
56+
#[Option]
57+
public string $foo = '';
58+
};
59+
60+
$values = [];
61+
Option::resolveOptions($object, $values);
62+
63+
$this->assertSame('', $object->foo);
64+
}
65+
66+
public function testNamedOption(): void
67+
{
68+
$object = new class {
69+
#[Option(name: 'bar')]
70+
public string $foo = '';
71+
};
72+
73+
$values = ['bar' => 'baz'];
74+
Option::resolveOptions($object, $values);
75+
76+
$this->assertSame('baz', $object->foo);
77+
$this->assertEmpty($values);
78+
}
79+
80+
public function testNamedRequiredOption(): void
81+
{
82+
$object = new class {
83+
#[Option(name: 'bar', required: true)]
84+
public string $foo;
85+
};
86+
87+
$values = ['bar' => 'baz'];
88+
Option::resolveOptions($object, $values);
89+
90+
$this->assertSame('baz', $object->foo);
91+
$this->assertEmpty($values);
92+
}
93+
94+
public function testOptionWithMultipleNames(): void
95+
{
96+
$object = new class {
97+
#[Option(name: ['foo', 'bar'])]
98+
public string $baz = '';
99+
};
100+
101+
$values = ['foo' => 'baz', 'bar' => 'oof'];
102+
Option::resolveOptions($object, $values);
103+
104+
$this->assertSame('baz', $object->baz);
105+
$this->assertSame(['bar' => 'oof'], $values);
106+
}
107+
108+
public function testMethodAnnotation(): void
109+
{
110+
$object = new class {
111+
public string $foo = '';
112+
113+
public string $bar = '';
114+
115+
#[Option]
116+
public function setFoo(string $value): void
117+
{
118+
$this->foo = $value;
119+
}
120+
121+
#[Option]
122+
public function bar(string $value): void
123+
{
124+
$this->bar = $value;
125+
}
126+
};
127+
128+
$values = ['foo' => 'bar', 'bar' => 'baz'];
129+
Option::resolveOptions($object, $values);
130+
131+
$this->assertSame('bar', $object->foo);
132+
$this->assertSame('baz', $object->bar);
133+
$this->assertEmpty($values);
134+
}
135+
136+
public function testErroneousMethodAnnotation(): void
137+
{
138+
$object = new class {
139+
#[Option]
140+
public function setFoo(string $value, string $invalid): void
141+
{
142+
}
143+
};
144+
145+
$this->expectException(RuntimeException::class);
146+
$this->expectExceptionMessage('Failed to invoke method setFoo');
147+
148+
$values = ['foo' => 'bar'];
149+
Option::resolveOptions($object, $values);
150+
}
151+
}

0 commit comments

Comments
 (0)