Skip to content

Commit a788bf0

Browse files
authored
Merge pull request #1 from ray-di/array
Add array and ArrayObject support for Input attribute
2 parents 03fc745 + 3e298aa commit a788bf0

File tree

9 files changed

+594
-13
lines changed

9 files changed

+594
-13
lines changed

.claude/settings.local.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
"allow": [
44
"Bash(composer test:*)",
55
"Bash(composer install:*)",
6-
"Bash(composer:*)"
6+
"Bash(composer:*)",
7+
"Bash(./vendor/bin/phpunit:*)",
8+
"Bash(./vendor/bin/phpmd:*)",
9+
"Bash(vendor/bin/phpunit:*)",
10+
"Bash(XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --coverage-filter src/)"
711
],
812
"deny": []
913
}

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,74 @@ echo $todo->title; // Buy milk
120120
echo $todo->assignee->name; // John
121121
```
122122

123+
### Array Support
124+
125+
Ray.InputQuery supports arrays and ArrayObject collections with the `item` parameter:
126+
127+
```php
128+
use Ray\InputQuery\Attribute\Input;
129+
130+
final class UserInput
131+
{
132+
public function __construct(
133+
#[Input] public readonly string $id,
134+
#[Input] public readonly string $name
135+
) {}
136+
}
137+
138+
final class UserListController
139+
{
140+
public function updateUsers(
141+
#[Input(item: UserInput::class)] array $users // Array of UserInput objects
142+
) {
143+
foreach ($users as $user) {
144+
echo $user->name; // Each element is a UserInput instance
145+
}
146+
}
147+
148+
public function processUsers(
149+
#[Input(item: UserInput::class)] ArrayObject $users // ArrayObject collection
150+
) {
151+
// $users is an ArrayObject containing UserInput instances
152+
}
153+
}
154+
```
155+
156+
Query data format for arrays:
157+
158+
```php
159+
$data = [
160+
'users' => [
161+
['id' => '1', 'name' => 'John'],
162+
['id' => '2', 'name' => 'Jane'],
163+
['id' => '3', 'name' => 'Bob']
164+
]
165+
];
166+
167+
$args = $inputQuery->getArguments($method, $data);
168+
// $args[0] will be an array of UserInput objects
169+
```
170+
171+
**ArrayObject Inheritance Support:**
172+
173+
Custom ArrayObject subclasses are also supported:
174+
175+
```php
176+
final class UserCollection extends ArrayObject
177+
{
178+
public function getFirst(): ?UserInput
179+
{
180+
return $this[0] ?? null;
181+
}
182+
}
183+
184+
public function handleUsers(
185+
#[Input(item: UserInput::class)] UserCollection $users
186+
) {
187+
$firstUser = $users->getFirst(); // Custom method available
188+
}
189+
```
190+
123191
### Mixed with Dependency Injection
124192

125193
Parameters without the `#[Input]` attribute are resolved via dependency injection:

demo/ArrayDemo.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require dirname(__DIR__) . '/vendor/autoload.php';
6+
7+
use Ray\Di\Injector;
8+
use Ray\InputQuery\Attribute\Input;
9+
use Ray\InputQuery\InputQuery;
10+
11+
// User input class
12+
final class User
13+
{
14+
public function __construct(
15+
#[Input]
16+
public readonly string $id,
17+
#[Input]
18+
public readonly string $name
19+
) {
20+
}
21+
}
22+
23+
// Controller with array handling
24+
final class UserController
25+
{
26+
public function listUsers(
27+
#[Input(item: User::class)]
28+
array $users
29+
): void {
30+
echo "Array of users:\n";
31+
foreach ($users as $index => $user) {
32+
echo " [$index] ID: {$user->id}, Name: {$user->name}\n";
33+
}
34+
}
35+
36+
public function listUsersAsArrayObject(
37+
#[Input(item: User::class)]
38+
ArrayObject $users
39+
): void {
40+
echo "\nArrayObject of users:\n";
41+
foreach ($users as $index => $user) {
42+
echo " [$index] ID: {$user->id}, Name: {$user->name}\n";
43+
}
44+
}
45+
}
46+
47+
// Demo
48+
$injector = new Injector();
49+
$inputQuery = new InputQuery($injector);
50+
51+
// Sample query data (like from $_POST)
52+
$query = [
53+
'users' => [
54+
['id' => '1', 'name' => 'jingu'],
55+
['id' => '2', 'name' => 'horikawa'],
56+
['id' => '3', 'name' => 'tanaka']
57+
]
58+
];
59+
60+
$controller = new UserController();
61+
62+
// Array example
63+
$method = new ReflectionMethod($controller, 'listUsers');
64+
$args = $inputQuery->getArguments($method, $query);
65+
$controller->listUsers(...$args);
66+
67+
// ArrayObject example
68+
$method = new ReflectionMethod($controller, 'listUsersAsArrayObject');
69+
$args = $inputQuery->getArguments($method, $query);
70+
$controller->listUsersAsArrayObject(...$args);

