Skip to content
Merged
6 changes: 5 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
"allow": [
"Bash(composer test:*)",
"Bash(composer install:*)",
"Bash(composer:*)"
"Bash(composer:*)",
"Bash(./vendor/bin/phpunit:*)",
"Bash(./vendor/bin/phpmd:*)",
"Bash(vendor/bin/phpunit:*)",
"Bash(XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --coverage-filter src/)"
],
"deny": []
}
Expand Down
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,74 @@ echo $todo->title; // Buy milk
echo $todo->assignee->name; // John
```

### Array Support

Ray.InputQuery supports arrays and ArrayObject collections with the `item` parameter:

```php
use Ray\InputQuery\Attribute\Input;

final class UserInput
{
public function __construct(
#[Input] public readonly string $id,
#[Input] public readonly string $name
) {}
}

final class UserListController
{
public function updateUsers(
#[Input(item: UserInput::class)] array $users // Array of UserInput objects
) {
foreach ($users as $user) {
echo $user->name; // Each element is a UserInput instance
}
}

public function processUsers(
#[Input(item: UserInput::class)] ArrayObject $users // ArrayObject collection
) {
// $users is an ArrayObject containing UserInput instances
}
}
```

Query data format for arrays:

```php
$data = [
'users' => [
['id' => '1', 'name' => 'John'],
['id' => '2', 'name' => 'Jane'],
['id' => '3', 'name' => 'Bob']
]
];

$args = $inputQuery->getArguments($method, $data);
// $args[0] will be an array of UserInput objects
```

**ArrayObject Inheritance Support:**

Custom ArrayObject subclasses are also supported:

```php
final class UserCollection extends ArrayObject
{
public function getFirst(): ?UserInput
{
return $this[0] ?? null;
}
}

public function handleUsers(
#[Input(item: UserInput::class)] UserCollection $users
) {
$firstUser = $users->getFirst(); // Custom method available
}
```

### Mixed with Dependency Injection

Parameters without the `#[Input]` attribute are resolved via dependency injection:
Expand Down
70 changes: 70 additions & 0 deletions demo/ArrayDemo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use Ray\Di\Injector;
use Ray\InputQuery\Attribute\Input;
use Ray\InputQuery\InputQuery;

// User input class
final class User
{
public function __construct(
#[Input]
public readonly string $id,
#[Input]
public readonly string $name
) {
}
}

// Controller with array handling
final class UserController
{
public function listUsers(
#[Input(item: User::class)]
array $users
): void {
echo "Array of users:\n";
foreach ($users as $index => $user) {
echo " [$index] ID: {$user->id}, Name: {$user->name}\n";
}
}

public function listUsersAsArrayObject(
#[Input(item: User::class)]
ArrayObject $users
): void {
echo "\nArrayObject of users:\n";
foreach ($users as $index => $user) {
echo " [$index] ID: {$user->id}, Name: {$user->name}\n";
}
}
}

// Demo
$injector = new Injector();
$inputQuery = new InputQuery($injector);

// Sample query data (like from $_POST)
$query = [
'users' => [
['id' => '1', 'name' => 'jingu'],
['id' => '2', 'name' => 'horikawa'],
['id' => '3', 'name' => 'tanaka']
]
];

$controller = new UserController();

// Array example
$method = new ReflectionMethod($controller, 'listUsers');
$args = $inputQuery->getArguments($method, $query);
$controller->listUsers(...$args);

// ArrayObject example
$method = new ReflectionMethod($controller, 'listUsersAsArrayObject');
$args = $inputQuery->getArguments($method, $query);
$controller->listUsersAsArrayObject(...$args);
4 changes: 4 additions & 0 deletions src/Attribute/Input.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@
#[Attribute(Attribute::TARGET_PARAMETER)]
final class Input
{
public function __construct(
public readonly string|null $item = null,
) {
}
}
149 changes: 138 additions & 11 deletions src/InputQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,32 @@

namespace Ray\InputQuery;

use ArrayObject;
use InvalidArgumentException;
use Override;
use Ray\Di\Di\Named;
use Ray\Di\Di\Qualifier;
use Ray\Di\Exception\Unbound;
use Ray\Di\InjectorInterface;
use Ray\InputQuery\Attribute\Input;
use ReflectionAttribute;
use ReflectionClass;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;

use function array_key_exists;
use function assert;
use function class_exists;
use function gettype;
use function is_array;
use function is_bool;
use function is_float;
use function is_int;
use function is_numeric;
use function is_scalar;
use function is_string;
use function is_subclass_of;
use function lcfirst;
use function sprintf;
use function str_replace;
Expand Down Expand Up @@ -91,7 +97,15 @@ private function resolveParameter(ReflectionParameter $param, array $query): mix
return $this->resolveFromDI($param);
}

