From 6b1a3852928f8104878d435171fdca152907c95c Mon Sep 17 00:00:00 2001 From: icefox Date: Mon, 23 Feb 2026 21:35:08 -0300 Subject: [PATCH 1/7] refactor --- src/Config.php | 2 +- ...llectionFactory.php => CustomHandlers.php} | 10 +- src/DataObjectFactory.php | 12 +- src/InputFactory.php | 41 ----- src/ReflectionHelper.php | 1 - src/{Support => }/RuleFactory.php | 3 +- src/{Support => }/ValueFactory.php | 7 +- tests/DataObjectTest.php | 2 +- tests/Rules/RulesTest.php | 2 +- tests/RulesTest.php | 4 +- tests/ValuesTest.php | 165 ++++++++++++++++++ workbench/config/dto.php | 5 + workbench/phpunit.xml | 17 -- 13 files changed, 191 insertions(+), 80 deletions(-) rename src/{Factories/CollectionFactory.php => CustomHandlers.php} (75%) delete mode 100644 src/InputFactory.php rename src/{Support => }/RuleFactory.php (99%) rename src/{Support => }/ValueFactory.php (98%) create mode 100644 tests/ValuesTest.php delete mode 100644 workbench/phpunit.xml diff --git a/src/Config.php b/src/Config.php index f827157..e804397 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Icefox\DTO; -use Icefox\DTO\Support\RuleFactory; +use Icefox\DTO\RuleFactory; use Illuminate\Support\Collection; use phpDocumentor\Reflection\PseudoTypes\Generic; diff --git a/src/Factories/CollectionFactory.php b/src/CustomHandlers.php similarity index 75% rename from src/Factories/CollectionFactory.php rename to src/CustomHandlers.php index bfd0fe0..075e1ba 100644 --- a/src/Factories/CollectionFactory.php +++ b/src/CustomHandlers.php @@ -1,17 +1,16 @@ */ - public static function rules(ParameterMeta $parameter, RuleFactory $factory): array + public static function CollectionRules(ParameterMeta $parameter, RuleFactory $factory): array { if (is_null($parameter->tag)) { return []; @@ -36,3 +35,4 @@ class CollectionFactory ); } } + diff --git a/src/DataObjectFactory.php b/src/DataObjectFactory.php index adbf200..78ece59 100644 --- a/src/DataObjectFactory.php +++ b/src/DataObjectFactory.php @@ -4,14 +4,14 @@ namespace Icefox\DTO; use Icefox\DTO\Attributes\FromInput; use Icefox\DTO\Attributes\FromRouteParameter; -use Icefox\DTO\Support\RuleFactory; -use Icefox\DTO\Support\ValueFactory; +use Icefox\DTO\RuleFactory; +use Icefox\DTO\ValueFactory; use Illuminate\Http\Request; use Illuminate\Routing\Route; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; -use ReflectionClass; class DataObjectFactory { @@ -31,7 +31,7 @@ class DataObjectFactory */ public static function fromArray(string $class, array $input, array $routeParameters): ?object { - $logger = new Log(); + $logger = Log::channel('dto'); $parameters = ReflectionHelper::getParametersMeta($class); foreach ($parameters as $parameter) { $parameterName = $parameter->reflection->getName(); @@ -60,7 +60,7 @@ class DataObjectFactory // continue; // } } - $logger->inputRaw($input); + $logger->debug('input', $input); $rules = (new RuleFactory($logger))->make($class); @@ -69,7 +69,7 @@ class DataObjectFactory : App::makeWith(Validator::class, ['data' => $input, 'rules' => $rules]); if ($validator->fails()) { - $logger->validationErrors($validator->errors()->toArray()); + $logger->warning('validation error', $validator->errors()->toArray()); if (method_exists($class, 'fails')) { return App::call("$class::fails", ['validator' => $validator ]); } diff --git a/src/InputFactory.php b/src/InputFactory.php deleted file mode 100644 index 7109eee..0000000 --- a/src/InputFactory.php +++ /dev/null @@ -1,41 +0,0 @@ -reflection->getName(); - - foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $attr) { - $map[$name][] = 'route_' . $attr->newInstance()->name; - } - - foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { - $map[$name][] = $attr->newInstance()->name; - } - - $map[$name][] = $name; - } - return $map; - } - - private static self $_instance; - - public static function instance(): self - { - if (empty(self::$_instance)) { - self::$_instance = new self(new Log()); - } - return self::$_instance; - } -} diff --git a/src/ReflectionHelper.php b/src/ReflectionHelper.php index 5c92a22..1ab7448 100644 --- a/src/ReflectionHelper.php +++ b/src/ReflectionHelper.php @@ -45,4 +45,3 @@ class ReflectionHelper return self::$cache[$class]; } } - diff --git a/src/Support/RuleFactory.php b/src/RuleFactory.php similarity index 99% rename from src/Support/RuleFactory.php rename to src/RuleFactory.php index 9aa1da5..c7a9e05 100644 --- a/src/Support/RuleFactory.php +++ b/src/RuleFactory.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace Icefox\DTO\Support; +namespace Icefox\DTO; use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\OverwriteRules; use Icefox\DTO\Config; use Icefox\DTO\ParameterMeta; use Icefox\DTO\ReflectionHelper; +use Icefox\DTO\RuleFactory; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; diff --git a/src/Support/ValueFactory.php b/src/ValueFactory.php similarity index 98% rename from src/Support/ValueFactory.php rename to src/ValueFactory.php index 2781ae2..53de322 100644 --- a/src/Support/ValueFactory.php +++ b/src/ValueFactory.php @@ -2,16 +2,14 @@ declare(strict_types=1); -namespace Icefox\DTO\Support; +namespace Icefox\DTO; use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Config; -use Icefox\DTO\ParameterMeta; use Icefox\DTO\ReflectionHelper; use Illuminate\Support\Facades\App; use ReflectionNamedType; -use ReflectionParameter; use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\PseudoTypes\Generic; use phpDocumentor\Reflection\Type; @@ -102,6 +100,9 @@ class ValueFactory }; } + /** + * @param array $input + */ public static function make(string $class, array $input): object { $parameters = ReflectionHelper::getParametersMeta($class); diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index b68a4b7..de463e5 100644 --- a/tests/DataObjectTest.php +++ b/tests/DataObjectTest.php @@ -3,7 +3,7 @@ namespace Tests; use Icefox\DTO\Log; -use Icefox\DTO\Support\RuleFactory; +use Icefox\DTO\RuleFactory; use Illuminate\Validation\ValidationException; use Tests\Classes\ArrayDataObject; use Tests\Classes\CollectionDataObject; diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index 5f7ed9f..c2a2da2 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -6,7 +6,7 @@ namespace Tests\Rules; use Icefox\DTO\Log; use Icefox\DTO\ReflectionHelper; -use Icefox\DTO\Support\RuleFactory; +use Icefox\DTO\RuleFactory; use Illuminate\Validation\ValidationException; use Tests\Rules\WithEmptyOverwriteRules; use Tests\Rules\WithMergedRules; diff --git a/tests/RulesTest.php b/tests/RulesTest.php index 8ffeff5..8e06401 100644 --- a/tests/RulesTest.php +++ b/tests/RulesTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace Tests; use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Support\RuleFactory; +use Icefox\DTO\RuleFactory; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Validator; readonly class BasicPrimitives { diff --git a/tests/ValuesTest.php b/tests/ValuesTest.php new file mode 100644 index 0000000..05d2ac8 --- /dev/null +++ b/tests/ValuesTest.php @@ -0,0 +1,165 @@ + 'abc', + 'number' => 42, + 'flag' => false, + 'items' => ['a', 2, true], + 'floating' => 4.2, + ]); + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(false); + expect($object->items)->toBe(['a', 2, true]); + expect($object->floating)->toEqualWithDelta(4.2, 0.000001); +}); + +readonly class AnnotatedArray +{ + /** + * @param array $items + */ + public function __construct(public array $items) {} +} +test('annotated array', function () { + expect(RuleFactory::instance()->make(AnnotatedArray::class))->toBe([ + 'items' => ['required', 'array'], + 'items.*' => ['required', 'numeric'], + ]); +}); + +readonly class AnnotatedArrayNullableValue +{ + /** + * @param array $items + */ + public function __construct(public array $items) {} +} +test('annotated array with nullable items', function () { + expect(RuleFactory::instance()->make(AnnotatedArrayNullableValue::class))->toBe([ + 'items' => ['required', 'array'], + 'items.*' => ['nullable', 'numeric'], + ]); +}); + +readonly class PlainLeaf +{ + public function __construct(public string $name) {} +} +readonly class PlainRoot +{ + public function __construct(public int $value, public PlainLeaf $leaf) {} +} + +test('plain nesting', function () { + expect(RuleFactory::instance()->make(PlainRoot::class))->toBe([ + 'value' => ['required', 'numeric'], + 'leaf' => ['required'], + 'leaf.name' => ['required'], + ]); +}); + + +readonly class AnnotatedArrayItem +{ + public function __construct(public int $value) {} +} + +readonly class AnnotatedArrayObject +{ + /** + * @param ?array $items + */ + public function __construct(public ?array $items) {} +} + +test('annotated array with object', function () { + expect(RuleFactory::instance()->make(AnnotatedArrayObject::class))->toBe([ + 'items' => ['nullable', 'array'], + 'items.*' => ['required'], + 'items.*.value' => ['required', 'numeric'], + ]); +}); + +readonly class FlattenedLeaf +{ + public function __construct(public ?bool $flag) {} +} + +readonly class NotFlattenedLeaf +{ + public function __construct(public string $description) {} +} + +readonly class FlattenedNode +{ + public function __construct( + public string $id, + public NotFlattenedLeaf $leaf, + #[Flat] + public FlattenedLeaf $squish, + public int $level = 1, + ) {} +} + +readonly class FlattenedRoot +{ + public function __construct( + public int $value, + #[Flat] + public FlattenedNode $node, + ) {} +} + +test('flattened basic', function () { + expect(RuleFactory::instance()->make(FlattenedRoot::class))->toBe([ + 'value' => ['required', 'numeric'], + 'id' => ['required' ], + 'leaf' => ['required'], + 'leaf.description' => ['required'], + 'flag' => ['nullable', 'boolean'], + 'level' => ['sometimes', 'numeric'], + ]); +}); + +readonly class AnnotatedCollectionItem +{ + public function __construct(public int $value) {} +} + +readonly class AnnotatedCollection +{ + /** + * @param Collection $group + */ + public function __construct(public Collection $group) {} +} + +test('annotated collection', function () { + expect(RuleFactory::instance()->make(AnnotatedCollection::class))->toBe([ + 'group' => ['required', 'array'], + 'group.*' => ['required'], + 'group.*.value' => ['required', 'numeric'], + ]); +}); diff --git a/workbench/config/dto.php b/workbench/config/dto.php index c89046d..d212857 100644 --- a/workbench/config/dto.php +++ b/workbench/config/dto.php @@ -1,8 +1,13 @@ [ + Collection::class => CustomHandlers::CollectionRules(...), + ], 'logging' => [ 'channel' => 'dto', 'context' => [ diff --git a/workbench/phpunit.xml b/workbench/phpunit.xml deleted file mode 100644 index 0788ab5..0000000 --- a/workbench/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - tests - - - - - app - - - From bba10b455f9d11ee6d68b886c1e58babc12ec541 Mon Sep 17 00:00:00 2001 From: icefox Date: Wed, 25 Feb 2026 12:29:47 -0300 Subject: [PATCH 2/7] tests --- .../{OverwriteRules.php => Overwrite.php} | 4 +- src/DataObjectFactory.php | 76 +++--- src/RuleFactory.php | 18 +- tests/DataObjectTest.php | 234 +++------------- tests/Rules/RulesTest.php | 100 ------- tests/Rules/WithEmptyOverwriteRules.php | 23 -- tests/Rules/WithMergedRules.php | 23 -- tests/Rules/WithOverwriteRules.php | 25 -- tests/RulesTest.php | 52 ++++ tests/ValuesTest.php | 255 +++++++++++------- 10 files changed, 296 insertions(+), 514 deletions(-) rename src/Attributes/{OverwriteRules.php => Overwrite.php} (82%) delete mode 100644 tests/Rules/RulesTest.php delete mode 100644 tests/Rules/WithEmptyOverwriteRules.php delete mode 100644 tests/Rules/WithMergedRules.php delete mode 100644 tests/Rules/WithOverwriteRules.php diff --git a/src/Attributes/OverwriteRules.php b/src/Attributes/Overwrite.php similarity index 82% rename from src/Attributes/OverwriteRules.php rename to src/Attributes/Overwrite.php index e1cee40..63a6453 100644 --- a/src/Attributes/OverwriteRules.php +++ b/src/Attributes/Overwrite.php @@ -7,6 +7,4 @@ namespace Icefox\DTO\Attributes; use Attribute; #[Attribute(Attribute::TARGET_METHOD)] -class OverwriteRules -{ -} +class Overwrite {} diff --git a/src/DataObjectFactory.php b/src/DataObjectFactory.php index 78ece59..3a14c7e 100644 --- a/src/DataObjectFactory.php +++ b/src/DataObjectFactory.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; +use Psr\Log\LoggerInterface; class DataObjectFactory { @@ -26,41 +27,13 @@ class DataObjectFactory /** * @param class-string $class - * @param array $input + * @param array $rawInput * @param array $routeParameters */ - public static function fromArray(string $class, array $input, array $routeParameters): ?object + public static function fromArray(string $class, array $rawInput, array $routeParameters): ?object { $logger = Log::channel('dto'); - $parameters = ReflectionHelper::getParametersMeta($class); - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $fromRouteParameter) { - if ($value = $routeParameters[$fromRouteParameter->newInstance()->name] ?? null) { - $input[$parameterName] = $value; - continue 2; - } - } - - foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { - if ($value = $input[$attr->newInstance()->name] ?? null) { - $input[$parameterName] = $value; - continue 2; - } - } - - if ($value = $input[$parameterName] ?? null) { - $input[$parameterName] = $value; - continue; - } - - // if ($parameter->reflection->isDefaultValueAvailable()) { - // $input[$parameterName] = $parameter->reflection->getDefaultValue(); - // continue; - // } - } - $logger->debug('input', $input); + $input = self::mapInput($class, $rawInput, $routeParameters, $logger); $rules = (new RuleFactory($logger))->make($class); @@ -78,4 +51,45 @@ class DataObjectFactory return ValueFactory::make($class, $validator->validated()); } + + + /** + * @param class-string $class + * @param array $rawInput + * @param array $routeParameters + * @return array + */ + public static function mapInput( + string $class, + array $rawInput, + array $routeParameters, + LoggerInterface $logger, + ): array { + $input = []; + $parameters = ReflectionHelper::getParametersMeta($class); + foreach ($parameters as $parameter) { + $parameterName = $parameter->reflection->getName(); + + foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $fromRouteParameter) { + if ($value = $routeParameters[$fromRouteParameter->newInstance()->name] ?? null) { + $input[$parameterName] = $value; + continue 2; + } + } + + foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { + if ($value = $rawInput[$attr->newInstance()->name] ?? null) { + $input[$parameterName] = $value; + continue 2; + } + } + + if ($value = $rawInput[$parameterName] ?? null) { + $input[$parameterName] = $value; + continue; + } + } + $logger->debug('input', $input); + return $input; + } } diff --git a/src/RuleFactory.php b/src/RuleFactory.php index c7a9e05..7ff6627 100644 --- a/src/RuleFactory.php +++ b/src/RuleFactory.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Icefox\DTO; use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Attributes\OverwriteRules; +use Icefox\DTO\Attributes\Overwrite; use Icefox\DTO\Config; use Icefox\DTO\ParameterMeta; use Icefox\DTO\ReflectionHelper; @@ -30,7 +30,7 @@ use phpDocumentor\Reflection\Types\Object_; final class RuleFactory { /** - * @return array> + * @return array> */ public function getRulesFromDocBlock( Type $type, @@ -63,7 +63,7 @@ final class RuleFactory /** * @param array $parameters - * @return array> + * @return array> */ public function infer(array $parameters, string $basePrefix): array { @@ -80,7 +80,7 @@ final class RuleFactory } /** - * @return array> + * @return array> */ public function buildParameterRule(ParameterMeta $parameter, string $prefix): array { @@ -150,7 +150,7 @@ final class RuleFactory /** * @param class-string $class - * @return array> + * @return array> */ public function make(string $class): array { @@ -162,7 +162,7 @@ final class RuleFactory $customRules = $hasRulesMethod ? App::call("$class::rules", []) : []; - if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::class))) { + if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(Overwrite::class))) { $rules = $customRules; } else { $inferredRules = RuleFactory::infer($parameters, ''); @@ -173,9 +173,9 @@ final class RuleFactory } /** - * @param array> $first - * @param array> $second - * @return array> + * @param array> $first + * @param array> $second + * @return array> */ public function mergeRules(array $first, array $second): array { diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index de463e5..8886ac2 100644 --- a/tests/DataObjectTest.php +++ b/tests/DataObjectTest.php @@ -2,211 +2,43 @@ namespace Tests; -use Icefox\DTO\Log; -use Icefox\DTO\RuleFactory; -use Illuminate\Validation\ValidationException; -use Tests\Classes\ArrayDataObject; -use Tests\Classes\CollectionDataObject; -use Tests\Classes\FromInputObject; -use Tests\Classes\OptionalData; -use Tests\Classes\OptionalNullableData; -use Tests\Classes\PrimitiveData; -use Tests\Classes\RecursiveDataObject; -use Tests\Classes\WithMapperObject; +use Icefox\DTO\Attributes\FromInput; +use Icefox\DTO\DataObjectFactory; +use Illuminate\Support\Collection; +use Psr\Log\NullLogger; -describe('primitive data test', function () { - it('creates required rules', function () { - $rules = (new RuleFactory(new Log()))->make(PrimitiveData::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'int' => ['required', 'numeric'], - 'float' => ['required', 'numeric'], - 'bool' => ['required', 'boolean'], - ]); - }); +readonly class MappedCollectionItem +{ + public function __construct(#[FromInput('id_item')] public int $idItem) {} +} - it('creates object with all required properties', function () { - $object = PrimitiveData::fromArray([ - 'string' => 'abc', - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - expect($object)->toBeInstanceOf(PrimitiveData::class); - expect($object->string)->toBe('abc'); - expect($object->int)->toBe(0); - expect($object->float)->toEqualWithDelta(3.14, 0.0001); - expect($object->bool)->toBeTrue(); - }); -}); +readonly class MappedCollectionRoot +{ + /** + * @param Collection $items + */ + public function __construct(public string $text, #[FromInput('data')] public Collection $items) {} +} -describe('optional data', function () { - it('creates optional rules', function () { - $rules = (new RuleFactory(new Log()))->make(OptionalData::class); - expect($rules)->toMatchArray([ - 'string' => ['sometimes'], - 'int' => ['sometimes', 'numeric'], - 'float' => ['sometimes', 'numeric'], - 'bool' => ['sometimes', 'boolean'], - ]); - }); +test('using from input', function () { + $mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [ + 'text' => 'abc', + 'data' => [ + [ 'id_item' => 1 ], + [ 'id_item' => 2 ], + [ 'id_item' => 4 ], + [ 'id_item' => 8 ], + ], + ], [], new NullLogger()); + var_dump($mapped); - it('creates object with default values', function () { - $object = OptionalData::fromArray([]); - expect($object)->toBeInstanceOf(OptionalData::class); - expect($object->string)->toBe('xyz'); - expect($object->int)->toBe(3); - expect($object->float)->toEqualWithDelta(0.777, 0.0001); - expect($object->bool)->toBeFalse(); - }); -}); - -describe('nullable data', function () { - it('creates nullable rules', function () { - $rules = (new RuleFactory(new Log()))->make(OptionalNullableData::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'int' => ['nullable', 'numeric'], - 'float' => ['sometimes', 'numeric'], - 'bool' => ['sometimes', 'boolean'], - ]); - }); - - it('accepts explicit null', function () { - $object = OptionalNullableData::fromArray([ - 'string' => 'ijk', - 'int' => null, - ]); - expect($object)->toBeInstanceOf(OptionalNullableData::class); - expect($object->string)->toBe('ijk'); - expect($object->int)->toBeNull(); - }); - - it('accepts implicit null', function () { - $object = OptionalNullableData::fromArray(['string' => 'dfg']); - expect($object)->toBeInstanceOf(OptionalNullableData::class); - expect($object->string)->toBe('dfg'); - expect($object->int)->toBeNull(); - }); -}); - -describe('reference other DataObject', function () { - - it('creates recursive rules', function () { - $rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'extra.string' => ['required'], - 'extra.int' => ['required', 'numeric'], - 'extra.float' => ['required', 'numeric'], - 'extra.bool' => ['required', 'boolean'], - ]); - }); -}); - -describe('primitive array', function () { - it('creates array rules', function () { - $rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class); - expect($rules)->toMatchArray([ - 'values' => ['required', 'array'], - 'values.*' => ['required', 'numeric'], - ]); - }); -}); - - -describe('object array', function () { - it('creates array rules', function () { - $rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class); - expect($rules)->toMatchArray([ - 'values' => ['required', 'array'], - 'values.*' => ['required'], - 'values.*.string' => ['required'], - 'values.*.int' => ['nullable', 'numeric'], - 'values.*.float' => ['sometimes', 'numeric'], - 'values.*.bool' => ['sometimes', 'boolean'], - ]); - }); -}); - -describe('can map input names', function () { - - it('creates rules with property names', function () { - - $rules = (new RuleFactory(new Log()))->make(FromInputObject::class); - expect($rules)->toMatchArray([ - 'text' => ['required' ], - 'standard' => ['required', 'numeric'], - ]); - }); - - it('maps input name', function () { - $object = FromInputObject::fromArray([ - 'other_name' => 'xyz', - 'standard' => 1, - ]); - expect($object->text)->toBe('xyz'); - expect($object->standard)->toBe(1); - }); - - it('prioritizes the mapped input', function () { - $object = FromInputObject::fromArray([ - 'other_name' => 'xyz', - 'text' => 'abc', - 'standard' => 1, - ]); - expect($object->text)->toBe('xyz'); - expect($object->standard)->toBe(1); - }); -}); - -describe('with mapper object', function () { - it('uses mapper', function () { - $object = WithMapperObject::fromArray([ - 'period' => [ - 'start' => '1980-01-01', - 'end' => '1990-01-01', - ], - 'standard' => 1, - ]); - expect($object->period->startsAt('1980-01-01'))->toBeTrue(); - expect($object->period->endsAt('1990-01-01'))->toBeTrue(); - }); - - it('uses mapper as validator', function () { - $object = WithMapperObject::fromArray([ - 'period' => [ - 'end' => '1990-01-01', - ], - 'standard' => 1, - ]); - })->throws(ValidationException::class); -}); - -test('failed validation throws ValidationException', function () { - $object = PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); -})->throws(ValidationException::class); - - -test('creates collection', function () { - $object = CollectionDataObject::fromArray([ - 'values' => [ - [ - 'string' => 'x', - 'int' => 1, - 'float' => 3.3, - ], - [ - 'string' => 'y', - 'int' => null, - ], + expect($mapped)->toBe([ + 'text' => 'abc', + 'items' => [ + [ 'idItem' => 1 ], + [ 'idItem' => 2 ], + [ 'idItem' => 4 ], + [ 'idItem' => 8 ], ], ]); - expect($object->values->count())->toBe(2); - expect($object->values[0]->string)->toBe('x'); - expect($object->values[1]->int)->toBeNull(); }); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php deleted file mode 100644 index c2a2da2..0000000 --- a/tests/Rules/RulesTest.php +++ /dev/null @@ -1,100 +0,0 @@ -toBe([ - 'value' => ['required', 'numeric'], - ]); - }); - - it('returns inferred rules shape regardless of OverwriteRules attribute', function () { - $parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class); - $rules = RuleFactory::infer($parameters, ''); - - expect($rules)->toBe([ - 'value' => ['required', 'numeric'], - ]); - }); -}); - -describe('getRules method', function () { - it('returns merged rules from DataObject::getRules()', function () { - $rules = (new RuleFactory(new Log()))->make(WithMergedRules::class); - - expect($rules)->toBe([ - 'value' => ['required', 'numeric', 'max:20'], - ]); - }); - - it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () { - $rules = (new RuleFactory(new Log()))->make(WithOverwriteRules::class); - - expect($rules)->toBe([ - 'value' => ['numeric', 'max:20'], - ]); - }); - - it('returns empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () { - $rules = (new RuleFactory(new Log()))->make(WithEmptyOverwriteRules::class); - - expect($rules)->toBe([]); - }); -}); - -describe('rules merging', function () { - it('merges custom rules with inferred rules by default', function () { - $object = WithMergedRules::fromArray([ - 'value' => 10, - ]); - - expect($object->value)->toBe(10); - }); - - it('fails validation when merged rule is violated', function () { - expect(fn() => WithMergedRules::fromArray([ - 'value' => 25, - ]))->toThrow(ValidationException::class); - }); - - it('fails validation when required rule is violated (inferred)', function () { - expect(fn() => WithMergedRules::fromArray([ - ]))->toThrow(ValidationException::class); - }); -}); - -describe('rules overwrite', function () { - it('uses only custom rules when OverwriteRules attribute is present', function () { - $object = WithOverwriteRules::fromArray([ - 'value' => 10, - ]); - - expect($object->value)->toBe(10); - }); - - it('fails validation when custom rule is violated', function () { - expect(fn() => WithOverwriteRules::fromArray([ - 'value' => 25, - ]))->toThrow(ValidationException::class); - }); - - it('does not enforce inferred required rule when overwritten', function () { - $rules = WithOverwriteRules::rules(); - expect($rules)->toHaveKey('value'); - expect($rules['value'])->toBe(['numeric', 'max:20']); - }); -}); diff --git a/tests/Rules/WithEmptyOverwriteRules.php b/tests/Rules/WithEmptyOverwriteRules.php deleted file mode 100644 index e743bed..0000000 --- a/tests/Rules/WithEmptyOverwriteRules.php +++ /dev/null @@ -1,23 +0,0 @@ - ['max:20'], - ]; - } -} diff --git a/tests/Rules/WithOverwriteRules.php b/tests/Rules/WithOverwriteRules.php deleted file mode 100644 index 21b2145..0000000 --- a/tests/Rules/WithOverwriteRules.php +++ /dev/null @@ -1,25 +0,0 @@ - ['numeric', 'max:20'], - ]; - } -} diff --git a/tests/RulesTest.php b/tests/RulesTest.php index 8e06401..34c48cf 100644 --- a/tests/RulesTest.php +++ b/tests/RulesTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests; use Icefox\DTO\Attributes\Flat; +use Icefox\DTO\Attributes\Overwrite; use Icefox\DTO\RuleFactory; use Illuminate\Support\Collection; @@ -157,3 +158,54 @@ test('annotated collection', function () { 'group.*.value' => ['required', 'numeric'], ]); }); + + +readonly class MergedRules +{ + public function __construct(public int $value, public string $text) {} + + /** + * @return array> + */ + public static function rules(): array + { + // only customized fields need to be provided + return [ + 'value' => ['min:20'], + ]; + } +} + +test('merging rules', function () { + expect(RuleFactory::instance()->make(MergedRules::class))->toBe([ + 'value' => ['required', 'numeric', 'min:20'], + 'text' => ['required'], + ]); +}); + +readonly class OverwriteRules +{ + // union types are not supported, generated rules are undefined + public function __construct(public int|bool $value, public string $text) {} + + /** + * @return array> + */ + #[Overwrite] + public static function rules(): array + { + // when overwriting, all fields must be provided with all rules, disables rules inference. + return [ + 'value' => ['required', function ($attribute, $value, $fail) { + if (!is_int($value) && !is_bool($value)) { + $fail("$attribute must be an integer or an array."); + } + }], + 'text' => ['required'], + ]; + } +} + +test('overwriting rules', function () { + expect(RuleFactory::instance()->make(OverwriteRules::class))->toHaveKeys(['value', 'text']); +}); diff --git a/tests/ValuesTest.php b/tests/ValuesTest.php index 05d2ac8..381900d 100644 --- a/tests/ValuesTest.php +++ b/tests/ValuesTest.php @@ -4,9 +4,14 @@ declare(strict_types=1); namespace Tests; +use Carbon\CarbonPeriod; +use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\Flat; +use Icefox\DTO\Attributes\FromInput; +use Icefox\DTO\Attributes\Overwrite; use Icefox\DTO\RuleFactory; use Icefox\DTO\ValueFactory; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; readonly class BasicPrimitives @@ -16,150 +21,202 @@ readonly class BasicPrimitives public int $number, public bool $flag, public ?array $items, - public float $floating = 0.0, + public float $floating = 4.7, ) {} } -test('required rules', function () { +test('basic creation works', function () { $object = ValueFactory::make(BasicPrimitives::class, [ 'text' => 'abc', 'number' => 42, - 'flag' => false, - 'items' => ['a', 2, true], - 'floating' => 4.2, + 'flag' => true, + 'items' => ['a', 2, false], + 'floating' => 32.6, ]); + expect($object->text)->toBe('abc'); expect($object->number)->toBe(42); - expect($object->flag)->toBe(false); - expect($object->items)->toBe(['a', 2, true]); - expect($object->floating)->toEqualWithDelta(4.2, 0.000001); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(32.6); }); -readonly class AnnotatedArray -{ - /** - * @param array $items - */ - public function __construct(public array $items) {} -} -test('annotated array', function () { - expect(RuleFactory::instance()->make(AnnotatedArray::class))->toBe([ - 'items' => ['required', 'array'], - 'items.*' => ['required', 'numeric'], +test('uses default values', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => ['a', 2, false], ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(4.7); }); -readonly class AnnotatedArrayNullableValue -{ - /** - * @param array $items - */ - public function __construct(public array $items) {} -} -test('annotated array with nullable items', function () { - expect(RuleFactory::instance()->make(AnnotatedArrayNullableValue::class))->toBe([ - 'items' => ['required', 'array'], - 'items.*' => ['nullable', 'numeric'], +test('uses default when null and not nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => ['a', 2, false], + 'floating' => null, ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(4.7); }); -readonly class PlainLeaf -{ - public function __construct(public string $name) {} -} -readonly class PlainRoot -{ - public function __construct(public int $value, public PlainLeaf $leaf) {} -} - -test('plain nesting', function () { - expect(RuleFactory::instance()->make(PlainRoot::class))->toBe([ - 'value' => ['required', 'numeric'], - 'leaf' => ['required'], - 'leaf.name' => ['required'], +test('accepts null when nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => null, ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(null); + expect($object->floating)->toBe(4.7); +}); + +test('accepts missing as null when nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(null); + expect($object->floating)->toBe(4.7); +}); + +readonly class NestedLeaf +{ + public function __construct(public bool $flag) {} +} + +readonly class RootWithNestedLeaf +{ + public function __construct(public int $value, public NestedLeaf $leaf) {} +} + +test('creates nested object', function () { + $root = ValueFactory::make(RootWithNestedLeaf::class, [ + 'value' => 42, + 'leaf' => [ + 'flag' => true, + ], + ]); + + expect($root->value)->toBe(42); + expect($root->leaf->flag)->toBe(true); }); -readonly class AnnotatedArrayItem +readonly class CollectionItem { public function __construct(public int $value) {} } -readonly class AnnotatedArrayObject +readonly class CollectionRoot { /** - * @param ?array $items + * @param Collection $items */ - public function __construct(public ?array $items) {} + public function __construct(public string $text, public Collection $items) {} } -test('annotated array with object', function () { - expect(RuleFactory::instance()->make(AnnotatedArrayObject::class))->toBe([ - 'items' => ['nullable', 'array'], - 'items.*' => ['required'], - 'items.*.value' => ['required', 'numeric'], +test('creates collection object', function () { + $root = ValueFactory::make(CollectionRoot::class, [ + 'text' => 'abc', + 'items' => [ + [ 'value' => 1 ], + [ 'value' => 2 ], + [ 'value' => 4 ], + [ 'value' => 8 ], + ], ]); + + expect($root->text)->toBe('abc'); + expect($root->items)->toBeInstanceOf(Collection::class); + expect($root->items->count())->toBe(4); + expect($root->items[0]->value)->toBe(1); + expect($root->items[1]->value)->toBe(2); + expect($root->items[2]->value)->toBe(4); + expect($root->items[3]->value)->toBe(8); }); -readonly class FlattenedLeaf +readonly class DoubleCast { - public function __construct(public ?bool $flag) {} + public static function cast(int $data): int + { + return $data * 2; + } } -readonly class NotFlattenedLeaf -{ - public function __construct(public string $description) {} -} - -readonly class FlattenedNode -{ - public function __construct( - public string $id, - public NotFlattenedLeaf $leaf, - #[Flat] - public FlattenedLeaf $squish, - public int $level = 1, - ) {} -} - -readonly class FlattenedRoot +readonly class WithExplicitCast { public function __construct( + #[CastWith(DoubleCast::class)] public int $value, - #[Flat] - public FlattenedNode $node, ) {} } -test('flattened basic', function () { - expect(RuleFactory::instance()->make(FlattenedRoot::class))->toBe([ - 'value' => ['required', 'numeric'], - 'id' => ['required' ], - 'leaf' => ['required'], - 'leaf.description' => ['required'], - 'flag' => ['nullable', 'boolean'], - 'level' => ['sometimes', 'numeric'], - ]); -}); - -readonly class AnnotatedCollectionItem -{ - public function __construct(public int $value) {} -} - -readonly class AnnotatedCollection +readonly class WithNestedCast { /** - * @param Collection $group + * @param array $items */ - public function __construct(public Collection $group) {} + public function __construct(public array $items) {} } -test('annotated collection', function () { - expect(RuleFactory::instance()->make(AnnotatedCollection::class))->toBe([ - 'group' => ['required', 'array'], - 'group.*' => ['required'], - 'group.*.value' => ['required', 'numeric'], - ]); +test('with explicit cast', function () { + $object = ValueFactory::make(WithExplicitCast::class, ['value' => 32]); + expect($object->value)->toBe(64); +}); + +test('with nested cast', function () { + $object = ValueFactory::make(WithNestedCast::class, ['items' => [ ['value' => 2], ['value' => 3], ['value' => 5]]]); + + expect($object->items[0]->value)->toBe(4); + expect($object->items[1]->value)->toBe(6); + expect($object->items[2]->value)->toBe(10); +}); + +readonly class CarbonPeriodCast +{ + /** + * @param array $data + */ + public static function cast(array $data): CarbonPeriod + { + return new CarbonPeriod(Carbon::parse($data['start']), Carbon::parse($data['end'])); + } +} + +readonly class WithObjectCast +{ + public function __construct( + #[CastWith(CarbonPeriodCast::class)] + public CarbonPeriod $period, + ) {} +} + +test('with object cast', function () { + $object = ValueFactory::make(WithObjectCast::class, ['period' => ['start' => '1980-10-01', 'end' => '1990-06-01']]); + + expect($object->period)->toBeInstanceOf(CarbonPeriod::class); + expect($object->period->start->format('Y-m-d'))->toBe('1980-10-01'); + expect($object->period->end->format('Y-m-d'))->toBe('1990-06-01'); }); From fc46fe20ee482d73a887cc2e07c293126cebff18 Mon Sep 17 00:00:00 2001 From: icefox Date: Wed, 25 Feb 2026 16:03:00 -0300 Subject: [PATCH 3/7] input mapping --- src/DataObjectFactory.php | 47 +++- src/ReflectionHelper.php | 19 ++ tests/Casters/CasterTest.php | 71 ------ tests/Casters/SimpleValue.php | 10 - tests/Casters/SimpleValueCaster.php | 20 -- tests/Casters/WithGlobalCaster.php | 16 -- tests/Casters/WithSpecificCaster.php | 18 -- tests/Casters/WithoutCaster.php | 16 -- tests/Classes/ArrayDataObject.php | 16 -- tests/Classes/CarbonPeriodMapper.php | 22 -- tests/Classes/CollectionDataObject.php | 17 -- tests/Classes/FailsReturnsDefault.php | 23 -- tests/Classes/FailsReturnsNull.php | 23 -- tests/Classes/FailsWithHttpResponse.php | 26 -- tests/Classes/FromInputObject.php | 19 -- tests/Classes/ObjectWithoutMapper.php | 17 -- tests/Classes/OptionalData.php | 19 -- tests/Classes/OptionalNullableData.php | 19 -- tests/Classes/PrimitiveData.php | 19 -- tests/Classes/RecursiveDataObject.php | 17 -- tests/Classes/WithMapperObject.php | 19 -- tests/DataObject/DataObjectTest.php | 127 ++++++++++ tests/DataObjectTest.php | 44 ---- tests/FailedValidation/FailsMethodTest.php | 108 -------- tests/Logging/CustomLogger.php | 36 --- tests/Logging/LogTest.php | 281 --------------------- tests/{ => Rules}/RulesTest.php | 2 +- tests/{ => Values}/ValuesTest.php | 6 +- workbench/config/dto.php | 3 + 29 files changed, 193 insertions(+), 887 deletions(-) delete mode 100644 tests/Casters/CasterTest.php delete mode 100644 tests/Casters/SimpleValue.php delete mode 100644 tests/Casters/SimpleValueCaster.php delete mode 100644 tests/Casters/WithGlobalCaster.php delete mode 100644 tests/Casters/WithSpecificCaster.php delete mode 100644 tests/Casters/WithoutCaster.php delete mode 100644 tests/Classes/ArrayDataObject.php delete mode 100644 tests/Classes/CarbonPeriodMapper.php delete mode 100644 tests/Classes/CollectionDataObject.php delete mode 100644 tests/Classes/FailsReturnsDefault.php delete mode 100644 tests/Classes/FailsReturnsNull.php delete mode 100644 tests/Classes/FailsWithHttpResponse.php delete mode 100644 tests/Classes/FromInputObject.php delete mode 100644 tests/Classes/ObjectWithoutMapper.php delete mode 100644 tests/Classes/OptionalData.php delete mode 100644 tests/Classes/OptionalNullableData.php delete mode 100644 tests/Classes/PrimitiveData.php delete mode 100644 tests/Classes/RecursiveDataObject.php delete mode 100644 tests/Classes/WithMapperObject.php create mode 100644 tests/DataObject/DataObjectTest.php delete mode 100644 tests/DataObjectTest.php delete mode 100644 tests/FailedValidation/FailsMethodTest.php delete mode 100644 tests/Logging/CustomLogger.php delete mode 100644 tests/Logging/LogTest.php rename tests/{ => Rules}/RulesTest.php (99%) rename tests/{ => Values}/ValuesTest.php (97%) diff --git a/src/DataObjectFactory.php b/src/DataObjectFactory.php index 3a14c7e..2d14088 100644 --- a/src/DataObjectFactory.php +++ b/src/DataObjectFactory.php @@ -13,6 +13,8 @@ use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; use Psr\Log\LoggerInterface; +use ReflectionNamedType; +use phpDocumentor\Reflection\Types\AbstractList; class DataObjectFactory { @@ -21,7 +23,8 @@ class DataObjectFactory */ public static function fromRequest(string $class, Request $request): ?object { - $routeParameters = $request->route() instanceof Route ? $request->route()->parameters() : []; + $route = $request->route(); + $routeParameters = $route instanceof Route ? $route->parameters() : []; return static::fromArray($class, $request->input(), $routeParameters); } @@ -52,7 +55,6 @@ class DataObjectFactory return ValueFactory::make($class, $validator->validated()); } - /** * @param class-string $class * @param array $rawInput @@ -67,6 +69,7 @@ class DataObjectFactory ): array { $input = []; $parameters = ReflectionHelper::getParametersMeta($class); + foreach ($parameters as $parameter) { $parameterName = $parameter->reflection->getName(); @@ -77,17 +80,51 @@ class DataObjectFactory } } + $reflectionType = $parameter->reflection->getType(); + $namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; + $annotatedType = $parameter->tag?->getType(); + + $isListType + = $parameter->reflection->isArray() + || in_array($namedType, config('dto.listTypes', [])) + || in_array($annotatedType?->__toString(), config('dto.listTypes', [])) + || $annotatedType instanceof AbstractList; + foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { if ($value = $rawInput[$attr->newInstance()->name] ?? null) { - $input[$parameterName] = $value; + if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) { + $input[$parameterName] = $isListType + ? array_map( + fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger), + $value, + ) + : self::mapInput($valueType, $value, $routeParameters, $logger); + } else { + $input[$parameterName] = $value; + } continue 2; } } - if ($value = $rawInput[$parameterName] ?? null) { - $input[$parameterName] = $value; + if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) { + $input[$parameterName] = $isListType + ? array_map( + fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger), + $rawInput[$parameterName], + ) + : self::mapInput($valueType, $rawInput[$parameterName], $routeParameters, $logger); continue; } + + if ($reflectionType instanceof ReflectionNamedType) { + $input[$parameterName] = $reflectionType->isBuiltin() + ? $rawInput[$parameterName] + : self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger); + + continue; + } + + $input[$parameterName] = $rawInput[$parameterName]; } $logger->debug('input', $input); return $input; diff --git a/src/ReflectionHelper.php b/src/ReflectionHelper.php index 1ab7448..b6f77a5 100644 --- a/src/ReflectionHelper.php +++ b/src/ReflectionHelper.php @@ -5,6 +5,8 @@ namespace Icefox\DTO; use ReflectionParameter; use phpDocumentor\Reflection\DocBlock\Tag; use phpDocumentor\Reflection\DocBlock\Tags\Param; +use phpDocumentor\Reflection\PseudoTypes\Generic; +use phpDocumentor\Reflection\Types\AbstractList; use phpDocumentor\Reflection\Types\ContextFactory; use phpDocumentor\Reflection\DocBlockFactory; use ReflectionClass; @@ -44,4 +46,21 @@ class ReflectionHelper ); return self::$cache[$class]; } + + public static function getListParameterValueType(?Param $param): ?string + { + $type = $param?->getType(); + + if ($type instanceof AbstractList) { + return $type->getValueType()->__toString(); + } + + if (!$type instanceof Generic) { + return null; + } + + $subtypes = $type->getTypes(); + return count($subtypes) > 1 ? $subtypes[1]->__toString() : $subtypes[0]->__toString(); + } + } diff --git a/tests/Casters/CasterTest.php b/tests/Casters/CasterTest.php deleted file mode 100644 index 93fc5ce..0000000 --- a/tests/Casters/CasterTest.php +++ /dev/null @@ -1,71 +0,0 @@ - []]); - }); - - it('uses CastWith attribute over global config caster', function () { - $globalCaster = function (mixed $data): SimpleValue { - return new SimpleValue($data * 3); - }; - config(['dto.cast.' . SimpleValue::class => $globalCaster]); - - $object = WithSpecificCaster::fromArray([ - 'value' => ['value' => 5], - ]); - - expect($object->value->value)->toBe(10); // 5 * 2 - }); - - it('falls back to global config caster when no CastWith attribute', function () { - $globalCaster = function (mixed $data): SimpleValue { - return new SimpleValue($data['value'] * 3); - }; - config(['dto.cast.' . SimpleValue::class => $globalCaster]); - - $object = WithGlobalCaster::fromArray([ - 'simple' => ['value' => 5], - ]); - - expect($object->simple->value)->toBe(15); // 5 * 3 - }); - - it('falls back to default construction when no caster exists', function () { - $object = WithoutCaster::fromArray([ - 'value' => ['value' => 5], - ]); - expect($object)->toBeInstanceOf(WithoutCaster::class); - }); -}); - -describe('caster with rules', function () { - beforeEach(function () { - config(['dto.cast' => []]); - }); - - it('validates input using caster rules before casting', function () { - expect(fn() => WithSpecificCaster::fromArray([ - 'value' => [], - ]))->toThrow(ValidationException::class); - }); - - it('accepts valid input and casts correctly', function () { - $object = WithSpecificCaster::fromArray([ - 'value' => ['value' => 10], - ]); - - expect($object->value->value)->toBe(20); // 10 * 2 - }); -}); diff --git a/tests/Casters/SimpleValue.php b/tests/Casters/SimpleValue.php deleted file mode 100644 index 1f08683..0000000 --- a/tests/Casters/SimpleValue.php +++ /dev/null @@ -1,10 +0,0 @@ - ['required', 'numeric'], - ]; - } -} diff --git a/tests/Casters/WithGlobalCaster.php b/tests/Casters/WithGlobalCaster.php deleted file mode 100644 index d829310..0000000 --- a/tests/Casters/WithGlobalCaster.php +++ /dev/null @@ -1,16 +0,0 @@ - $values - */ - public function __construct(public array $values) {} -} diff --git a/tests/Classes/CarbonPeriodMapper.php b/tests/Classes/CarbonPeriodMapper.php deleted file mode 100644 index 4edee01..0000000 --- a/tests/Classes/CarbonPeriodMapper.php +++ /dev/null @@ -1,22 +0,0 @@ - ['required', 'date'], - 'end' => ['required', 'date'], - ]; - } -} diff --git a/tests/Classes/CollectionDataObject.php b/tests/Classes/CollectionDataObject.php deleted file mode 100644 index 711dbc7..0000000 --- a/tests/Classes/CollectionDataObject.php +++ /dev/null @@ -1,17 +0,0 @@ - $values - */ - public function __construct(public Collection $values) {} -} diff --git a/tests/Classes/FailsReturnsDefault.php b/tests/Classes/FailsReturnsDefault.php deleted file mode 100644 index 7fdba3a..0000000 --- a/tests/Classes/FailsReturnsDefault.php +++ /dev/null @@ -1,23 +0,0 @@ -json(['errors' => $validator->errors()], 422) - ); - } -} diff --git a/tests/Classes/FromInputObject.php b/tests/Classes/FromInputObject.php deleted file mode 100644 index 575e44c..0000000 --- a/tests/Classes/FromInputObject.php +++ /dev/null @@ -1,19 +0,0 @@ - ['value' => 1 ] ], [], new NullLogger()); + expect($input)->toBe(['element' => ['value' => 1]]); +}); + +readonly class MappedElement +{ + public function __construct(#[FromInput('name')] public int $value) {} +} + +readonly class MappedNode +{ + public function __construct(public MappedElement $element) {} +} + +test('basic nested input map', function () { + $input = DataObjectFactory::mapInput(MappedNode::class, ['element' => ['name' => 1 ] ], [], new NullLogger()); + expect($input)->toBe(['element' => ['value' => 1]]); +}); + +readonly class MappedCollectionItem +{ + public function __construct(#[FromInput('id_item')] public int $idItem) {} +} + +readonly class MappedCollectionRoot +{ + /** + * @param Collection $items + */ + public function __construct(public string $text, #[FromInput('data')] public Collection $items) {} +} + +test('using from input nested', function () { + $mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [ + 'text' => 'abc', + 'data' => [ + [ 'id_item' => 1 ], + [ 'id_item' => 2 ], + [ 'id_item' => 4 ], + [ 'id_item' => 8 ], + ], + ], [], new NullLogger()); + + expect($mapped)->toBe([ + 'text' => 'abc', + 'items' => [ + [ 'idItem' => 1 ], + [ 'idItem' => 2 ], + [ 'idItem' => 4 ], + [ 'idItem' => 8 ], + ], + ]); +}); + +readonly class CollectionRoot +{ + /** + * @param Collection $items + */ + public function __construct(public string $text, public Collection $items) {} +} + +test('using from input', function () { + $mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [ + 'text' => 'abc', + 'items' => [ + [ 'id_item' => 1 ], + [ 'id_item' => 2 ], + [ 'id_item' => 4 ], + [ 'id_item' => 8 ], + ], + ], [], new NullLogger()); + + expect($mapped)->toBe([ + 'text' => 'abc', + 'items' => [ + [ 'idItem' => 1 ], + [ 'idItem' => 2 ], + [ 'idItem' => 4 ], + [ 'idItem' => 8 ], + ], + ]); +}); + +readonly class AnnotatedArrayItem +{ + public function __construct(#[FromInput('name')] public float $value) {} +} + +readonly class AnnotatedArray +{ + /** + * @param array $items + */ + public function __construct(public array $items) {} +} + +test('annotated array', function () { + $mapped = DataObjectFactory::mapInput( + AnnotatedArray::class, + ['items' => [['name' => 1], ['name' => 2]]], + [], + new NullLogger(), + ); + + expect($mapped)->toBe(['items' => [['value' => 1], ['value' => 2]]]); +}); diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php deleted file mode 100644 index 8886ac2..0000000 --- a/tests/DataObjectTest.php +++ /dev/null @@ -1,44 +0,0 @@ - $items - */ - public function __construct(public string $text, #[FromInput('data')] public Collection $items) {} -} - -test('using from input', function () { - $mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [ - 'text' => 'abc', - 'data' => [ - [ 'id_item' => 1 ], - [ 'id_item' => 2 ], - [ 'id_item' => 4 ], - [ 'id_item' => 8 ], - ], - ], [], new NullLogger()); - var_dump($mapped); - - expect($mapped)->toBe([ - 'text' => 'abc', - 'items' => [ - [ 'idItem' => 1 ], - [ 'idItem' => 2 ], - [ 'idItem' => 4 ], - [ 'idItem' => 8 ], - ], - ]); -}); diff --git a/tests/FailedValidation/FailsMethodTest.php b/tests/FailedValidation/FailsMethodTest.php deleted file mode 100644 index 11b2ca1..0000000 --- a/tests/FailedValidation/FailsMethodTest.php +++ /dev/null @@ -1,108 +0,0 @@ - 0, - 'float' => 3.14, - 'bool' => true, - ]); - })->toThrow(ValidationException::class); - }); - - it('returns null when fails() returns null', function () { - $result = FailsReturnsNull::fromArray([ - 'int' => 0, - ]); - - expect($result)->toBeNull(); - }); - - it('returns static instance when fails() returns an object', function () { - $result = FailsReturnsDefault::fromArray([ - 'int' => 0, - ]); - - expect($result)->toBeInstanceOf(FailsReturnsDefault::class); - expect($result->string)->toBe('default_value'); - expect($result->int)->toBe(42); - }); -}); - -describe('HTTP request handling', function () { - - beforeEach(function () { - \Illuminate\Support\Facades\Route::post('/test-validation-exception', function () { - PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - return response()->json(['success' => true]); - }); - - \Illuminate\Support\Facades\Route::post('/test-http-response-exception', function () { - FailsWithHttpResponse::fromArray([ - 'int' => 0, - ]); - return response()->json(['success' => true]); - }); - - \Illuminate\Support\Facades\Route::post('/test-validation-exception-html', function () { - PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - return response('success'); - }); - }); - - it('returns 422 with errors when ValidationException is thrown in JSON request', function () { - $response = $this->postJson('/test-validation-exception', [ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['string']); - }); - - it('returns custom JSON response when HttpResponseException is thrown', function () { - $response = $this->postJson('/test-http-response-exception', [ - 'int' => 0, - ]); - - $response->assertStatus(422); - $response->assertJsonStructure(['errors']); - $response->assertJsonFragment([ - 'errors' => [ - 'string' => ['The string field is required.'], - ], - ]); - }); - - it('redirects back with session errors when ValidationException is thrown in text/html request', function () { - $response = $this->post('/test-validation-exception-html', [ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ], [ - 'Accept' => 'text/html', - ]); - - $response->assertRedirect(); - $response->assertSessionHasErrors(['string']); - }); -}); diff --git a/tests/Logging/CustomLogger.php b/tests/Logging/CustomLogger.php deleted file mode 100644 index c4bff3d..0000000 --- a/tests/Logging/CustomLogger.php +++ /dev/null @@ -1,36 +0,0 @@ -logs[] = [ - 'level' => $level, - 'message' => $message, - 'context' => $context, - ]; - } - - public function hasLog(string $level, string $contains): bool - { - foreach ($this->logs as $log) { - if ($log['level'] === $level && str_contains($log['message'], $contains)) { - return true; - } - } - return false; - } - - public function clear(): void - { - $this->logs = []; - } -} diff --git a/tests/Logging/LogTest.php b/tests/Logging/LogTest.php deleted file mode 100644 index 6847938..0000000 --- a/tests/Logging/LogTest.php +++ /dev/null @@ -1,281 +0,0 @@ -set('dto.log.logger', NullLogger::class); - }); - - it('uses NullLogger as fallback when logger config is null', function () { - config()->set('dto.log.logger', null); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(NullLogger::class); - }); - - it('uses NullLogger as fallback when logger config is invalid', function () { - config()->set('dto.log.logger', 'NonExistentLoggerClass'); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(NullLogger::class); - }); - - it('instantiates logger from class name via Laravel container', function () { - config()->set('dto.log.logger', CustomLogger::class); - $log = new Log(); - expect($log->logger)->toBeInstanceOf(CustomLogger::class); - }); - - it('uses logger object directly when provided', function () { - $customLogger = new CustomLogger(); - config()->set('dto.log.logger', $customLogger); - $log = new Log(); - expect($log->logger)->toBe($customLogger); - }); - - it('invokes callable to get logger instance', function () { - config()->set('dto.log.logger', function () { - return new CustomLogger(); - }); - - $log = new Log(); - - expect($log->logger)->toBeInstanceOf(CustomLogger::class); - }); -}); - -describe('log level configuration', function () { - - beforeEach(function () { - $this->customLogger = new CustomLogger(); - config()->set('dto.log.logger', $this->customLogger); - }); - - afterEach(function () { - config()->set('dto.log.logger', NullLogger::class); - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::DEBUG); - config()->set('dto.log.raw_input', LogLevel::DEBUG); - config()->set('dto.log.validation_errors', LogLevel::INFO); - }); - - it('logs rules at configured level', function () { - config()->set('dto.log.rules', LogLevel::INFO); - - $log = new Log(); - $log->rules(['field' => ['required']]); - - expect($this->customLogger->hasLog(LogLevel::INFO, 'field'))->toBeTrue(); - }); - - it('logs input at configured level', function () { - config()->set('dto.log.input', LogLevel::INFO); - - $log = new Log(); - $log->input(['field' => 'value']); - - expect($this->customLogger->hasLog(LogLevel::INFO, 'value'))->toBeTrue(); - }); - - it('logs raw input at configured level', function () { - config()->set('dto.log.raw_input', LogLevel::ERROR); - - $log = new Log(); - $log->inputRaw(['field' => 'raw_value']); - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'raw_value'))->toBeTrue(); - }); - - it('logs validation errors at configured level', function () { - config()->set('dto.log.validation_errors', LogLevel::ERROR); - - $log = new Log(); - $log->validationErrors(['field' => ['The field is required.']]); - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue(); - }); - - it('allows different log levels for each log type', function () { - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::INFO); - config()->set('dto.log.raw_input', LogLevel::INFO); - - $log = new Log(); - - $log->rules(['rules_field' => ['required']]); - $log->input(['input_field' => 'value']); - $log->inputRaw(['raw_field' => 'raw_value']); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'rules_field'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::INFO, 'input_field'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::INFO, 'raw_field'))->toBeTrue(); - }); - - it('defaults to DEBUG level when not configured', function () { - config()->set('dto.log.rules', null); - config()->set('dto.log.input', null); - config()->set('dto.log.raw_input', null); - - $customLogger = new CustomLogger(); - config()->set('dto.log.logger', $customLogger); - - $log = new Log(); - - $log->rules(['field' => ['required']]); - $log->input(['field' => 'value']); - $log->inputRaw(['field' => 'raw_value']); - - expect(count($customLogger->logs))->toBe(3); - expect($customLogger->logs[0]['level'])->toBe(LogLevel::DEBUG); - expect($customLogger->logs[1]['level'])->toBe(LogLevel::DEBUG); - expect($customLogger->logs[2]['level'])->toBe(LogLevel::DEBUG); - }); -}); - -describe('integration with DataObject', function () { - - beforeEach(function () { - $this->customLogger = new CustomLogger(); - config()->set('dto.log.logger', $this->customLogger); - config()->set('dto.log.rules', LogLevel::DEBUG); - config()->set('dto.log.input', LogLevel::DEBUG); - config()->set('dto.log.raw_input', LogLevel::DEBUG); - }); - - afterEach(function () { - config()->set('dto.log.logger', NullLogger::class); - }); - - it('logs raw input during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'raw_input'))->toBeFalse(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'string'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue(); - }); - - it('logs rules during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue(); - }); - - it('logs processed input during fromArray execution', function () { - PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'test'))->toBeTrue(); - }); - - it('captures all three log types during successful fromArray', function () { - PrimitiveData::fromArray([ - 'string' => 'integration_test', - 'int' => 123, - 'float' => 9.99, - 'bool' => false, - ]); - - $rawInputLogged = false; - $rulesLogged = false; - $inputLogged = false; - - foreach ($this->customLogger->logs as $log) { - if (str_contains($log['message'], 'string')) { - $rawInputLogged = true; - } - if (str_contains($log['message'], 'required')) { - $rulesLogged = true; - } - if (str_contains($log['message'], 'integration_test')) { - $inputLogged = true; - } - } - - expect($rawInputLogged)->toBeTrue('Raw input should be logged'); - expect($rulesLogged)->toBeTrue('Rules should be logged'); - expect($inputLogged)->toBeTrue('Processed input should be logged'); - }); - - it('logs even when validation fails', function () { - try { - PrimitiveData::fromArray([ - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - } catch (\Illuminate\Validation\ValidationException $e) { - // Expected - } - - expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue(); - }); - - it('logs validation errors when validation fails', function () { - config()->set('dto.log.validation_errors', LogLevel::ERROR); - - try { - PrimitiveData::fromArray([ - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - } catch (\Illuminate\Validation\ValidationException $e) { - // Expected - } - - expect($this->customLogger->hasLog(LogLevel::ERROR, 'string'))->toBeTrue(); - expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue(); - }); -}); - -describe('logging with NullLogger', function () { - - it('does not throw when logging with NullLogger', function () { - config()->set('dto.log.logger', NullLogger::class); - - $log = new Log(); - - expect(function () use ($log) { - $log->rules(['field' => ['required']]); - $log->input(['field' => 'value']); - $log->inputRaw(['field' => 'raw_value']); - })->not->toThrow(\Throwable::class); - }); - - it('does not affect DataObject behavior when using NullLogger', function () { - config()->set('dto.log.logger', NullLogger::class); - - $object = PrimitiveData::fromArray([ - 'string' => 'test', - 'int' => 42, - 'float' => 3.14, - 'bool' => true, - ]); - - expect($object)->toBeInstanceOf(PrimitiveData::class); - expect($object->string)->toBe('test'); - }); -}); diff --git a/tests/RulesTest.php b/tests/Rules/RulesTest.php similarity index 99% rename from tests/RulesTest.php rename to tests/Rules/RulesTest.php index 34c48cf..1d722b5 100644 --- a/tests/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests; +namespace Tests\Rules; use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\Overwrite; diff --git a/tests/ValuesTest.php b/tests/Values/ValuesTest.php similarity index 97% rename from tests/ValuesTest.php rename to tests/Values/ValuesTest.php index 381900d..93eb02f 100644 --- a/tests/ValuesTest.php +++ b/tests/Values/ValuesTest.php @@ -2,14 +2,10 @@ declare(strict_types=1); -namespace Tests; +namespace Tests\Values; use Carbon\CarbonPeriod; use Icefox\DTO\Attributes\CastWith; -use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Attributes\FromInput; -use Icefox\DTO\Attributes\Overwrite; -use Icefox\DTO\RuleFactory; use Icefox\DTO\ValueFactory; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; diff --git a/workbench/config/dto.php b/workbench/config/dto.php index d212857..a06c16b 100644 --- a/workbench/config/dto.php +++ b/workbench/config/dto.php @@ -17,4 +17,7 @@ return [ 'internals' => LogLevel::DEBUG, ], ], + 'listTypes' => [ + Collection::class, + ], ]; From b827038df3388d76ff4f9ecc9d3e96e7227bc97a Mon Sep 17 00:00:00 2001 From: icefox Date: Wed, 25 Feb 2026 17:47:17 -0300 Subject: [PATCH 4/7] refactor, tests --- src/Config.php | 63 ------- src/CustomHandlers.php | 3 +- src/DataObject.php | 1 + src/{ => Factories}/DataObjectFactory.php | 7 +- src/{ => Factories}/RuleFactory.php | 7 +- src/{ => Factories}/ValueFactory.php | 45 ++--- src/ReflectionHelper.php | 14 ++ tests/DataObject/DataObjectTest.php | 2 +- tests/Http/RequestTests.php | 206 ++++++++++++++++++++++ tests/Rules/RulesTest.php | 2 +- tests/Values/ValuesTest.php | 57 +++++- 11 files changed, 304 insertions(+), 103 deletions(-) delete mode 100644 src/Config.php rename src/{ => Factories}/DataObjectFactory.php (97%) rename src/{ => Factories}/RuleFactory.php (97%) rename src/{ => Factories}/ValueFactory.php (79%) create mode 100644 tests/Http/RequestTests.php diff --git a/src/Config.php b/src/Config.php deleted file mode 100644 index e804397..0000000 --- a/src/Config.php +++ /dev/null @@ -1,63 +0,0 @@ - static::rulesIlluminateCollection(...), - default => null, - }; - } - - - /** - * @return array - */ - private static function rulesIlluminateCollection(ParameterMeta $parameter, RuleFactory $factory): array - { - if (is_null($parameter->tag)) { - return []; - } - - $type = $parameter->tag->getType(); - if (!$type instanceof Generic) { - return []; - } - - $subtypes = $type->getTypes(); - - if (count($subtypes) == 0) { - return ['' => ['array']]; - } - - $subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1]; - - return $factory->mergeRules( - ['' => ['array']], - $factory->getRulesFromDocBlock($subtype, '.*'), - ); - } -} diff --git a/src/CustomHandlers.php b/src/CustomHandlers.php index 075e1ba..f76b988 100644 --- a/src/CustomHandlers.php +++ b/src/CustomHandlers.php @@ -2,7 +2,7 @@ namespace Icefox\DTO; -use Icefox\DTO\RuleFactory; +use Icefox\DTO\Factories\RuleFactory; use phpDocumentor\Reflection\PseudoTypes\Generic; class CustomHandlers @@ -35,4 +35,3 @@ class CustomHandlers ); } } - diff --git a/src/DataObject.php b/src/DataObject.php index 95a7235..d703f47 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Icefox\DTO; +use Icefox\DTO\Factories\DataObjectFactory; use Illuminate\Http\Request; trait DataObject diff --git a/src/DataObjectFactory.php b/src/Factories/DataObjectFactory.php similarity index 97% rename from src/DataObjectFactory.php rename to src/Factories/DataObjectFactory.php index 2d14088..9ba6f88 100644 --- a/src/DataObjectFactory.php +++ b/src/Factories/DataObjectFactory.php @@ -1,11 +1,12 @@ getName()) { - if ($globalRules = Config::getRules($name)) { + if ($globalRules = config('dto.rules.' . $name, null)) { foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { $realPrefix = $prefix . $scopedPrefix; $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); diff --git a/src/ValueFactory.php b/src/Factories/ValueFactory.php similarity index 79% rename from src/ValueFactory.php rename to src/Factories/ValueFactory.php index 53de322..3f262cd 100644 --- a/src/ValueFactory.php +++ b/src/Factories/ValueFactory.php @@ -2,15 +2,13 @@ declare(strict_types=1); -namespace Icefox\DTO; +namespace Icefox\DTO\Factories; use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Config; use Icefox\DTO\ReflectionHelper; use Illuminate\Support\Facades\App; use ReflectionNamedType; -use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\PseudoTypes\Generic; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\AbstractList; @@ -22,21 +20,6 @@ use phpDocumentor\Reflection\Types\Object_; class ValueFactory { - public static function constructObject(string $className, mixed $rawValue): object - { - if ($mapper = Config::getCaster($className)) { - return $mapper($rawValue); - } - - // Plain values or numeric arrays are passed as a single parameter to the constructor - if (!is_array($rawValue) || array_key_exists(0, $rawValue)) { - return new $className($className); - } - - // Associative arrays leverage Laravel service container - return App::makeWith($className, $rawValue); - } - public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed { if ($type instanceof Nullable) { @@ -89,14 +72,18 @@ class ValueFactory public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed { - return match ($parameter->getName()) { + $type = $parameter->getName(); + if (is_a($type, \BackedEnum::class, true)) { + return $type::from($rawValue); + } + + return match ($type) { 'string' => $rawValue, 'bool' => boolval($rawValue), 'int' => intval($rawValue), 'float' => floatval($rawValue), 'array' => $rawValue, - default => self::make($parameter->getName(), $rawValue), - + default => self::make($type, $rawValue), }; } @@ -113,9 +100,9 @@ class ValueFactory $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input; if (is_null($parameterArgs)) { - if ($parameter->reflection->allowsNull()) { - $arguments[$name] = null; - } + $arguments[$name] = $parameter->reflection->isDefaultValueAvailable() + ? $parameter->reflection->getDefaultValue() + : null; continue; } @@ -129,20 +116,22 @@ class ValueFactory $type = $parameter->tag?->getType(); if (empty($parameterClass) && $type instanceof Object_) { - $parameterClass = $type->getFqsen(); + $parameterClass = $type->getFqsen()?->__toString(); } + if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) { $arguments[$name] = App::call($caster, ['data' => $parameterArgs]); continue; } - if ($parameter->tag instanceof Param) { + if (!is_null($type)) { $arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs); continue; } - if ($parameter->reflection->getType() instanceof ReflectionNamedType) { - $arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs); + $reflectionType = $parameter->reflection->getType(); + if ($reflectionType instanceof ReflectionNamedType) { + $arguments[$name] = self::resolveDeclaredTypeValue($reflectionType, $parameterArgs); continue; } diff --git a/src/ReflectionHelper.php b/src/ReflectionHelper.php index b6f77a5..47ebd39 100644 --- a/src/ReflectionHelper.php +++ b/src/ReflectionHelper.php @@ -2,6 +2,7 @@ namespace Icefox\DTO; +use ReflectionNamedType; use ReflectionParameter; use phpDocumentor\Reflection\DocBlock\Tag; use phpDocumentor\Reflection\DocBlock\Tags\Param; @@ -63,4 +64,17 @@ class ReflectionHelper return count($subtypes) > 1 ? $subtypes[1]->__toString() : $subtypes[0]->__toString(); } + public static function isListType(ParameterMeta $parameter): bool + { + $reflectionType = $parameter->reflection->getType(); + $namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; + $annotatedType = $parameter->tag?->getType(); + return + $parameter->reflection->isArray() + || in_array($namedType, config('dto.listTypes', [])) + || in_array($annotatedType?->__toString(), config('dto.listTypes', [])) + || $annotatedType instanceof AbstractList; + + } + } diff --git a/tests/DataObject/DataObjectTest.php b/tests/DataObject/DataObjectTest.php index 05b1787..5994877 100644 --- a/tests/DataObject/DataObjectTest.php +++ b/tests/DataObject/DataObjectTest.php @@ -3,7 +3,7 @@ namespace Tests\DataObject; use Icefox\DTO\Attributes\FromInput; -use Icefox\DTO\DataObjectFactory; +use Icefox\DTO\Factories\DataObjectFactory; use Illuminate\Support\Collection; use Psr\Log\NullLogger; diff --git a/tests/Http/RequestTests.php b/tests/Http/RequestTests.php new file mode 100644 index 0000000..b253e7d --- /dev/null +++ b/tests/Http/RequestTests.php @@ -0,0 +1,206 @@ + 'Test Title', + 'content' => 'Test Content', + ], []); + + expect($dto)->toBeInstanceOf(SimplePostDTO::class) + ->and($dto->title)->toBe('Test Title') + ->and($dto->content)->toBe('Test Content'); +}); + +test('failed validation throws validation exception', function () { + try { + DataObjectFactory::fromArray(SimplePostDTO::class, [ + 'title' => 'Test', + 'content' => [], + ], []); + } catch (\Throwable $e) { + expect($e)->toBeInstanceOf(ValidationException::class); + return; + } + throw new \Exception('Should have thrown an exception'); +}); + +readonly class PostDTOWithNumeric +{ + public function __construct( + public string $title, + public int $views, + ) {} +} + +test('failed validation returns proper error format', function () { + try { + DataObjectFactory::fromArray(PostDTOWithNumeric::class, [ + 'title' => 'Test', + 'views' => 'not-a-number', + ], []); + } catch (ValidationException $e) { + $errors = $e->validator->errors(); + + expect($errors->has('views'))->toBeTrue() + ->and($errors->first('views'))->toContain('number'); + } +}); + +test('validation error message format matches laravel', function () { + try { + DataObjectFactory::fromArray(PostDTOWithNumeric::class, [ + 'title' => 'Test', + 'views' => 'invalid', + ], []); + } catch (ValidationException $e) { + $errors = $e->validator->errors()->toArray(); + + expect($errors)->toHaveKey('views') + ->and($errors['views'][0])->toContain('views'); + } +}); + +readonly class RestrictedDTO +{ + public function __construct(public string $content) {} + + public static function fails($validator): object + { + return (object) [ + 'error' => 'Access denied', + 'details' => $validator->errors()->toArray(), + 'status' => 403, + ]; + } +} + +test('custom fails returns custom response structure', function () { + $result = DataObjectFactory::fromArray(RestrictedDTO::class, [ + 'content' => [], // invalid - must be string + ], []); + + expect($result->error)->toBe('Access denied') + ->and($result->details)->toHaveKey('content') + ->and($result->status)->toBe(403); +}); + +readonly class CustomBagDTO +{ + public string $name; + public float $price; + + public function __construct(string $name, float $price) + { + $this->name = $name; + $this->price = $price; + } + + public static function rules(): array + { + return [ + 'name' => ['required', 'min:3'], + 'price' => ['required', 'numeric', 'min:0'], + ]; + } +} + +test('named error bag test', function () { + try { + DataObjectFactory::fromArray(CustomBagDTO::class, [ + 'name' => 'ab', + 'price' => -10, + ], []); + } catch (ValidationException $e) { + $errors = $e->validator->errors()->toArray(); + + expect($errors)->toHaveKey('name') + ->and($errors)->toHaveKey('price'); + } +}); + +test('route parameter priority over from input', function () { + $dto = new class (123) { + public function __construct( + #[FromRouteParameter('user_id')] + #[FromInput('user_id')] + public int $id, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['user_id' => 456], + ['user_id' => 123], + new \Psr\Log\NullLogger(), + ); + + expect($result['id'])->toBe(123); +}); + +test('multiple route parameters', function () { + $dto = new class (45, 89, 'A') { + public function __construct( + #[FromRouteParameter('course_id')] + public int $courseId, + #[FromRouteParameter('student_id')] + public int $studentId, + #[FromInput('grade')] + public string $grade, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['grade' => 'A'], + ['course_id' => 45, 'student_id' => 89], + new \Psr\Log\NullLogger(), + ); + + expect($result['courseId'])->toBe(45) + ->and($result['studentId'])->toBe(89) + ->and($result['grade'])->toBe('A'); +}); + +test('route parameter with nested object', function () { + $addressDto = new class ('Main St') { + public function __construct(public string $street) {} + }; + + $dto = new class (123, $addressDto) { + public function __construct( + #[FromRouteParameter('user_id')] + public int $userId, + public $address, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['user_id' => 456, 'address' => ['street' => 'Main St']], + ['user_id' => 123], + new \Psr\Log\NullLogger(), + ); + + expect($result['userId'])->toBe(123) + ->and($result['address']['street'])->toBe('Main St'); +}); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index 1d722b5..16ec1a7 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -6,7 +6,7 @@ namespace Tests\Rules; use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\Overwrite; -use Icefox\DTO\RuleFactory; +use Icefox\DTO\Factories\RuleFactory; use Illuminate\Support\Collection; readonly class BasicPrimitives diff --git a/tests/Values/ValuesTest.php b/tests/Values/ValuesTest.php index 93eb02f..17cd822 100644 --- a/tests/Values/ValuesTest.php +++ b/tests/Values/ValuesTest.php @@ -6,7 +6,7 @@ namespace Tests\Values; use Carbon\CarbonPeriod; use Icefox\DTO\Attributes\CastWith; -use Icefox\DTO\ValueFactory; +use Icefox\DTO\Factories\ValueFactory; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -216,3 +216,58 @@ test('with object cast', function () { expect($object->period->start->format('Y-m-d'))->toBe('1980-10-01'); expect($object->period->end->format('Y-m-d'))->toBe('1990-06-01'); }); + +enum Status: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; + case PENDING = 'pending'; +} + +readonly class TaskDTOWithEnum +{ + public function __construct( + public string $title, + public Status $status, + ) {} +} + +readonly class TaskDTOWithNullableEnum +{ + public function __construct( + public string $title, + public ?Status $status, + ) {} +} + +test('backed enum properly cast from validated data', function () { + $object = ValueFactory::make(TaskDTOWithEnum::class, [ + 'title' => 'Task 1', + 'status' => 'active', + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeInstanceOf(Status::class) + ->and($object->status)->toBe(Status::ACTIVE); +}); + +test('nullable backed enum with null', function () { + $object = ValueFactory::make(TaskDTOWithNullableEnum::class, [ + 'title' => 'Task 1', + 'status' => null, + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeNull(); +}); + +test('nullable backed enum with valid value', function () { + $object = ValueFactory::make(TaskDTOWithNullableEnum::class, [ + 'title' => 'Task 1', + 'status' => 'pending', + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeInstanceOf(Status::class) + ->and($object->status)->toBe(Status::PENDING); +}); From 30706c3521fd6162c9aab03b76e2971f2214a092 Mon Sep 17 00:00:00 2001 From: icefox Date: Fri, 27 Feb 2026 11:14:42 -0300 Subject: [PATCH 5/7] http tests --- src/DataObject.php | 2 +- src/Factories/DataObjectFactory.php | 16 +- src/Factories/ValueFactory.php | 2 +- src/IDataObject.php | 6 + src/Providers/DataObjectServiceProvider.php | 23 +++ tests/DataObject/DataObjectTest.php | 68 +++++++ tests/Http/RequestTest.php | 107 ++++++++++ tests/Http/RequestTests.php | 206 -------------------- 8 files changed, 216 insertions(+), 214 deletions(-) create mode 100644 src/IDataObject.php create mode 100644 src/Providers/DataObjectServiceProvider.php create mode 100644 tests/Http/RequestTest.php delete mode 100644 tests/Http/RequestTests.php diff --git a/src/DataObject.php b/src/DataObject.php index d703f47..ebfb754 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -9,7 +9,7 @@ use Illuminate\Http\Request; trait DataObject { - public static function fromRequest(Request $request): mixed + public static function fromRequest(Request $request): ?static { return DataObjectFactory::fromRequest(static::class, $request); } diff --git a/src/Factories/DataObjectFactory.php b/src/Factories/DataObjectFactory.php index 9ba6f88..89ae40c 100644 --- a/src/Factories/DataObjectFactory.php +++ b/src/Factories/DataObjectFactory.php @@ -118,14 +118,18 @@ class DataObjectFactory } if ($reflectionType instanceof ReflectionNamedType) { - $input[$parameterName] = $reflectionType->isBuiltin() - ? $rawInput[$parameterName] - : self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger); - + if ($reflectionType->isBuiltin()) { + if (array_key_exists($parameterName, $rawInput)) { + $input[$parameterName] = $rawInput[$parameterName]; + } + } else { + $input[$parameterName] = self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger); + } continue; } - - $input[$parameterName] = $rawInput[$parameterName]; + if (array_key_exists($parameterName, $rawInput)) { + $input[$parameterName] = $rawInput[$parameterName]; + } } $logger->debug('input', $input); return $input; diff --git a/src/Factories/ValueFactory.php b/src/Factories/ValueFactory.php index 3f262cd..5509c51 100644 --- a/src/Factories/ValueFactory.php +++ b/src/Factories/ValueFactory.php @@ -137,6 +137,6 @@ class ValueFactory $arguments[$name] = $parameterArgs; } - return App::makeWith($class, $arguments); + return new $class(...$arguments); } } diff --git a/src/IDataObject.php b/src/IDataObject.php new file mode 100644 index 0000000..5797c95 --- /dev/null +++ b/src/IDataObject.php @@ -0,0 +1,6 @@ +app->beforeResolving(function ($abstract, $parameters, $app) { + if ($app->has($abstract)) { + return; + } + if (is_subclass_of($abstract, IDataObject::class)) { + $app->bind($abstract, fn($container) => DataObjectFactory::fromRequest($abstract, $container['request'])); + } + }); + } +} + diff --git a/tests/DataObject/DataObjectTest.php b/tests/DataObject/DataObjectTest.php index 5994877..30b3055 100644 --- a/tests/DataObject/DataObjectTest.php +++ b/tests/DataObject/DataObjectTest.php @@ -3,6 +3,7 @@ namespace Tests\DataObject; use Icefox\DTO\Attributes\FromInput; +use Icefox\DTO\Attributes\FromRouteParameter; use Icefox\DTO\Factories\DataObjectFactory; use Illuminate\Support\Collection; use Psr\Log\NullLogger; @@ -125,3 +126,70 @@ test('annotated array', function () { expect($mapped)->toBe(['items' => [['value' => 1], ['value' => 2]]]); }); + +test('route parameter priority over from input', function () { + $dto = new class (123) { + public function __construct( + #[FromRouteParameter('user_id')] + #[FromInput('user_id')] + public int $id, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['user_id' => 456], + ['user_id' => 123], + new \Psr\Log\NullLogger(), + ); + + expect($result['id'])->toBe(123); +}); + +test('multiple route parameters', function () { + $dto = new class (45, 89, 'A') { + public function __construct( + #[FromRouteParameter('course_id')] + public int $courseId, + #[FromRouteParameter('student_id')] + public int $studentId, + #[FromInput('grade')] + public string $grade, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['grade' => 'A'], + ['course_id' => 45, 'student_id' => 89], + new \Psr\Log\NullLogger(), + ); + + expect($result['courseId'])->toBe(45) + ->and($result['studentId'])->toBe(89) + ->and($result['grade'])->toBe('A'); +}); + +test('route parameter with nested object', function () { + $addressDto = new class ('Main St') { + public function __construct(public string $street) {} + }; + + $dto = new class (123, $addressDto) { + public function __construct( + #[FromRouteParameter('user_id')] + public int $userId, + public $address, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['user_id' => 456, 'address' => ['street' => 'Main St']], + ['user_id' => 123], + new \Psr\Log\NullLogger(), + ); + + expect($result['userId'])->toBe(123) + ->and($result['address']['street'])->toBe('Main St'); +}); diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php new file mode 100644 index 0000000..c92e06d --- /dev/null +++ b/tests/Http/RequestTest.php @@ -0,0 +1,107 @@ +reply = $message == 'ping' ? 'pong' : 'unknown'; + } +} + +test('injects data object', function () { + Route::post('/', fn(Basic $object) => ['reply' => $object->reply]); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', ['message' => 'ping'])->assertJson(['reply' => 'pong']); +}); + +test('fails on validation error', function () { + Route::post('/', fn(Basic $object) => ['reply' => $object->reply]); + /** @var \Tests\TestCase $this */ + $resp = $this->postJson('/', []) + ->assertStatus(422) + ->assertJson([ + 'message' => 'The message field is required.', + 'errors' => ['message' => ['The message field is required.']], + ]); +}); + +readonly class WithCustomValidator implements IDataObject +{ + public string $reply; + public function __construct(string $message) + { + $this->reply = $message == 'ping' ? 'pong' : 'unknown'; + } + + /** + * @param array $data + * @param array $rules + */ + public static function withValidator(array $data, array $rules): Validator + { + return ValidatorFacade::make($data, $rules, $messages = [ + 'message.required' => 'the known message is pong', + ]); + } +} + +test('replies with custom validator', function () { + Route::post('/', fn(WithCustomValidator $object) => []); + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(422) + ->assertJson([ + 'message' => 'the known message is pong', + 'errors' => ['message' => ['the known message is pong']], + ]); +}); + +readonly class WithCustomFailure implements IDataObject +{ + public function __construct(public bool $flag) {} + + public static function fails(Validator $validator): void + { + throw new HttpResponseException( + response(['result' => 'invalid, but that is ok' ], 202), + ); + } +} + +test('uses custom response', function () { + Route::post('/', fn(WithCustomFailure $object) => response('', 204)); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(202) + ->assertJson(['result' => 'invalid, but that is ok']); +}); + +readonly class WithDefaultObjectOnFailure implements IDataObject +{ + public function __construct(public bool $flag) {} + + public static function fails(): self + { + return new self(false); + } +} + +test('uses default object on failure', function () { + Route::post('/', fn(WithDefaultObjectOnFailure $object) => response(['flag' => $object->flag], 200)); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(200) + ->assertJson(['flag' => false]); +}); diff --git a/tests/Http/RequestTests.php b/tests/Http/RequestTests.php deleted file mode 100644 index b253e7d..0000000 --- a/tests/Http/RequestTests.php +++ /dev/null @@ -1,206 +0,0 @@ - 'Test Title', - 'content' => 'Test Content', - ], []); - - expect($dto)->toBeInstanceOf(SimplePostDTO::class) - ->and($dto->title)->toBe('Test Title') - ->and($dto->content)->toBe('Test Content'); -}); - -test('failed validation throws validation exception', function () { - try { - DataObjectFactory::fromArray(SimplePostDTO::class, [ - 'title' => 'Test', - 'content' => [], - ], []); - } catch (\Throwable $e) { - expect($e)->toBeInstanceOf(ValidationException::class); - return; - } - throw new \Exception('Should have thrown an exception'); -}); - -readonly class PostDTOWithNumeric -{ - public function __construct( - public string $title, - public int $views, - ) {} -} - -test('failed validation returns proper error format', function () { - try { - DataObjectFactory::fromArray(PostDTOWithNumeric::class, [ - 'title' => 'Test', - 'views' => 'not-a-number', - ], []); - } catch (ValidationException $e) { - $errors = $e->validator->errors(); - - expect($errors->has('views'))->toBeTrue() - ->and($errors->first('views'))->toContain('number'); - } -}); - -test('validation error message format matches laravel', function () { - try { - DataObjectFactory::fromArray(PostDTOWithNumeric::class, [ - 'title' => 'Test', - 'views' => 'invalid', - ], []); - } catch (ValidationException $e) { - $errors = $e->validator->errors()->toArray(); - - expect($errors)->toHaveKey('views') - ->and($errors['views'][0])->toContain('views'); - } -}); - -readonly class RestrictedDTO -{ - public function __construct(public string $content) {} - - public static function fails($validator): object - { - return (object) [ - 'error' => 'Access denied', - 'details' => $validator->errors()->toArray(), - 'status' => 403, - ]; - } -} - -test('custom fails returns custom response structure', function () { - $result = DataObjectFactory::fromArray(RestrictedDTO::class, [ - 'content' => [], // invalid - must be string - ], []); - - expect($result->error)->toBe('Access denied') - ->and($result->details)->toHaveKey('content') - ->and($result->status)->toBe(403); -}); - -readonly class CustomBagDTO -{ - public string $name; - public float $price; - - public function __construct(string $name, float $price) - { - $this->name = $name; - $this->price = $price; - } - - public static function rules(): array - { - return [ - 'name' => ['required', 'min:3'], - 'price' => ['required', 'numeric', 'min:0'], - ]; - } -} - -test('named error bag test', function () { - try { - DataObjectFactory::fromArray(CustomBagDTO::class, [ - 'name' => 'ab', - 'price' => -10, - ], []); - } catch (ValidationException $e) { - $errors = $e->validator->errors()->toArray(); - - expect($errors)->toHaveKey('name') - ->and($errors)->toHaveKey('price'); - } -}); - -test('route parameter priority over from input', function () { - $dto = new class (123) { - public function __construct( - #[FromRouteParameter('user_id')] - #[FromInput('user_id')] - public int $id, - ) {} - }; - - $result = DataObjectFactory::mapInput( - $dto::class, - ['user_id' => 456], - ['user_id' => 123], - new \Psr\Log\NullLogger(), - ); - - expect($result['id'])->toBe(123); -}); - -test('multiple route parameters', function () { - $dto = new class (45, 89, 'A') { - public function __construct( - #[FromRouteParameter('course_id')] - public int $courseId, - #[FromRouteParameter('student_id')] - public int $studentId, - #[FromInput('grade')] - public string $grade, - ) {} - }; - - $result = DataObjectFactory::mapInput( - $dto::class, - ['grade' => 'A'], - ['course_id' => 45, 'student_id' => 89], - new \Psr\Log\NullLogger(), - ); - - expect($result['courseId'])->toBe(45) - ->and($result['studentId'])->toBe(89) - ->and($result['grade'])->toBe('A'); -}); - -test('route parameter with nested object', function () { - $addressDto = new class ('Main St') { - public function __construct(public string $street) {} - }; - - $dto = new class (123, $addressDto) { - public function __construct( - #[FromRouteParameter('user_id')] - public int $userId, - public $address, - ) {} - }; - - $result = DataObjectFactory::mapInput( - $dto::class, - ['user_id' => 456, 'address' => ['street' => 'Main St']], - ['user_id' => 123], - new \Psr\Log\NullLogger(), - ); - - expect($result['userId'])->toBe(123) - ->and($result['address']['street'])->toBe('Main St'); -}); From 71d49def6b183a7e6dd6d112bf91637d188fb866 Mon Sep 17 00:00:00 2001 From: icefox Date: Fri, 27 Feb 2026 11:17:21 -0300 Subject: [PATCH 6/7] abstract --- src/Factories/DataObjectFactory.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Factories/DataObjectFactory.php b/src/Factories/DataObjectFactory.php index 89ae40c..c38ad95 100644 --- a/src/Factories/DataObjectFactory.php +++ b/src/Factories/DataObjectFactory.php @@ -85,11 +85,7 @@ class DataObjectFactory $namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; $annotatedType = $parameter->tag?->getType(); - $isListType - = $parameter->reflection->isArray() - || in_array($namedType, config('dto.listTypes', [])) - || in_array($annotatedType?->__toString(), config('dto.listTypes', [])) - || $annotatedType instanceof AbstractList; + $isListType = ReflectionHelper::isListType($parameter); foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { if ($value = $rawInput[$attr->newInstance()->name] ?? null) { From a5b80681c96109589eecb1a1327be2e18e1ed9a8 Mon Sep 17 00:00:00 2001 From: icefox Date: Sat, 7 Mar 2026 11:27:12 -0300 Subject: [PATCH 7/7] use Data namespace --- composer.json | 8 +- src/Attributes/CastWith.php | 3 +- src/Attributes/Flat.php | 2 +- src/Attributes/FromInput.php | 2 +- src/Attributes/FromRouteParameter.php | 2 +- src/Attributes/Overwrite.php | 2 +- src/CustomHandlers.php | 4 +- src/DataObject.php | 4 +- src/Factories/DataObjectFactory.php | 41 ++-- src/Factories/RuleFactory.php | 12 +- src/Factories/ValueFactory.php | 8 +- src/IData.php | 5 + src/IDataObject.php | 6 - src/ParameterMeta.php | 2 +- src/Providers/DataObjectServiceProvider.php | 13 +- src/ReflectionHelper.php | 2 +- tests/DataObject/DataObjectTest.php | 211 +++++++++++++++++++- tests/Http/RequestTest.php | 10 +- tests/Rules/RulesTest.php | 6 +- tests/Values/ValuesTest.php | 4 +- workbench/config/dto.php | 4 +- 21 files changed, 283 insertions(+), 68 deletions(-) create mode 100644 src/IData.php delete mode 100644 src/IDataObject.php diff --git a/composer.json b/composer.json index b415c1c..5f263ef 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "icefox/dto", "type": "library", + "version": "0.0.1", "require": { "laravel/framework": "^11.0", "psr/log": "^3.0", @@ -18,15 +19,12 @@ "license": "GPL-2.0-only", "autoload": { "psr-4": { - "Icefox\\DTO\\": "src/" + "Icefox\\Data\\": "src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/", - "Workbench\\App\\": "workbench/app/", - "Workbench\\Database\\Factories\\": "workbench/database/factories/", - "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" + "Tests\\": "tests/" } }, "authors": [ diff --git a/src/Attributes/CastWith.php b/src/Attributes/CastWith.php index 6d544c5..25c32cf 100644 --- a/src/Attributes/CastWith.php +++ b/src/Attributes/CastWith.php @@ -2,8 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; - +namespace Icefox\Data\Attributes; use Attribute; #[Attribute(Attribute::TARGET_PARAMETER)] diff --git a/src/Attributes/Flat.php b/src/Attributes/Flat.php index 27d73ad..2664003 100644 --- a/src/Attributes/Flat.php +++ b/src/Attributes/Flat.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; +namespace Icefox\Data\Attributes; use Attribute; diff --git a/src/Attributes/FromInput.php b/src/Attributes/FromInput.php index 0a536bc..d43b697 100644 --- a/src/Attributes/FromInput.php +++ b/src/Attributes/FromInput.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; +namespace Icefox\Data\Attributes; use Attribute; diff --git a/src/Attributes/FromRouteParameter.php b/src/Attributes/FromRouteParameter.php index 4eb11d1..d104e1c 100644 --- a/src/Attributes/FromRouteParameter.php +++ b/src/Attributes/FromRouteParameter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; +namespace Icefox\Data\Attributes; use Attribute; diff --git a/src/Attributes/Overwrite.php b/src/Attributes/Overwrite.php index 63a6453..a797cc6 100644 --- a/src/Attributes/Overwrite.php +++ b/src/Attributes/Overwrite.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Icefox\DTO\Attributes; +namespace Icefox\Data\Attributes; use Attribute; diff --git a/src/CustomHandlers.php b/src/CustomHandlers.php index f76b988..cf0eb15 100644 --- a/src/CustomHandlers.php +++ b/src/CustomHandlers.php @@ -1,8 +1,8 @@ make($class); @@ -104,13 +109,19 @@ class DataObjectFactory } if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) { - $input[$parameterName] = $isListType - ? array_map( - fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger), - $rawInput[$parameterName], - ) - : self::mapInput($valueType, $rawInput[$parameterName], $routeParameters, $logger); - continue; + if (class_exists($valueType)) { + $input[$parameterName] = $isListType + ? array_map( + fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger), + $rawInput[$parameterName], + ) + : self::mapInput($valueType, $rawInput[$parameterName], $routeParameters, $logger); + continue; + } + if (array_key_exists($parameterName, $rawInput)) { + $input[$parameterName] = $rawInput[$parameterName]; + continue; + } } if ($reflectionType instanceof ReflectionNamedType) { diff --git a/src/Factories/RuleFactory.php b/src/Factories/RuleFactory.php index 4dc4acf..df1bdd7 100644 --- a/src/Factories/RuleFactory.php +++ b/src/Factories/RuleFactory.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Icefox\DTO\Factories; +namespace Icefox\Data\Factories; -use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Attributes\Overwrite; -use Icefox\DTO\ParameterMeta; -use Icefox\DTO\ReflectionHelper; -use Icefox\DTO\Factories\RuleFactory; +use Icefox\Data\Attributes\Flat; +use Icefox\Data\Attributes\Overwrite; +use Icefox\Data\ParameterMeta; +use Icefox\Data\ReflectionHelper; +use Icefox\Data\Factories\RuleFactory; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; diff --git a/src/Factories/ValueFactory.php b/src/Factories/ValueFactory.php index 5509c51..835d7ea 100644 --- a/src/Factories/ValueFactory.php +++ b/src/Factories/ValueFactory.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Icefox\DTO\Factories; +namespace Icefox\Data\Factories; -use Icefox\DTO\Attributes\CastWith; -use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\ReflectionHelper; +use Icefox\Data\Attributes\CastWith; +use Icefox\Data\Attributes\Flat; +use Icefox\Data\ReflectionHelper; use Illuminate\Support\Facades\App; use ReflectionNamedType; use phpDocumentor\Reflection\PseudoTypes\Generic; diff --git a/src/IData.php b/src/IData.php new file mode 100644 index 0000000..0f2e749 --- /dev/null +++ b/src/IData.php @@ -0,0 +1,5 @@ +publishes([ + __DIR__ . '../../workbench/config/dto.php' => config_path('dto.php'), + ]); + $this->app->beforeResolving(function ($abstract, $parameters, $app) { if ($app->has($abstract)) { return; } - if (is_subclass_of($abstract, IDataObject::class)) { + if (is_subclass_of($abstract, IData::class)) { $app->bind($abstract, fn($container) => DataObjectFactory::fromRequest($abstract, $container['request'])); } }); } } - diff --git a/src/ReflectionHelper.php b/src/ReflectionHelper.php index 47ebd39..fa64c03 100644 --- a/src/ReflectionHelper.php +++ b/src/ReflectionHelper.php @@ -1,6 +1,6 @@ toBe(123) ->and($result['address']['street'])->toBe('Main St'); }); + +readonly class SimpleWithDefaults +{ + public function __construct( + public string $name, + public int $age, + public string $city = 'Unknown', + ) {} + + /** + * @return array + */ + public static function defaults(): array + { + return [ + 'city' => 'New York', + 'age' => 25, + ]; + } +} + +test('defaults with fromArray - basic usage', function () { + $object = DataObjectFactory::fromArray( + SimpleWithDefaults::class, + ['name' => 'John'], + [], + ); + + expect($object->name)->toBe('John') + ->and($object->age)->toBe(25) + ->and($object->city)->toBe('New York'); +}); + +readonly class NestedWithDefaults +{ + public function __construct( + public string $title, + public SimpleWithDefaults $user, + ) {} + + /** + * @return array> + */ + public static function defaults(): array + { + return [ + 'user' => [ + 'name' => 'Default User', + 'age' => 30, + ], + ]; + } +} + +test('defaults with nested objects', function () { + $object = DataObjectFactory::fromArray( + NestedWithDefaults::class, + ['title' => 'Admin Dashboard'], + [], + ); + + expect($object->title)->toBe('Admin Dashboard') + ->and($object->user->name)->toBe('Default User') + ->and($object->user->age)->toBe(30) + ->and($object->user->city)->toBe('Unknown'); +}); + +test('defaults merged with input - input overrides defaults', function () { + $object = DataObjectFactory::fromArray( + SimpleWithDefaults::class, + ['name' => 'Alice', 'age' => 40, 'city' => 'Los Angeles'], + [], + ); + + expect($object->name)->toBe('Alice') + ->and($object->age)->toBe(40) + ->and($object->city)->toBe('Los Angeles'); +}); + + +test('defaults with simple nested structures', function () { + $object = DataObjectFactory::fromArray( + NestedWithDefaults::class, + ['title' => 'Simple Project'], + [], + ); + + expect($object->title)->toBe('Simple Project') + ->and($object->user->name)->toBe('Default User') + ->and($object->user->age)->toBe(30); +}); + +readonly class ArrayWithDefaults +{ + /** + * @param array $settings + */ + public function __construct( + public string $projectName, + public array $settings, + ) {} + /** + * @return array> + */ + public static function defaults(): array + { + return [ + 'settings' => [ + 'theme' => 'dark', + 'notifications' => true, + 'language' => 'en', + ], + ]; + } +} + +test('defaults with array structures', function () { + $object = DataObjectFactory::fromArray( + ArrayWithDefaults::class, + ['projectName' => 'New Project'], + [], + ); + + expect($object->projectName)->toBe('New Project') + ->and($object->settings)->toBe([ + 'theme' => 'dark', + 'notifications' => true, + 'language' => 'en', + ]); +}); + +test('defaults partially overridden by input', function () { + $object = DataObjectFactory::fromArray( + ArrayWithDefaults::class, + [ + 'projectName' => 'Custom Project', + 'settings' => [ + 'theme' => 'light', + 'language' => 'fr', + ], + ], + [], + ); + + expect($object->projectName)->toBe('Custom Project') + ->and($object->settings)->toBe([ + 'theme' => 'light', + 'notifications' => true, // From defaults + 'language' => 'fr', + ]); +}); + +readonly class WithInputMappingAndDefaults +{ + public function __construct( + #[FromInput('full_name')] + public string $name, + #[FromInput('user_age')] + public int $age, + public string $role = 'user', + ) {} + /** + * @return array + */ + public static function defaults(): array + { + return [ + 'name' => 'Default Name', + 'role' => 'admin', + 'age' => 18, + ]; + } +} + +test('defaults work with FromInput attribute mapping', function () { + $object = DataObjectFactory::fromArray( + WithInputMappingAndDefaults::class, + ['full_name' => 'John Doe'], + [], + ); + + expect($object->name)->toBe('John Doe') // From input mapping + ->and($object->age)->toBe(18) // From defaults + ->and($object->role)->toBe('admin'); // From defaults +}); + +test('array_merge_recursive behavior with nested arrays', function () { + $object = DataObjectFactory::fromArray( + ArrayWithDefaults::class, + [ + 'projectName' => 'Test Project', + 'settings' => [ + 'new_setting' => 'custom_value', + ], + ], + [], + ); + + expect($object->settings)->toBe([ + 'theme' => 'dark', // From defaults + 'notifications' => true, // From defaults + 'language' => 'en', // From defaults + 'new_setting' => 'custom_value', // From input + ]); +}); diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index c92e06d..d0713e3 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -2,13 +2,13 @@ namespace Tests\Http; -use Icefox\DTO\IDataObject; +use Icefox\Data\IData; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Validator as ValidatorFacade; use Illuminate\Validation\Validator; -readonly class Basic implements IDataObject +readonly class Basic implements IData { public string $reply; public function __construct(string $message) @@ -35,7 +35,7 @@ test('fails on validation error', function () { ]); }); -readonly class WithCustomValidator implements IDataObject +readonly class WithCustomValidator implements IData { public string $reply; public function __construct(string $message) @@ -66,7 +66,7 @@ test('replies with custom validator', function () { ]); }); -readonly class WithCustomFailure implements IDataObject +readonly class WithCustomFailure implements IData { public function __construct(public bool $flag) {} @@ -87,7 +87,7 @@ test('uses custom response', function () { ->assertJson(['result' => 'invalid, but that is ok']); }); -readonly class WithDefaultObjectOnFailure implements IDataObject +readonly class WithDefaultObjectOnFailure implements IData { public function __construct(public bool $flag) {} diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index 16ec1a7..f902d73 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Tests\Rules; -use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Attributes\Overwrite; -use Icefox\DTO\Factories\RuleFactory; +use Icefox\Data\Attributes\Flat; +use Icefox\Data\Attributes\Overwrite; +use Icefox\Data\Factories\RuleFactory; use Illuminate\Support\Collection; readonly class BasicPrimitives diff --git a/tests/Values/ValuesTest.php b/tests/Values/ValuesTest.php index 17cd822..e592d17 100644 --- a/tests/Values/ValuesTest.php +++ b/tests/Values/ValuesTest.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Tests\Values; use Carbon\CarbonPeriod; -use Icefox\DTO\Attributes\CastWith; -use Icefox\DTO\Factories\ValueFactory; +use Icefox\Data\Attributes\CastWith; +use Icefox\Data\Factories\ValueFactory; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; diff --git a/workbench/config/dto.php b/workbench/config/dto.php index a06c16b..ecf8413 100644 --- a/workbench/config/dto.php +++ b/workbench/config/dto.php @@ -1,12 +1,12 @@ [ - Collection::class => CustomHandlers::CollectionRules(...), + Collection::class => CustomHandlers::class . "::CollectionRules", ], 'logging' => [ 'channel' => 'dto',