src/Attribute/Input.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@
99
#[Attribute(Attribute::TARGET_PARAMETER)]
1010
final class Input
1111
{
12+
public function __construct(
13+
public readonly string|null $item = null,
14+
) {
15+
}
1216
}

src/InputQuery.php

Lines changed: 138 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,32 @@
44

55
namespace Ray\InputQuery;
66

7+
use ArrayObject;
78
use InvalidArgumentException;
89
use Override;
910
use Ray\Di\Di\Named;
1011
use Ray\Di\Di\Qualifier;
1112
use Ray\Di\Exception\Unbound;
1213
use Ray\Di\InjectorInterface;
1314
use Ray\InputQuery\Attribute\Input;
15+
use ReflectionAttribute;
1416
use ReflectionClass;
1517
use ReflectionMethod;
1618
use ReflectionNamedType;
1719
use ReflectionParameter;
1820

21+
use function array_key_exists;
1922
use function assert;
2023
use function class_exists;
24+
use function gettype;
25+
use function is_array;
2126
use function is_bool;
2227
use function is_float;
2328
use function is_int;
2429
use function is_numeric;
2530
use function is_scalar;
2631
use function is_string;
32+
use function is_subclass_of;
2733
use function lcfirst;
2834
use function sprintf;
2935
use function str_replace;
@@ -91,7 +97,15 @@ private function resolveParameter(ReflectionParameter $param, array $query): mix
9197
return $this->resolveFromDI($param);
9298
}
9399

94-
// Has #[Input] attribute - get from query
100+
return $this->resolveInputParameter($param, $query, $inputAttributes);
101+
}
102+
103+
/**
104+
* @param array<string, mixed> $query
105+
* @param array<ReflectionAttribute<Input>> $inputAttributes
106+
*/
107+
private function resolveInputParameter(ReflectionParameter $param, array $query, array $inputAttributes): mixed
108+
{
95109
$type = $param->getType();
96110
$paramName = $param->getName();
97111

@@ -100,27 +114,99 @@ private function resolveParameter(ReflectionParameter $param, array $query): mix
100114
}
101115

102116
if ($type->isBuiltin()) {
103-
// Scalar type with #[Input]
104-
/** @psalm-suppress MixedAssignment $value */
105-
$value = $query[$paramName] ?? $this->getDefaultValue($param);
117+
return $this->resolveBuiltinType($param, $query, $inputAttributes, $type);
118+
}
119+
120+
return $this->resolveObjectType($param, $query, $inputAttributes, $type);
121+
}
106122

