From 398ef068b0221b721b8a5d89aeca747ef7847ac7 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 7 Jul 2025 17:40:46 +0900 Subject: [PATCH 1/3] feat: add ToArray functionality for flattening Input objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ToArrayInterface for converting Input objects to flat arrays - Add ToArray implementation with recursive object flattening - Handle property name conflicts (later values overwrite earlier ones) - Preserve arrays for SQL IN clause compatibility - Ignore private/protected properties (public only) - Add comprehensive test suite covering all edge cases This enables SQL parameter binding from nested Input objects: $params = $toArray($orderInput); // Flat array for SQL queries 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/ToArray.php | 54 ++++++++++++ src/ToArrayInterface.php | 17 ++++ tests/ToArrayTest.php | 173 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/ToArray.php create mode 100644 src/ToArrayInterface.php create mode 100644 tests/ToArrayTest.php diff --git a/src/ToArray.php b/src/ToArray.php new file mode 100644 index 0000000..5fb24f5 --- /dev/null +++ b/src/ToArray.php @@ -0,0 +1,54 @@ +extractProperties($input); + } + + /** @return array */ + private function extractProperties(object $object): array + { + $result = []; + $reflection = new ReflectionClass($object); + + foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + /** @var mixed $value */ + $value = $property->getValue($object); + $name = $property->getName(); + + if (is_object($value)) { + // Recursively extract nested objects + $nestedProperties = $this->extractProperties($value); + /** @var mixed $nestedValue */ + foreach ($nestedProperties as $nestedName => $nestedValue) { + /** @psalm-suppress MixedAssignment */ + $result[$nestedName] = $nestedValue; + } + + continue; + } + + // Keep arrays and scalar values as-is + /** @psalm-suppress MixedAssignment */ + $result[$name] = $value; + } + + return $result; + } +} diff --git a/src/ToArrayInterface.php b/src/ToArrayInterface.php new file mode 100644 index 0000000..c036a0b --- /dev/null +++ b/src/ToArrayInterface.php @@ -0,0 +1,17 @@ + Flat associative array + */ + public function __invoke(object $input): array; +} diff --git a/tests/ToArrayTest.php b/tests/ToArrayTest.php new file mode 100644 index 0000000..9103e7b --- /dev/null +++ b/tests/ToArrayTest.php @@ -0,0 +1,173 @@ +toArray = new ToArray(); + } + + public function testSimpleObject(): void + { + $input = new class { + public function __construct( + #[Input] + public readonly string $name = 'John', + #[Input] + public readonly int $age = 30, + ) { + } + }; + + $result = ($this->toArray)($input); + + $this->assertSame(['name' => 'John', 'age' => 30], $result); + } + + public function testNestedObject(): void + { + $author = new class { + public function __construct( + #[Input] + public readonly string $name = 'John', + #[Input] + public readonly string $email = 'john@example.com', + ) { + } + }; + + $article = new class { + #[Input] + public readonly string $title; + + #[Input] + public readonly object $author; + + public function __construct() + { + $this->title = 'Hello World'; + $this->author = new class { + public function __construct( + #[Input] + public readonly string $name = 'John', + #[Input] + public readonly string $email = 'john@example.com', + ) { + } + }; + } + }; + + $result = ($this->toArray)($article); + + $this->assertSame([ + 'title' => 'Hello World', + 'name' => 'John', + 'email' => 'john@example.com', + ], $result); + } + + public function testArrayProperty(): void + { + $input = new class { + /** @param array $userIds */ + public function __construct( + #[Input] + public readonly string $status = 'active', + #[Input] + public readonly array $userIds = [1, 2, 3], + ) { + } + }; + + $result = ($this->toArray)($input); + + $this->assertSame([ + 'status' => 'active', + 'userIds' => [1, 2, 3], + ], $result); + } + + public function testPropertyNameConflict(): void + { + $order = new class { + #[Input] + public readonly string $id; + + #[Input] + public readonly object $customer; + + public function __construct() + { + $this->id = 'order-456'; + $this->customer = new class { + public function __construct( + #[Input] + public readonly string $id = 'customer-123', + #[Input] + public readonly string $name = 'John', + ) { + } + }; + } + }; + + $result = ($this->toArray)($order); + + // Later property overwrites earlier one + $this->assertSame([ + 'id' => 'customer-123', + 'name' => 'John', + ], $result); + } + + public function testPrivatePropertiesIgnored(): void + { + $input = new class { + public function __construct( + #[Input] + public readonly string $public = 'visible', + #[Input] + private readonly string $private = 'hidden', + ) { + } + }; + + $result = ($this->toArray)($input); + + $this->assertSame(['public' => 'visible'], $result); + $this->assertArrayNotHasKey('private', $result); + } + + public function testNullValues(): void + { + $input = new class { + public function __construct( + #[Input] + public readonly string $name = 'John', + #[Input] + public readonly string|null $email = null, + #[Input] + public readonly object|null $address = null, + ) { + } + }; + + $result = ($this->toArray)($input); + + $this->assertSame([ + 'name' => 'John', + 'email' => null, + 'address' => null, + ], $result); + } +} From f6bced4996fc30d3dce026ddc78925f4f4c9a2c7 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 7 Jul 2025 18:20:36 +0900 Subject: [PATCH 2/3] docs: dramatically improve README clarity and add ToArray v0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add intuitive Quick Example showing query string → hierarchical objects - Replace complex overview with simple, immediate value demonstration - Move Demo section to end (logical flow: learn → understand → try) - Remove unnecessary PSR-7 example, focus on simple $_POST usage - Add comprehensive ToArray documentation with Aura.Sql integration - Update CHANGELOG for v0.2.0 release with ToArray functionality The README now answers "what is this?" in 3 seconds with concrete examples. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CHANGELOG.md | 7 +- README.md | 267 ++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 215 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b84a8..8789327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2025-07-07 + +### Added +- `ToArrayInterface` and `ToArray` implementation for flattening objects to flat associative arrays + ## [0.1.0] - 2025-07-07 ### Added @@ -37,4 +42,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - symfony/polyfill-php83 ^1.28 - koriym/file-upload ^0.2.0 (optional, for file upload support) -[0.1.0]: https://github.com/ray-di/Ray.InputQuery/releases/tag/0.1.0 \ No newline at end of file +[0.1.0]: https://github.com/ray-di/Ray.InputQuery/releases/tag/0.1.0 diff --git a/README.md b/README.md index 1810f3b..0ff4b6b 100644 --- a/README.md +++ b/README.md @@ -4,83 +4,58 @@ [![Type Coverage](https://shepherd.dev/github/ray-di/Ray.InputQuery/coverage.svg)](https://shepherd.dev/github/ray-di/Ray.InputQuery) [![codecov](https://codecov.io/gh/ray-di/Ray.InputQuery/branch/main/graph/badge.svg)](https://codecov.io/gh/ray-di/Ray.InputQuery) -Structured input objects from HTTP with 100% test coverage. +Convert HTTP query parameters into hierarchical PHP objects automatically. -## Overview - -Ray.InputQuery transforms flat HTTP data into structured PHP objects through explicit type declarations. Using the `#[Input]` attribute, you declare which parameters come from query data, while other parameters are resolved via dependency injection. +## Quick Example -**Core Mechanism:** -- **Attribute-Based Control** - `#[Input]` explicitly marks query-sourced parameters -- **Prefix-Based Nesting** - `assigneeId`, `assigneeName` fields automatically compose `UserInput` objects -- **Type-Safe Conversion** - Leverages PHP's type system for automatic scalar conversion -- **DI Integration** - Parameters without `#[Input]` are resolved from dependency injection - -**The Problem:** ```php -// Manual parameter extraction and object construction -$data = $request->getParsedBody(); // or $_POST -$title = $data['title'] ?? ''; -$assigneeId = $data['assigneeId'] ?? ''; -$assigneeName = $data['assigneeName'] ?? ''; -$assigneeEmail = $data['assigneeEmail'] ?? ''; -``` +// HTTP Request: ?name=John&email=john@example.com&addressStreet=123 Main St&addressCity=Tokyo -**Ray.InputQuery Solution:** -```php -// Declarative structure definition -final class TodoInput { +// Automatically becomes: +final class AddressInput { public function __construct( - #[Input] public readonly string $title, - #[Input] public readonly UserInput $assignee, // Auto-composed from assigneeId, assigneeName, assigneeEmail - private LoggerInterface $logger // From DI container + #[Input] public readonly string $street, + #[Input] public readonly string $city ) {} } -public function createTodo(TodoInput $input) { - // $input automatically structured from request data +final class UserInput { + public function __construct( + #[Input] public readonly string $name, + #[Input] public readonly string $email, + #[Input] public readonly AddressInput $address // Nested object! + ) {} } -``` -## Installation - -```bash -composer require ray/input-query +$user = $inputQuery->newInstance(UserInput::class, $_GET); +echo $user->name; // "John" +echo $user->address->street; // "123 Main St" ``` -### Optional: File Upload Support +**Key Point**: `addressStreet` and `addressCity` automatically compose the `AddressInput` object. -For file upload functionality, also install: +## Overview -```bash -composer require koriym/file-upload -``` +Ray.InputQuery transforms flat HTTP data into structured PHP objects through explicit type declarations. Using the `#[Input]` attribute, you declare which parameters come from query data, while other parameters are resolved via dependency injection. -## Demo +**Core Features:** +- **Automatic Nesting** - Prefix-based parameters create hierarchical objects +- **Type Safety** - Leverages PHP's type system for automatic conversion +- **DI Integration** - Mix query parameters with dependency injection +- **Validation** - Type constraints ensure data integrity -### Web Demo - -To see file upload integration in action: +## Installation ```bash -php -S localhost:8080 -t demo/ +composer require ray/input-query ``` -Then visit [http://localhost:8080](http://localhost:8080) in your browser. - -### Console Demos +### Optional: File Upload Support -Run various examples from the command line: +For file upload functionality, also install: ```bash -# Basic examples with nested objects and DI -php demo/run.php - -# Array processing demo -php demo/ArrayDemo.php - -# CSV file processing with batch operations -php demo/csv/run.php +composer require koriym/file-upload ``` ## Documentation @@ -136,10 +111,6 @@ echo $user->email; // john@example.com // Method argument resolution from $_POST $method = new ReflectionMethod(UserController::class, 'register'); $args = $inputQuery->getArguments($method, $_POST); -$result = $method->invokeArgs($controller, $args); - - // Or with PSR-7 Request -$args = $inputQuery->getArguments($method, $request->getParsedBody()); $result = $method->invokeArgs($controller, $args); ``` @@ -528,3 +499,183 @@ $input = $inputQuery->newInstance(GalleryInput::class, [ 'images' => $mockImages ]); ``` + +## Converting Objects to Arrays + +Ray.InputQuery provides the `ToArray` functionality to convert objects with `#[Input]` parameters into flat associative arrays, primarily for SQL parameter binding with libraries like Aura.Sql: + +### Basic ToArray Usage + +```php +use Ray\InputQuery\ToArray; + +final class CustomerInput +{ + public function __construct( + #[Input] public readonly string $name, + #[Input] public readonly string $email, + ) {} +} + +final class OrderInput +{ + public function __construct( + #[Input] public readonly string $id, + #[Input] public readonly CustomerInput $customer, + #[Input] public readonly array $items, + ) {} +} + +// Create nested input object +$orderInput = new OrderInput( + id: 'ORD-001', + customer: new CustomerInput(name: 'John Doe', email: 'john@example.com'), + items: [['product' => 'laptop', 'quantity' => 1]] +); + +// Convert to flat array for SQL +$toArray = new ToArray(); +$params = $toArray($orderInput); + +// Result: +// [ +// 'id' => 'ORD-001', +// 'name' => 'John Doe', // Flattened from customer +// 'email' => 'john@example.com', // Flattened from customer +// 'items' => [['product' => 'laptop', 'quantity' => 1]] // Arrays preserved +// ] +``` + +### SQL Param¥¥eter Binding + +The flattened arrays work seamlessly with Aura.Sql and other SQL libraries: + +```php +// Using with Aura.Sql +$sql = "INSERT INTO orders (id, customer_name, customer_email) VALUES (:id, :name, :email)"; +$statement = $pdo->prepare($sql); +$statement->execute($params); + +// Arrays are preserved for IN clauses +$productIds = $params['productIds']; // [1, 2, 3] +$sql = "SELECT * FROM products WHERE id IN (?)"; +$statement = $pdo->prepare($sql); +$statement->execute([$productIds]); // Aura.Sql handles array expansion + +// Other use cases +return new JsonResponse($params); // API responses +$this->logger->info('Order data', $params); // Logging +``` + +### Property Name Conflicts + +When flattened properties have the same name, later values overwrite earlier ones: + +```php +final class OrderInput +{ + public function __construct( + #[Input] public readonly string $id, // 'ORD-001' + #[Input] public readonly CustomerInput $customer, // Has 'id' property: 'CUST-123' + ) {} +} + +$params = $toArray($orderInput); +// Result: ['id' => 'CUST-123'] // Customer ID overwrites order ID +``` + +### Key Features + +- **Recursive Flattening**: Nested objects with `#[Input]` parameters are automatically flattened +- **Array Preservation**: Arrays remain intact for SQL IN clauses (Aura.Sql compatible) +- **Property Conflicts**: Later properties overwrite earlier ones +- **Public Properties Only**: Private/protected properties are ignored +- **Type Safety**: Maintains type information through transformation + +### Complex Example + +```php +final class AddressInput +{ + public function __construct( + #[Input] public readonly string $street, + #[Input] public readonly string $city, + #[Input] public readonly string $country, + ) {} +} + +final class CustomerInput +{ + public function __construct( + #[Input] public readonly string $name, + #[Input] public readonly string $email, + #[Input] public readonly AddressInput $address, + ) {} +} + +final class OrderInput +{ + public function __construct( + #[Input] public readonly string $orderId, + #[Input] public readonly CustomerInput $customer, + #[Input] public readonly AddressInput $shipping, + #[Input] public readonly array $productIds, + ) {} +} + +$order = new OrderInput( + orderId: 'ORD-001', + customer: new CustomerInput( + name: 'John Doe', + email: 'john@example.com', + address: new AddressInput(street: '123 Main St', city: 'Tokyo', country: 'Japan') + ), + shipping: new AddressInput(street: '456 Oak Ave', city: 'Osaka', country: 'Japan'), + productIds: ['PROD-1', 'PROD-2', 'PROD-3'] +); + +$params = $toArray($order); +// Result: +// [ +// 'orderId' => 'ORD-001', +// 'name' => 'John Doe', +// 'email' => 'john@example.com', +// 'street' => '456 Oak Ave', // Shipping address overwrites customer address +// 'city' => 'Osaka', // Shipping address overwrites customer address +// 'country' => 'Japan', // Same value, so no visible conflict +// 'productIds' => ['PROD-1', 'PROD-2', 'PROD-3'] // Array preserved +// ] + +// Use the flattened data +$orderId = $params['orderId']; +$customerName = $params['name']; +$shippingAddress = "{$params['street']}, {$params['city']}, {$params['country']}"; +$productIds = $params['productIds']; // Array preserved +``` + +## Demo + +### Web Demo + +To see file upload integration in action: + +```bash +php -S localhost:8080 -t demo/ +``` + +Then visit [http://localhost:8080](http://localhost:8080) in your browser. + +### Console Demos + +Run various examples from the command line: + +```bash +# Basic examples with nested objects and DI +php demo/run.php + +# Array processing demo +php demo/ArrayDemo.php + +# CSV file processing with batch operations +php demo/csv/run.php +``` From 9a4ccd3af99e0ee4f27a8aa18390a938c78b2152 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 7 Jul 2025 18:22:43 +0900 Subject: [PATCH 3/3] fix: remove unused variable in ToArrayTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused variable in testNestedObject method. The test creates the author object directly in the constructor instead. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/ToArrayTest.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/ToArrayTest.php b/tests/ToArrayTest.php index 9103e7b..3769ee6 100644 --- a/tests/ToArrayTest.php +++ b/tests/ToArrayTest.php @@ -35,16 +35,6 @@ public function __construct( public function testNestedObject(): void { - $author = new class { - public function __construct( - #[Input] - public readonly string $name = 'John', - #[Input] - public readonly string $email = 'john@example.com', - ) { - } - }; - $article = new class { #[Input] public readonly string $title;