// Has #[Input] attribute - get from query
return $this->resolveInputParameter($param, $query, $inputAttributes);
}

/**
* @param array<string, mixed> $query
* @param array<ReflectionAttribute<Input>> $inputAttributes
*/
private function resolveInputParameter(ReflectionParameter $param, array $query, array $inputAttributes): mixed
{
$type = $param->getType();
$paramName = $param->getName();

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

if ($type->isBuiltin()) {
// Scalar type with #[Input]
/** @psalm-suppress MixedAssignment $value */
$value = $query[$paramName] ?? $this->getDefaultValue($param);
return $this->resolveBuiltinType($param, $query, $inputAttributes, $type);
}

return $this->resolveObjectType($param, $query, $inputAttributes, $type);
}

return $this->convertScalar($value, $type);
/**
* @param array<string, mixed> $query
* @param array<ReflectionAttribute<Input>> $inputAttributes
*/
private function resolveBuiltinType(ReflectionParameter $param, array $query, array $inputAttributes, ReflectionNamedType $type): mixed
{
$paramName = $param->getName();

if ($type->getName() === 'array') {
$inputAttribute = $inputAttributes[0]->newInstance();
if ($inputAttribute->item !== null) {
assert(class_exists($inputAttribute->item));
$itemClass = $inputAttribute->item;

/** @var class-string<T> $itemClass */
return $this->createArrayOfInputs($paramName, $query, $itemClass);
}
}

// Object type with #[Input] - create nested
// Scalar type with #[Input]
/** @psalm-suppress MixedAssignment $value */
$value = $query[$paramName] ?? $this->getDefaultValue($param);

return $this->convertScalar($value, $type);
}

/**
* @param array<string, mixed> $query
* @param array<ReflectionAttribute<Input>> $inputAttributes
*/
private function resolveObjectType(ReflectionParameter $param, array $query, array $inputAttributes, ReflectionNamedType $type): mixed
{
$paramName = $param->getName();
$className = $type->getName();

// Check for ArrayObject types with item specification
$arrayObjectResult = $this->resolveArrayObjectType($paramName, $query, $inputAttributes, $className);
if ($arrayObjectResult !== null) {
return $arrayObjectResult;
}

// Regular object type with #[Input] - create nested
$nestedQuery = $this->extractNestedQuery($paramName, $query);

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

$class = $type->getName();
assert(class_exists($class));
assert(class_exists($className));

/** @var class-string<T> $className */
return $this->create($className, $nestedQuery);
}

/**
* @param array<string, mixed> $query
* @param array<ReflectionAttribute<Input>> $inputAttributes
*/
private function resolveArrayObjectType(string $paramName, array $query, array $inputAttributes, string $className): mixed
{
$isArrayObjectSubclass = class_exists($className) && is_subclass_of($className, ArrayObject::class);
$isArrayObject = $className === ArrayObject::class;

if (! $isArrayObjectSubclass && ! $isArrayObject) {
return null;
}

/** @var class-string<T> $class */
return $this->create($class, $nestedQuery);
$inputAttribute = $inputAttributes[0]->newInstance();
if ($inputAttribute->item === null) {
return null;
}

assert(class_exists($inputAttribute->item));
/** @var class-string<T> $itemClass */
$itemClass = $inputAttribute->item;
$array = $this->createArrayOfInputs($paramName, $query, $itemClass);

if ($isArrayObject) {
return new ArrayObject($array);
}

assert(class_exists($className));
/** @var class-string $className */
$reflectionClass = new ReflectionClass($className);

return $reflectionClass->newInstance($array);
}

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

return lcfirst($string);
}

/**
* @param array<string, mixed> $query
* @param class-string<T> $itemClass
*
* @return array<array-key, T>
*/
private function createArrayOfInputs(string $paramName, array $query, string $itemClass): array
{
if (! array_key_exists($paramName, $query)) {
return [];
}

/** @var mixed $arrayData */
$arrayData = $query[$paramName];

if (! is_array($arrayData)) {
return [];
}

$result = [];
/** @var mixed $itemData */
foreach ($arrayData as $key => $itemData) {
if (! is_array($itemData)) {
throw new InvalidArgumentException(
sprintf(
'Expected array for item at key "%s", got %s.',
$key,
gettype($itemData),
),
);
}

// Query parameters from HTTP requests have string keys
/** @psalm-var array<string, mixed> $itemData */
/** @phpstan-var array<string, mixed> $itemData */
$result[$key] = $this->create($itemClass, $itemData);
}

return $result;
}
}
Loading