107-
return $this->convertScalar($value, $type);
123+
/**
124+
* @param array<string, mixed> $query
125+
* @param array<ReflectionAttribute<Input>> $inputAttributes
126+
*/
127+
private function resolveBuiltinType(ReflectionParameter $param, array $query, array $inputAttributes, ReflectionNamedType $type): mixed
128+
{
129+
$paramName = $param->getName();
130+
131+
if ($type->getName() === 'array') {
132+
$inputAttribute = $inputAttributes[0]->newInstance();
133+
if ($inputAttribute->item !== null) {
134+
assert(class_exists($inputAttribute->item));
135+
$itemClass = $inputAttribute->item;
136+
137+
/** @var class-string<T> $itemClass */
138+
return $this->createArrayOfInputs($paramName, $query, $itemClass);
139+
}
108140
}
109141

110-
// Object type with #[Input] - create nested
142+
// Scalar type with #[Input]
143+
/** @psalm-suppress MixedAssignment $value */
144+
$value = $query[$paramName] ?? $this->getDefaultValue($param);
145+
146+
return $this->convertScalar($value, $type);
147+
}
148+
149+
/**
150+
* @param array<string, mixed> $query
151+
* @param array<ReflectionAttribute<Input>> $inputAttributes
152+
*/
153+
private function resolveObjectType(ReflectionParameter $param, array $query, array $inputAttributes, ReflectionNamedType $type): mixed
154+
{
155+
$paramName = $param->getName();
156+
$className = $type->getName();
157+
158+
// Check for ArrayObject types with item specification
159+
$arrayObjectResult = $this->resolveArrayObjectType($paramName, $query, $inputAttributes, $className);
160+
if ($arrayObjectResult !== null) {
161+
return $arrayObjectResult;
162+
}
163+
164+
// Regular object type with #[Input] - create nested
111165
$nestedQuery = $this->extractNestedQuery($paramName, $query);
112166

113167
// If no nested keys found, try using the entire query
114-
// This handles cases like controller method parameters
115168
if (empty($nestedQuery)) {
116169
$nestedQuery = $query;
117170
}
118171

119-
$class = $type->getName();
120-
assert(class_exists($class));
172+
assert(class_exists($className));
173+
174+
/** @var class-string<T> $className */
175+
return $this->create($className, $nestedQuery);
176+
}
177+
178+
/**
179+
* @param array<string, mixed> $query
180+
* @param array<ReflectionAttribute<Input>> $inputAttributes
181+
*/
182+
private function resolveArrayObjectType(string $paramName, array $query, array $inputAttributes, string $className): mixed
183+
{
184+
$isArrayObjectSubclass = class_exists($className) && is_subclass_of($className, ArrayObject::class);
185+
$isArrayObject = $className === ArrayObject::class;
186+
187+
if (! $isArrayObjectSubclass && ! $isArrayObject) {
188+
return null;
189+
}
121190

122-
/** @var class-string<T> $class */
123-
return $this->create($class, $nestedQuery);
191+
$inputAttribute = $inputAttributes[0]->newInstance();
192+
if ($inputAttribute->item === null) {
193+
return null;
194+
}
195+
196+
assert(class_exists($inputAttribute->item));
197+
/** @var class-string<T> $itemClass */
198+
$itemClass = $inputAttribute->item;
199+
$array = $this->createArrayOfInputs($paramName, $query, $itemClass);
200+
201+
if ($isArrayObject) {
202+
return new ArrayObject($array);
203+
}
204+
205+
assert(class_exists($className));
206+
/** @var class-string $className */
207+
$reflectionClass = new ReflectionClass($className);
208+
209+
return $reflectionClass->newInstance($array);
124210
}
125211

126212
private function resolveFromDI(ReflectionParameter $param): mixed
@@ -245,4 +331,45 @@ private function toCamelCase(string $string): string
245331

246332
return lcfirst($string);
247333
}
334+
335+
/**
336+
* @param array<string, mixed> $query
337+
* @param class-string<T> $itemClass
338+
*
339+
* @return array<array-key, T>
340+
*/
341+
private function createArrayOfInputs(string $paramName, array $query, string $itemClass): array
342+
{
343+
if (! array_key_exists($paramName, $query)) {
344+
return [];
345+
}
346+
347+
/** @var mixed $arrayData */
348+
$arrayData = $query[$paramName];
349+
350+
if (! is_array($arrayData)) {
351+
return [];
352+
}
353+
354+
$result = [];
355+
/** @var mixed $itemData */
356+
foreach ($arrayData as $key => $itemData) {
357+
if (! is_array($itemData)) {
358+
throw new InvalidArgumentException(
359+
sprintf(
360+
'Expected array for item at key "%s", got %s.',
361+
$key,
362+
gettype($itemData),
363+
),
364+
);
365+
}
366+
367+
// Query parameters from HTTP requests have string keys
368+
/** @psalm-var array<string, mixed> $itemData */
369+
/** @phpstan-var array<string, mixed> $itemData */
370+
$result[$key] = $this->create($itemClass, $itemData);
371+
}
372+
373+
return $result;
374+
}
248375
}

0 commit comments

Comments
 (0)