diff --git a/.gitignore b/.gitignore index aa538e7..366490b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,16 @@ /phpunit.xml /.phpunit.result.cache /.phpcs-cache + +# Uploaded files +demo/uploads/ +uploads/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/README.md b/README.md index 1c5329a..3387f8f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ $title = $data['title'] ?? ''; $assigneeId = $data['assigneeId'] ?? ''; $assigneeName = $data['assigneeName'] ?? ''; $assigneeEmail = $data['assigneeEmail'] ?? ''; - +``` **Ray.InputQuery Solution:** ```php @@ -48,6 +48,14 @@ public function createTodo(TodoInput $input) { composer require ray/input-query ``` +### Optional: File Upload Support + +For file upload functionality, also install: + +```bash +composer require koriym/file-upload +``` + ## Demo To see file upload integration in action: @@ -100,7 +108,7 @@ $injector = new Injector(); $inputQuery = new InputQuery($injector); // Create object directly from array -$user = $inputQuery->create(UserInput::class, [ +$user = $inputQuery->newInstance(UserInput::class, [ 'name' => 'John Doe', 'email' => 'john@example.com' ]); @@ -123,23 +131,34 @@ $result = $method->invokeArgs($controller, $args); Ray.InputQuery automatically creates nested objects from flat query data: ```php -final class TodoInput +final class AddressInput { public function __construct( - #[Input] public readonly string $title, - #[Input] public readonly UserInput $assignee // Nested input + #[Input] public readonly string $street, + #[Input] public readonly string $city, + #[Input] public readonly string $zip ) {} } -$todo = $inputQuery->create(TodoInput::class, [ - 'title' => 'Buy milk', - 'assigneeId' => '123', - 'assigneeName' => 'John', - 'assigneeEmail' => 'john@example.com' +final class UserInput +{ + public function __construct( + #[Input] public readonly string $name, + #[Input] public readonly string $email, + #[Input] public readonly AddressInput $address // Nested input + ) {} +} + +$user = $inputQuery->newInstance(UserInput::class, [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'addressStreet' => '123 Main St', + 'addressCity' => 'Tokyo', + 'addressZip' => '100-0001' ]); -echo $todo->title; // Buy milk -echo $todo->assignee->name; // John +echo $user->name; // John Doe +echo $user->address->street; // 123 Main St ``` ### Array Support @@ -289,14 +308,71 @@ Parameters without the `#[Input]` attribute are resolved via dependency injectio ```php use Ray\Di\Di\Named; -final class OrderInput +interface AddressServiceInterface +{ + public function findByZip(string $zip): Address; +} + + +interface TicketFactoryInterface +{ + public function create(string $eventId, string $ticketId): Ticket; +} + +final class EventBookingInput { public function __construct( - #[Input] public readonly string $orderId, // From query - #[Input] public readonly CustomerInput $customer, // From query - #[Named('tax.rate')] private float $taxRate, // From DI - private LoggerInterface $logger // From DI - ) {} + #[Input] public readonly string $ticketId, // From query - raw ID + #[Input] public readonly string $email, // From query + #[Input] public readonly string $zip, // From query + #[Named('event_id')] private string $eventId, // From DI + private TicketFactoryInterface $ticketFactory, // From DI + private AddressServiceInterface $addressService, // From DI + ) { + // Create complete Ticket object from ID (includes validation, expiry, etc.) + $this->ticket = $this->ticketFactory->create($eventId, $ticketId); + // Fully validated immutable ticket object created! + + if (!$this->ticket->isValid) { + throw new InvalidTicketException( + "Ticket {$ticketId} is invalid: {$this->ticket->getInvalidReason()}" + ); + } + + // Get address from zip + $this->address = $this->addressService->findByZip($zip); + } + + public readonly Ticket $ticket; // Complete ticket object with ID, status, etc. + public readonly Address $address; // Structured address object +} + +// DI configuration +$injector = new Injector(new class extends AbstractModule { + protected function configure(): void + { + $this->bind(TicketFactoryInterface::class)->to(TicketFactory::class); // Can swap with mock in tests + $this->bind(AddressServiceInterface::class)->to(AddressService::class); + $this->bind()->annotatedWith('event_id')->toInstance('ray-event-2025'); + } +}); + +$inputQuery = new InputQuery($injector); + +// Usage - Factory automatically creates complete objects from IDs +try { + $booking = $inputQuery->newInstance(EventBookingInput::class, [ + 'ticketId' => 'TKT-2024-001', + 'email' => 'user@example.com', + 'zip' => '100-0001' + ]); + + // $booking->ticket is a Ticket object with ID and validation status + echo "Ticket ID: " . $booking->ticket->id; // Only valid ticket ID + +} catch (InvalidTicketException $e) { + // Handle expired or invalid tickets + echo "Booking failed: " . $e->getMessage(); } ``` @@ -316,6 +392,15 @@ Ray.InputQuery provides comprehensive file upload support through integration wi composer require koriym/file-upload ``` +When using file upload features, instantiate InputQuery with FileUploadFactory: + +```php +use Ray\InputQuery\InputQuery; +use Ray\InputQuery\FileUploadFactory; + +$inputQuery = new InputQuery($injector, new FileUploadFactory()); +``` + ### Using #[InputFile] Attribute For file uploads, use the dedicated `#[InputFile]` attribute which provides validation options: @@ -352,7 +437,7 @@ File upload handling is designed to be test-friendly: ```php // Production usage - FileUpload library handles file uploads automatically -$input = $inputQuery->create(UserProfileInput::class, $_POST); +$input = $inputQuery->newInstance(UserProfileInput::class, $_POST); // FileUpload objects are created automatically from uploaded files // Testing usage - inject mock FileUpload objects directly for easy testing @@ -364,7 +449,7 @@ $mockAvatar = FileUpload::create([ 'error' => UPLOAD_ERR_OK, ]); -$input = $inputQuery->create(UserProfileInput::class, [ +$input = $inputQuery->newInstance(UserProfileInput::class, [ 'name' => 'Test User', 'email' => 'test@example.com', 'avatar' => $mockAvatar, @@ -412,7 +497,7 @@ class GalleryController } // Production usage - FileUpload library handles multiple files automatically -$input = $inputQuery->create(GalleryInput::class, $_POST); +$input = $inputQuery->newInstance(GalleryInput::class, $_POST); // Array of FileUpload objects created automatically from uploaded files // Testing usage - inject array of mock FileUpload objects for easy testing @@ -421,33 +506,8 @@ $mockImages = [ FileUpload::create(['name' => 'image2.png', ...]) ]; -$input = $inputQuery->create(GalleryInput::class, [ +$input = $inputQuery->newInstance(GalleryInput::class, [ 'title' => 'My Gallery', 'images' => $mockImages ]); ``` - -## Integration - -Ray.InputQuery is designed as a foundation library to be used by: - -- [Ray.MediaQuery](https://github.com/ray-di/Ray.MediaQuery) - For database query integration -- [BEAR.Resource](https://github.com/bearsunday/BEAR.Resource) - For REST resource integration - -## Project Quality - -This project maintains high quality standards: - -- **100% Code Coverage** - Achieved through public interface tests only -- **Static Analysis** - Psalm and PHPStan at maximum levels -- **Test Design** - No private method tests, ensuring maintainability -- **Type Safety** - Comprehensive Psalm type annotations - -## Requirements - -- PHP 8.1+ -- ray/di ^2.0 - -## License - -MIT diff --git a/composer-require-checker.json b/composer-require-checker.json index c53e62a..a67acc6 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,7 +1,8 @@ { "symbol-whitelist" : [ "Koriym\\FileUpload\\FileUpload", - "Koriym\\FileUpload\\ErrorFileUpload" + "Koriym\\FileUpload\\ErrorFileUpload", + "Koriym\\FileUpload\\AbstractFileUpload" ], "php-core-extensions" : [ "Core", diff --git a/demo/ArrayDemo.php b/demo/ArrayDemo.php index 39f9203..64d5db4 100644 --- a/demo/ArrayDemo.php +++ b/demo/ArrayDemo.php @@ -32,7 +32,7 @@ public function listUsers( echo " [$index] ID: {$user->id}, Name: {$user->name}\n"; } } - + public function listUsersAsArrayObject( #[Input(item: User::class)] ArrayObject $users @@ -67,4 +67,4 @@ public function listUsersAsArrayObject( // ArrayObject example $method = new ReflectionMethod($controller, 'listUsersAsArrayObject'); $args = $inputQuery->getArguments($method, $query); -$controller->listUsersAsArrayObject(...$args); \ No newline at end of file +$controller->listUsersAsArrayObject(...$args); diff --git a/demo/csv/AgeGroup.php b/demo/csv/AgeGroup.php new file mode 100644 index 0000000..73fd62e --- /dev/null +++ b/demo/csv/AgeGroup.php @@ -0,0 +1,54 @@ + */ + private array $groups = [ + 'under_25' => 0, + '25_35' => 0, + '36_50' => 0, + 'over_50' => 0, + ]; + + public function addAge(?int $age): void + { + if ($age === null) { + return; + } + + if ($age < 25) { + $this->groups['under_25']++; + } elseif ($age <= 35) { + $this->groups['25_35']++; + } elseif ($age <= 50) { + $this->groups['36_50']++; + } else { + $this->groups['over_50']++; + } + } + + /** @return array */ + public function getGroups(): array + { + return $this->groups; + } + + public function getTotalCount(): int + { + return array_sum($this->groups); + } +} \ No newline at end of file diff --git a/demo/csv/AgeInput.php b/demo/csv/AgeInput.php new file mode 100644 index 0000000..aad9af7 --- /dev/null +++ b/demo/csv/AgeInput.php @@ -0,0 +1,29 @@ +move($saveFile); + $csvData = new SplFileObject($saveFile); + $csvData->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY); + $csvData->setCsvControl($delimiter); + + // Step 2: DI Container setup with Singleton service + $injector = new Injector(new class extends AbstractModule { + protected function configure(): void + { + // AgeGroup as Singleton - same instance shared across all Input objects + $this->bind(AgeGroup::class)->in(Scope::SINGLETON); + } + }); + + // Step 3: Ray.InputQuery setup + $inputQuery = new InputQuery($injector); + $method1 = new \ReflectionMethod(self::class, 'dump'); + $method2 = new \ReflectionMethod(self::class, 'dump2'); + + // Step 4: Process each CSV row with Ray.InputQuery + foreach ($csvData as $row) { + // Raw CSV data transformed to associative array + $query = ['name' => $row[0], 'email' => $row[1], 'age' => $row[2], 'role' => $row[3]]; + + // Ray.InputQuery creates arguments with automatic type conversion + $args1 = $inputQuery->getArguments($method1,$query); // Primitive types approach + $args2 = $inputQuery->getArguments($method2,$query); // Input object approach + + // Invoke methods with type-safe arguments + $method1->invokeArgs($this, $args1); + $method2->invokeArgs($this, $args2); + } + + // Step 5: Retrieve accumulated data from Singleton service + $addGroup = $injector->getInstance(AgeGroup::class); + assert($addGroup instanceof AgeGroup); + $count = $addGroup->getTotalCount(); + echo "Total count: $count\n"; + $ageGroup = $addGroup->getGroups(); + $ageGroup = array_unique($ageGroup); + echo "Age group: " . json_encode($ageGroup) . "\n"; + return; + + } + + public function dump( + #[Input] string $name, // Injected primitive type + #[Input] string $email, + #[Input] int $age, + #[Input] string $role, + AgeGroup $ageGroup // Injected service for age grouping + ): void + { + $ageGroup->addAge($age); + echo "dump1: Name: $name Email: $email Age: $age Role: $role\n"; + } + + public function dump2( + #[Input] UserInput $user, // Construct Input object with validation + #[Input] int $email, // You can mix Input objects with primitive types + AgeGroup $ageGroup // Injected service for age grouping + ): void + { + $ageGroup->addAge($user->age); + + echo "dump2: Name: $user->name Email: $user->email Age: {$user->ageInput->age}:{$user->age}\n"; + } +} diff --git a/demo/csv/UserInput.php b/demo/csv/UserInput.php new file mode 100644 index 0000000..eafd89e --- /dev/null +++ b/demo/csv/UserInput.php @@ -0,0 +1,41 @@ +ageGroup?->addAge($this->age); + } + + public function __toString(): string + { + $age = $this->age !== null ? " (Age: {$this->age})" : ''; + $dept = $this->department ? " [{$this->department}]" : ''; + + return "{$this->name} <{$this->email}>{$age}{$dept}"; + } +} diff --git a/demo/csv/run.php b/demo/csv/run.php new file mode 100644 index 0000000..611269b --- /dev/null +++ b/demo/csv/run.php @@ -0,0 +1,39 @@ +getArguments($method, [ + 'csvFile' => $csvFile, + 'delimiter' => ',' +]); +$method->invoke(new CsvDemo(), ...$args); + +echo "✅ Key Principles Demonstrated:\n"; +echo "================================\n"; +echo "🔹 Input objects receive data, don't convert types\n"; +echo "🔹 Ray.InputQuery handles ALL type conversion automatically\n"; +echo "🔹 Separate utilities for CSV parsing (outside Ray.InputQuery)\n"; +echo "🔹 Type safety guaranteed by the framework\n"; +echo "🔹 Clean separation of concerns\n"; diff --git a/demo/csv/tmp/saved.csv b/demo/csv/tmp/saved.csv new file mode 100644 index 0000000..cdd08da --- /dev/null +++ b/demo/csv/tmp/saved.csv @@ -0,0 +1,11 @@ +name,email,age,department +Alice Johnson,alice@example.com,28,Engineering +Bob Smith,bob@example.com,35,Marketing +Carol Davis,carol@example.com,31,Sales +David Wilson,david@example.com,29,Engineering +Eve Brown,eve@example.com,26,Design +Frank Miller,frank@example.com,42,Management +Grace Lee,grace@example.com,33,Engineering +Henry Taylor,henry@example.com,27,Marketing +Ivy Chen,ivy@example.com,30,Sales +Jack Anderson,jack@example.com,38,Engineering \ No newline at end of file diff --git a/demo/csv/users.csv b/demo/csv/users.csv new file mode 100644 index 0000000..cdd08da --- /dev/null +++ b/demo/csv/users.csv @@ -0,0 +1,11 @@ +name,email,age,department +Alice Johnson,alice@example.com,28,Engineering +Bob Smith,bob@example.com,35,Marketing +Carol Davis,carol@example.com,31,Sales +David Wilson,david@example.com,29,Engineering +Eve Brown,eve@example.com,26,Design +Frank Miller,frank@example.com,42,Management +Grace Lee,grace@example.com,33,Engineering +Henry Taylor,henry@example.com,27,Marketing +Ivy Chen,ivy@example.com,30,Sales +Jack Anderson,jack@example.com,38,Engineering \ No newline at end of file diff --git a/demo/run.php b/demo/run.php index c6ffd93..7e3639c 100644 --- a/demo/run.php +++ b/demo/run.php @@ -30,7 +30,7 @@ protected function configure(): void } }); -$inputQuery = new InputQuery($injector, $injector->getInstance(FileUploadFactoryInterface::class)); +$inputQuery = new InputQuery($injector, new FileUploadFactory()); echo "1. Simple User Profile Creation\n"; echo "================================\n"; @@ -44,7 +44,7 @@ protected function configure(): void 'isPublic' => '1' ]; -$userProfile = $inputQuery->create(UserProfile::class, $userFormData); +$userProfile = $inputQuery->newInstance(UserProfile::class, $userFormData); echo $userProfile->getDisplayInfo() . "\n\n"; echo "2. Nested Object Creation (Blog Post with Author)\n"; @@ -61,7 +61,7 @@ protected function configure(): void 'authorId' => 'user123' ]; -$blogPost = $inputQuery->create(BlogPost::class, $blogFormData); +$blogPost = $inputQuery->newInstance(BlogPost::class, $blogFormData); echo $blogPost->getPostSummary() . "\n\n"; echo "3. Controller Method Arguments with DI\n"; @@ -93,7 +93,7 @@ protected function configure(): void 'isPublic' => 'true' // string -> bool ]; -$profile = $inputQuery->create(UserProfile::class, $scalarData); +$profile = $inputQuery->newInstance(UserProfile::class, $scalarData); echo "Converted types:\n"; echo "- age (string '25' -> int): " . var_export($profile->age, true) . " (" . gettype($profile->age) . ")\n"; echo "- isPublic (string 'true' -> bool): " . var_export($profile->isPublic, true) . " (" . gettype($profile->isPublic) . ")\n\n"; @@ -107,7 +107,7 @@ protected function configure(): void 'email' => 'minimal@example.com' ]; -$minimalProfile = $inputQuery->create(UserProfile::class, $minimalData); +$minimalProfile = $inputQuery->newInstance(UserProfile::class, $minimalData); echo "Profile with defaults:\n"; echo $minimalProfile->getDisplayInfo() . "\n\n"; @@ -123,7 +123,7 @@ protected function configure(): void 'author_id' => 'snake123' // author_id -> authorId ]; -$snakeCasePost = $inputQuery->create(BlogPost::class, $snakeCaseData); +$snakeCasePost = $inputQuery->newInstance(BlogPost::class, $snakeCaseData); echo "Successfully converted snake_case keys:\n"; echo $snakeCasePost->getPostSummary() . "\n\n"; @@ -142,7 +142,7 @@ protected function configure(): void ]), 'banner' => FileUpload::create([ 'name' => 'banner.png', - 'type' => 'image/png', + 'type' => 'image/png', 'size' => 2048, 'tmp_name' => '/tmp/upload2', 'error' => 0, @@ -165,7 +165,7 @@ protected function configure(): void ] ]; -$fileExample = $inputQuery->create(FileUploadExample::class, $fileUploadData); +$fileExample = $inputQuery->newInstance(FileUploadExample::class, $fileUploadData); echo $fileExample->getUploadSummary() . "\n"; echo "🌟 HTML Form Mapping Examples\n"; diff --git a/docs/design/design.md b/docs/design/design.md index 2b2b863..b003087 100644 --- a/docs/design/design.md +++ b/docs/design/design.md @@ -340,7 +340,7 @@ final class UserInput } // 使用 -$user = $inputQuery->create(UserInput::class, $_POST); +$user = $inputQuery->newInstance(UserInput::class, $_POST); echo $user->name; // "John Doe" echo $user->email; // "john@example.com" ``` @@ -371,7 +371,7 @@ final class AuthorInput } // 自動的にネスト構造を構築 -$article = $inputQuery->create(ArticleInput::class, $_POST); +$article = $inputQuery->newInstance(ArticleInput::class, $_POST); echo $article->author->name; // "John" ``` diff --git a/docs/design/file-upload-integration.md b/docs/design/file-upload-integration.md index 3881099..0dd8d22 100644 --- a/docs/design/file-upload-integration.md +++ b/docs/design/file-upload-integration.md @@ -172,7 +172,7 @@ if ($upload instanceof ErrorFileUpload) { ```php // In tests, use FileUpload::fromFile() for easy testing $upload = FileUpload::fromFile(__DIR__ . '/fixtures/test-image.jpg'); -$input = $inputQuery->create(UserProfileInput::class, [ +$input = $inputQuery->newInstance(UserProfileInput::class, [ 'name' => 'Test User', 'email' => 'test@example.com' ], ['avatar' => $upload->toArray()]); @@ -307,10 +307,11 @@ if ($upload instanceof ErrorFileUpload) { ``` **3. Testing Integration** + ```php // Natural testing approach $testUpload = FileUpload::fromFile(__DIR__ . '/fixtures/test.jpg'); -$input = $inputQuery->create(UserInput::class, $_POST, [ +$input = $inputQuery->newInstance(UserInput::class, $_POST, [ 'avatar' => $testUpload->toArray() ]); ``` @@ -359,4 +360,4 @@ The proposed integration maintains Ray.InputQuery's declarative philosophy while **Quality Assessment Conclusion**: Koriym.FileUpload exceeds expectations with exceptional code quality, making it an ideal integration candidate. The architectural alignment between both libraries ensures that the proposed integration will deliver a groundbreaking developer experience for PHP file upload processing. -This design preserves backward compatibility while opening up new possibilities for web application development in the Ray ecosystem. \ No newline at end of file +This design preserves backward compatibility while opening up new possibilities for web application development in the Ray ecosystem. diff --git a/docs/framework_integration.md b/docs/framework_integration.md index 0780c55..fc783c1 100644 --- a/docs/framework_integration.md +++ b/docs/framework_integration.md @@ -551,7 +551,7 @@ $result = $method->invokeArgs($controller, $args); ### 2. Direct Object Creation Pattern ```php -$input = $inputQuery->create(UserInput::class, $requestData); +$input = $inputQuery->newInstance(UserInput::class, $requestData); $result = $controller->handleUser($input); ``` diff --git a/docs/prompts/usage-generator.md b/docs/prompts/usage-generator.md index d4bd985..11f9461 100644 --- a/docs/prompts/usage-generator.md +++ b/docs/prompts/usage-generator.md @@ -29,6 +29,7 @@ Ray.InputQuery is a library that: Based on the provided Input classes, generate examples for: ### 1. Basic Ray.InputQuery Usage + ```php use Ray\InputQuery\InputQuery; use Ray\Di\Injector; @@ -37,7 +38,7 @@ $injector = new Injector(); $inputQuery = new InputQuery($injector); // Direct object creation -$input = $inputQuery->create(ExampleInput::class, $_POST); +$input = $inputQuery->newInstance(ExampleInput::class, $_POST); // Method argument resolution $method = new ReflectionMethod(Controller::class, 'action'); @@ -208,12 +209,13 @@ class CsvImportResource extends ResourceObject ``` #### Multi-step Form + ```php // Step 1: Basic Info -$step1 = $inputQuery->create(Step1Input::class, $_SESSION['step1']); +$step1 = $inputQuery->newInstance(Step1Input::class, $_SESSION['step1']); // Step 2: Details -$step2 = $inputQuery->create(Step2Input::class, $_SESSION['step2']); +$step2 = $inputQuery->newInstance(Step2Input::class, $_SESSION['step2']); // Combine for final submission $order = new OrderInput($step1, $step2, $paymentInput); diff --git a/src/FileUploadFactory.php b/src/FileUploadFactory.php index 928d7e0..04d7925 100644 --- a/src/FileUploadFactory.php +++ b/src/FileUploadFactory.php @@ -4,6 +4,7 @@ namespace Ray\InputQuery; +use Koriym\FileUpload\AbstractFileUpload; use Koriym\FileUpload\ErrorFileUpload; use Koriym\FileUpload\FileUpload; use Override; @@ -45,7 +46,7 @@ final class FileUploadFactory implements FileUploadFactoryInterface * @param ReflectionAttribute|null $inputFileAttribute InputFile attribute instance containing validation options */ #[Override] - public function create(ReflectionParameter $param, array $query, ReflectionAttribute|null $inputFileAttribute): FileUpload|ErrorFileUpload + public function create(ReflectionParameter $param, array $query, ReflectionAttribute|null $inputFileAttribute): AbstractFileUpload { $validationOptions = $this->extractValidationOptions($inputFileAttribute); @@ -74,7 +75,7 @@ public function createFromFiles(ReflectionParameter $param, array $filesData, ar * @param Query $query Service locator for pre-created FileUpload objects (testing) or empty array (production) * @param ReflectionAttribute|null $inputFileAttribute InputFile attribute instance containing validation options * - * @return array + * @return array */ #[Override] public function createMultiple(ReflectionParameter $param, array $query, ReflectionAttribute|null $inputFileAttribute): array @@ -127,7 +128,7 @@ private function extractValidationOptions(ReflectionAttribute|null $inputFileAtt * @param ValidationOptions $validationOptions Validation rules for file upload * @param array|null $filesData Custom files data (for createFromFiles) */ - private function resolveFileUpload(ReflectionParameter $param, array $query, array $validationOptions = [], array|null $filesData = null): FileUpload|ErrorFileUpload + private function resolveFileUpload(ReflectionParameter $param, array $query, array $validationOptions = [], array|null $filesData = null): AbstractFileUpload { $paramName = $param->getName(); diff --git a/src/FileUploadFactoryInterface.php b/src/FileUploadFactoryInterface.php index 811619c..2865410 100644 --- a/src/FileUploadFactoryInterface.php +++ b/src/FileUploadFactoryInterface.php @@ -4,8 +4,7 @@ namespace Ray\InputQuery; -use Koriym\FileUpload\ErrorFileUpload; -use Koriym\FileUpload\FileUpload; +use Koriym\FileUpload\AbstractFileUpload; use Ray\InputQuery\Attribute\InputFile; use ReflectionAttribute; use ReflectionParameter; @@ -20,7 +19,7 @@ interface FileUploadFactoryInterface * @param array $query Service locator for pre-created FileUpload objects (testing) or empty array (production) * @param ReflectionAttribute|null $inputFileAttribute InputFile attribute instance containing validation options */ - public function create(ReflectionParameter $param, array $query, ReflectionAttribute|null $inputFileAttribute): FileUpload|ErrorFileUpload; + public function create(ReflectionParameter $param, array $query, ReflectionAttribute|null $inputFileAttribute): AbstractFileUpload; /** * Create multiple FileUploads from InputFile attribute and query data @@ -31,7 +30,7 @@ public function create(ReflectionParameter $param, array $query, ReflectionAttri * @param array $query Service locator for pre-created FileUpload objects (testing) or empty array (production) * @param ReflectionAttribute|null $inputFileAttribute InputFile attribute instance containing validation options * - * @return array + * @return array */ public function createMultiple(ReflectionParameter $param, array $query, ReflectionAttribute|null $inputFileAttribute): array; } diff --git a/src/InputQuery.php b/src/InputQuery.php index 63f5f8b..096f9e3 100644 --- a/src/InputQuery.php +++ b/src/InputQuery.php @@ -69,10 +69,13 @@ */ final class InputQuery implements InputQueryInterface { + private FileUploadFactoryInterface $fileUploadFactory; + public function __construct( private InjectorInterface $injector, - private FileUploadFactoryInterface $fileUploadFactory, + FileUploadFactoryInterface|null $fileUploadFactory = null, ) { + $this->fileUploadFactory = $fileUploadFactory ?? new NullUploadFactory(); } /** @@ -97,7 +100,7 @@ public function getArguments(ReflectionMethod $method, array $query): array * @return T */ #[Override] - public function create(string $class, array $query): object + public function newInstance(string $class, array $query): object { $reflection = new ReflectionClass($class); $constructor = $reflection->getConstructor(); @@ -271,7 +274,7 @@ private function resolveObjectType(ReflectionParameter $param, array $query, arr assert(class_exists($className)); /** @var class-string $className */ - return $this->create($className, $nestedQuery); + return $this->newInstance($className, $nestedQuery); } /** @@ -477,7 +480,7 @@ private function createArrayOfInputs(string $paramName, array $query, string $it // Query parameters from HTTP requests have string keys /** @psalm-var array $itemData */ /** @phpstan-var array $itemData */ - $result[$key] = $this->create($itemClass, $itemData); + $result[$key] = $this->newInstance($itemClass, $itemData); } return $result; diff --git a/src/InputQueryInterface.php b/src/InputQueryInterface.php index 016e696..a49a608 100644 --- a/src/InputQueryInterface.php +++ b/src/InputQueryInterface.php @@ -15,7 +15,7 @@ interface InputQueryInterface /** * Get method arguments from query data * - * @param Query $query HTTP request data ($_POST, $_GET, etc.) + * @param Query $query Array data (HTTP request, test data, etc.) * * @return array */ @@ -25,9 +25,9 @@ public function getArguments(ReflectionMethod $method, array $query): array; * Create object from query data * * @param class-string $class - * @param Query $query HTTP request data ($_POST, $_GET, etc.) + * @param Query $query Array data (HTTP request, test data, etc.) * * @return T */ - public function create(string $class, array $query): object; + public function newInstance(string $class, array $query): object; } diff --git a/src/NullUploadFactory.php b/src/NullUploadFactory.php new file mode 100644 index 0000000..de12468 --- /dev/null +++ b/src/NullUploadFactory.php @@ -0,0 +1,45 @@ + $mockAvatar, ]; - $result = $this->inputQuery->create(FileUploadInput::class, $query); + $result = $this->inputQuery->newInstance(FileUploadInput::class, $query); $this->assertSame('Jingu', $result->name); $this->assertSame($mockAvatar, $result->avatar); @@ -64,7 +64,7 @@ public function testFileUploadWithValidationOptions(): void 'avatar' => $mockAvatar, ]; - $result = $this->inputQuery->create(FileUploadWithOptionsInput::class, $query); + $result = $this->inputQuery->newInstance(FileUploadWithOptionsInput::class, $query); $this->assertSame('Horikawa', $result->name); $this->assertSame($mockAvatar, $result->avatar); @@ -78,7 +78,7 @@ public function testOptionalFileUpload(): void 'banner' => null, ]; - $result = $this->inputQuery->create(OptionalFileUploadInput::class, $query); + $result = $this->inputQuery->newInstance(OptionalFileUploadInput::class, $query); $this->assertSame('Test User', $result->name); $this->assertNull($result->banner); @@ -107,7 +107,7 @@ public function testFileUploadArray(): void 'images' => [$mockImage1, $mockImage2], ]; - $result = $this->inputQuery->create(FileUploadArrayInput::class, $query); + $result = $this->inputQuery->newInstance(FileUploadArrayInput::class, $query); $this->assertSame('Gallery', $result->title); $this->assertCount(2, $result->images); diff --git a/tests/InputFileTest.php b/tests/InputFileTest.php index 7da894e..b5ab1fd 100644 --- a/tests/InputFileTest.php +++ b/tests/InputFileTest.php @@ -38,7 +38,7 @@ public function testCreateFileInputFromQuery(): void ]); $query = ['name' => 'test user', 'avatar' => $fileUpload]; - $input = $this->inputQuery->create(InputFileInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileInput::class, $query); $this->assertInstanceOf(InputFileInput::class, $input); $this->assertSame($fileUpload, $input->avatar); @@ -55,7 +55,7 @@ public function testCreateFileInputWithOptionsFromQuery(): void ]); $query = ['name' => 'test user', 'avatar' => $fileUpload]; - $input = $this->inputQuery->create(InputFileWithOptionsInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileWithOptionsInput::class, $query); $this->assertInstanceOf(InputFileWithOptionsInput::class, $input); $this->assertSame($fileUpload, $input->avatar); @@ -73,7 +73,7 @@ public function testCreateFileInputFromFiles(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(InputFileInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileInput::class, $query); $this->assertInstanceOf(InputFileInput::class, $input); $this->assertInstanceOf(FileUpload::class, $input->avatar); @@ -94,7 +94,7 @@ public function testFileValidationMaxSizeError(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(InputFileValidationInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileValidationInput::class, $query); $this->assertInstanceOf(InputFileValidationInput::class, $input); $this->assertInstanceOf(ErrorFileUpload::class, $input->avatar); @@ -114,7 +114,7 @@ public function testFileValidationTypeError(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(InputFileValidationInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileValidationInput::class, $query); $this->assertInstanceOf(InputFileValidationInput::class, $input); $this->assertInstanceOf(ErrorFileUpload::class, $input->avatar); @@ -134,7 +134,7 @@ public function testFileValidationSuccess(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(InputFileValidationInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileValidationInput::class, $query); $this->assertInstanceOf(InputFileValidationInput::class, $input); $this->assertInstanceOf(FileUpload::class, $input->avatar); @@ -154,7 +154,7 @@ public function testFileValidationExtensionError(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(InputFileExtensionValidationInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileExtensionValidationInput::class, $query); $this->assertInstanceOf(InputFileExtensionValidationInput::class, $input); $this->assertInstanceOf(ErrorFileUpload::class, $input->avatar); @@ -174,7 +174,7 @@ public function testFileValidationExtensionSuccess(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(InputFileExtensionValidationInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileExtensionValidationInput::class, $query); $this->assertInstanceOf(InputFileExtensionValidationInput::class, $input); $this->assertInstanceOf(FileUpload::class, $input->avatar); @@ -193,7 +193,7 @@ public function testFileValidationExtensionCaseInsensitive(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(InputFileExtensionValidationInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileExtensionValidationInput::class, $query); $this->assertInstanceOf(InputFileExtensionValidationInput::class, $input); // This should fail because pathinfo() is case-sensitive @@ -207,7 +207,7 @@ public function testMultipleInputFileAttributesThrowsException(): void $this->expectExceptionMessage('Only one #[InputFile] attribute is allowed per parameter'); $query = ['name' => 'test user']; - $this->inputQuery->create(MultipleInputFileAttributesInput::class, $query); + $this->inputQuery->newInstance(MultipleInputFileAttributesInput::class, $query); } public function testConflictingInputAndInputFileAttributesThrowsException(): void @@ -216,7 +216,7 @@ public function testConflictingInputAndInputFileAttributesThrowsException(): voi $this->expectExceptionMessage('Parameter $conflictingParam cannot have both #[Input] and #[InputFile] attributes at the same time.'); $query = ['name' => 'test user']; - $this->inputQuery->create(ConflictingAttributesInput::class, $query); + $this->inputQuery->newInstance(ConflictingAttributesInput::class, $query); } protected function tearDown(): void diff --git a/tests/InputQueryTest.php b/tests/InputQueryTest.php index d5c977f..979ff8a 100644 --- a/tests/InputQueryTest.php +++ b/tests/InputQueryTest.php @@ -93,7 +93,7 @@ public function testCreateSimpleObject(): void 'email' => 'john@example.com', ]; - $user = $this->inputQuery->create(UserInput::class, $query); + $user = $this->inputQuery->newInstance(UserInput::class, $query); $this->assertInstanceOf(UserInput::class, $user); $this->assertSame('John', $user->name); @@ -108,7 +108,7 @@ public function testCreateNestedObject(): void 'authorEmail' => 'john@example.com', ]; - $todo = $this->inputQuery->create(TodoInput::class, $query); + $todo = $this->inputQuery->newInstance(TodoInput::class, $query); $this->assertInstanceOf(TodoInput::class, $todo); /** @var TodoInput $todo */ @@ -125,7 +125,7 @@ public function testCreateMixedInputAndDI(): void 'email' => 'jane@example.com', ]; - $mixed = $this->inputQuery->create(MixedInput::class, $query); + $mixed = $this->inputQuery->newInstance(MixedInput::class, $query); $this->assertInstanceOf(MixedInput::class, $mixed); assert($mixed instanceof MixedInput); @@ -162,7 +162,7 @@ public function testKeyNormalization(): void 'author-email' => 'author@example.com', // kebab-case ]; - $todo = $this->inputQuery->create(TodoInput::class, [ + $todo = $this->inputQuery->newInstance(TodoInput::class, [ 'title' => 'Test', 'author_name' => 'Author', 'author_email' => 'author@example.com', @@ -181,7 +181,7 @@ public function testEmptyQueryWithDefaults(): void // For now, this might throw exceptions, which is expected behavior $this->expectException(InvalidArgumentException::class); - $this->inputQuery->create(UserInput::class, $query); + $this->inputQuery->newInstance(UserInput::class, $query); } public function testIsInstanceOfInputQuery(): void @@ -199,7 +199,7 @@ public function testScalarTypeConversions(): void 'active' => '1', // string -> bool ]; - $scalar = $this->inputQuery->create(ScalarInput::class, $query); + $scalar = $this->inputQuery->newInstance(ScalarInput::class, $query); $this->assertInstanceOf(ScalarInput::class, $scalar); assert($scalar instanceof ScalarInput); @@ -227,7 +227,7 @@ public function testBooleanConversions(): void 'active' => $case['active'], ]; - $scalar = $this->inputQuery->create(ScalarInput::class, $query); + $scalar = $this->inputQuery->newInstance(ScalarInput::class, $query); assert($scalar instanceof ScalarInput); $this->assertSame($case['expected'], $scalar->active, "Failed for active='{$case['active']}'."); } @@ -237,7 +237,7 @@ public function testDefaultValues(): void { $query = ['name' => 'John']; // Other parameters should use defaults - $defaultInput = $this->inputQuery->create(DefaultValuesInput::class, $query); + $defaultInput = $this->inputQuery->newInstance(DefaultValuesInput::class, $query); assert($defaultInput instanceof DefaultValuesInput); $this->assertSame('John', $defaultInput->name); @@ -256,7 +256,7 @@ public function testPartialDefaultValues(): void // active and score should use defaults ]; - $defaultInput = $this->inputQuery->create(DefaultValuesInput::class, $query); + $defaultInput = $this->inputQuery->newInstance(DefaultValuesInput::class, $query); assert($defaultInput instanceof DefaultValuesInput); $this->assertSame('Jane', $defaultInput->name); @@ -270,7 +270,7 @@ public function testNullableValues(): void { $query = []; - $nullable = $this->inputQuery->create(NullableInput::class, $query); + $nullable = $this->inputQuery->newInstance(NullableInput::class, $query); assert($nullable instanceof NullableInput); $this->assertNull($nullable->name); @@ -286,7 +286,7 @@ public function testNullScalarConversion(): void 'active' => null, ]; - $nullable = $this->inputQuery->create(NullableInput::class, $query); + $nullable = $this->inputQuery->newInstance(NullableInput::class, $query); assert($nullable instanceof NullableInput); $this->assertNull($nullable->name); @@ -298,7 +298,7 @@ public function testNoConstructorClass(): void { $query = ['name' => 'test']; - $noConstructor = $this->inputQuery->create(NoConstructorInput::class, $query); + $noConstructor = $this->inputQuery->newInstance(NoConstructorInput::class, $query); $this->assertInstanceOf(NoConstructorInput::class, $noConstructor); $this->assertSame('default', $noConstructor->name); @@ -314,7 +314,7 @@ public function testComplexNestedPrefix(): void ]; // Create a TodoInput where author should be mapped from author prefix - $todo = $this->inputQuery->create(TodoInput::class, $query); + $todo = $this->inputQuery->newInstance(TodoInput::class, $query); assert($todo instanceof TodoInput); $this->assertSame('Main Task', $todo->title); @@ -332,7 +332,7 @@ public function testEmptyNestedQueryFallback(): void 'email' => 'direct@example.com', ]; - $todo = $this->inputQuery->create(TodoInput::class, $query); + $todo = $this->inputQuery->newInstance(TodoInput::class, $query); assert($todo instanceof TodoInput); $this->assertSame('Task without prefix', $todo->title); @@ -369,7 +369,7 @@ public function testUnionTypes(): void 'name' => 'union', ]; - $union = $this->inputQuery->create(UnionTypeInput::class, $query); + $union = $this->inputQuery->newInstance(UnionTypeInput::class, $query); $this->assertInstanceOf(UnionTypeInput::class, $union); assert($union instanceof UnionTypeInput); @@ -381,7 +381,7 @@ public function testUnionTypesWithDefaults(): void { $query = []; // Use defaults - $union = $this->inputQuery->create(UnionTypeInput::class, $query); + $union = $this->inputQuery->newInstance(UnionTypeInput::class, $query); $this->assertInstanceOf(UnionTypeInput::class, $union); assert($union instanceof UnionTypeInput); @@ -412,7 +412,7 @@ public function testConvertScalarDefaultType(): void 'active' => '1', ]; - $scalar = $this->inputQuery->create(ScalarInput::class, $query); + $scalar = $this->inputQuery->newInstance(ScalarInput::class, $query); assert($scalar instanceof ScalarInput); // Ensure all types are converted correctly - values are tested in other methods @@ -428,7 +428,7 @@ public function testExtractNestedQueryWithEmptyKey(): void 'authorEmail' => 'john@example.com', ]; - $todo = $this->inputQuery->create(TodoInput::class, [ + $todo = $this->inputQuery->newInstance(TodoInput::class, [ 'title' => 'Test', ...$query, ]); @@ -445,7 +445,7 @@ public function testGetDefaultValueWithoutDefault(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Required parameter "name" is missing and has no default value'); - $this->inputQuery->create(UserInput::class, ['email' => 'test@example.com']); // missing required 'name' + $this->inputQuery->newInstance(UserInput::class, ['email' => 'test@example.com']); // missing required 'name' } public function testNonNamedTypeParameter(): void @@ -1045,7 +1045,7 @@ public function testInputFileCreateMethod(): void // Use existing InputFileInput class that has #[InputFile] attribute $query = ['name' => 'test user']; - $input = $this->inputQuery->create(InputFileInput::class, $query); + $input = $this->inputQuery->newInstance(InputFileInput::class, $query); $this->assertInstanceOf(InputFileInput::class, $input); $this->assertInstanceOf(FileUpload::class, $input->avatar); @@ -1115,7 +1115,7 @@ public function testNullableFileUploadWithNoFile(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(NullableFileInput::class, $query); + $input = $this->inputQuery->newInstance(NullableFileInput::class, $query); $this->assertInstanceOf(NullableFileInput::class, $input); $this->assertNull($input->avatar); // Should be null for nullable parameter @@ -1133,7 +1133,7 @@ public function testDefaultFileUploadWithNoFile(): void ]; $query = ['name' => 'test user']; - $input = $this->inputQuery->create(DefaultFileInput::class, $query); + $input = $this->inputQuery->newInstance(DefaultFileInput::class, $query); $this->assertInstanceOf(DefaultFileInput::class, $input); $this->assertNull($input->avatar); // Should use default value (null)