From bef42b3352648c18acc7da19c36e0c10aee89ac4 Mon Sep 17 00:00:00 2001 From: icefox Date: Wed, 18 Feb 2026 19:32:14 -0300 Subject: [PATCH 01/10] deprecated: try to magically resolve constructor --- src/Support/ValueFactory.php | 8 +++++--- tests/Classes/ObjectWithoutMapper.php | 2 +- tests/DataObjectTest.php | 5 ----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Support/ValueFactory.php b/src/Support/ValueFactory.php index 8229b35..efebe48 100644 --- a/src/Support/ValueFactory.php +++ b/src/Support/ValueFactory.php @@ -24,11 +24,13 @@ class ValueFactory return $mapper($rawValue); } - if (is_array($rawValue)) { - return App::makeWith($className, $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); } - return new $className($rawValue); + // Associative arrays leverage Laravel service container + return App::makeWith($className, $rawValue); } public static function resolveTypedValue(mixed $rawValue, Type $type): mixed diff --git a/tests/Classes/ObjectWithoutMapper.php b/tests/Classes/ObjectWithoutMapper.php index 4c37d78..82342e3 100644 --- a/tests/Classes/ObjectWithoutMapper.php +++ b/tests/Classes/ObjectWithoutMapper.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Tests\Classes; +use Carbon\Carbon; use Icefox\DTO\DataObject; -use Illuminate\Support\Carbon; readonly class ObjectWithoutMapper { diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index 9514994..1ed2ce4 100644 --- a/tests/DataObjectTest.php +++ b/tests/DataObjectTest.php @@ -198,11 +198,6 @@ test('failed validation throws ValidationException', function () { })->throws(ValidationException::class); -test('tries to resolve without mapper', function () { - $object = ObjectWithoutMapper::fromArray(['date' => '1990-04-01']); - expect($object->date->isSameDay('1990-04-01'))->toBeTrue(); -})->group('object-without-mapper'); - test('creates collection', function () { $object = CollectionDataObject::fromArray([ 'values' => [ From afb47c19772a827d112b3ae526754d4758fedac7 Mon Sep 17 00:00:00 2001 From: icefox Date: Wed, 18 Feb 2026 20:18:39 -0300 Subject: [PATCH 02/10] CastWith --- .../{FromMapper.php => CastWith.php} | 2 +- src/Config.php | 18 ++++++++ src/DataObject.php | 4 +- src/Support/RuleFactory.php | 6 ++- src/Support/ValueFactory.php | 43 ++++++++++++------- src/config/dto.php | 5 +-- tests/Classes/WithMapperObject.php | 4 +- 7 files changed, 56 insertions(+), 26 deletions(-) rename src/Attributes/{FromMapper.php => CastWith.php} (93%) create mode 100644 src/Config.php diff --git a/src/Attributes/FromMapper.php b/src/Attributes/CastWith.php similarity index 93% rename from src/Attributes/FromMapper.php rename to src/Attributes/CastWith.php index ffdce99..6d544c5 100644 --- a/src/Attributes/FromMapper.php +++ b/src/Attributes/CastWith.php @@ -7,7 +7,7 @@ namespace Icefox\DTO\Attributes; use Attribute; #[Attribute(Attribute::TARGET_PARAMETER)] -class FromMapper +class CastWith { /** * @param class-string $class diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..a25d8df --- /dev/null +++ b/src/Config.php @@ -0,0 +1,18 @@ +reflection->getName(); - if ($mapper = array_first($parameter->reflection->getAttributes(FromMapper::class))) { + if ($mapper = array_first($parameter->reflection->getAttributes(CastWith::class))) { $value = App::call( [App::make($mapper->newInstance()->class), 'map'], ['value' => $validator->getValue($parameterName)], diff --git a/src/Support/RuleFactory.php b/src/Support/RuleFactory.php index 623ebc9..a3572b6 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Icefox\DTO\Support; +use Icefox\DTO\Attributes\CastWith; +use Icefox\DTO\Config; use Icefox\DTO\Attributes\FromMapper; use Icefox\DTO\ParameterMeta; use Illuminate\Support\Facades\App; @@ -139,7 +141,7 @@ class RuleFactory } if ($type instanceof ReflectionNamedType && $name = $type->getName()) { - if ($globalRules = config('dto.rules.' . $name)) { + if ($globalRules = Config::getRules($name)) { foreach ($globalRules($parameter->reflection, $parameter->tag->getType()) as $scopedPrefix => $values) { $realPrefix = $root . $scopedPrefix; $rules[$realPrefix] = array_values(array_unique(array_merge($rules[$realPrefix] ?? [], $values))); @@ -166,7 +168,7 @@ class RuleFactory } } - foreach ($parameter->reflection->getAttributes(FromMapper::class) as $attr) { + foreach ($parameter->reflection->getAttributes(CastWith::class) as $attr) { $mapperClass = $attr->newInstance()->class; if (method_exists($mapperClass, 'rules')) { $subRules = App::call("$mapperClass@rules"); diff --git a/src/Support/ValueFactory.php b/src/Support/ValueFactory.php index efebe48..992402e 100644 --- a/src/Support/ValueFactory.php +++ b/src/Support/ValueFactory.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Icefox\DTO\Support; +use Icefox\DTO\Attributes\CastWith; +use Icefox\DTO\Config; use Illuminate\Support\Facades\App; use ReflectionNamedType; use ReflectionParameter; @@ -20,7 +22,7 @@ class ValueFactory { public static function constructObject(string $className, mixed $rawValue): object { - if ($mapper = config('dto.mappers.' . $className, null)) { + if ($mapper = Config::getCaster($className)) { return $mapper($rawValue); } @@ -83,21 +85,32 @@ class ValueFactory return null; } - if (is_null($type)) { - $reflectedType = $reflection->getType(); - if ($reflectedType instanceof ReflectionNamedType && $name = $reflectedType->getName()) { - return match ($name) { - 'string' => $rawValue, - 'bool' => boolval($rawValue), - 'int' => intval($rawValue), - 'float' => floatval($rawValue), - 'array' => $rawValue, - default => self::constructObject($name, $rawValue), - }; - } - return $rawValue; + $castWithAttrs = $reflection->getAttributes(CastWith::class); + if ($withCast = $reflection->getAttributes(CastWith::class)[0] ?? null) { + $caster = $withCast->newInstance()->class; + return App::call("$caster@cast", ['value' => $rawValue]); } - return self::resolveTypedValue($rawValue, $type); + if (!is_null($type)) { + return self::resolveTypedValue($rawValue, $type); + } + + $reflectedType = $reflection->getType(); + if ($reflectedType instanceof ReflectionNamedType && $name = $reflectedType->getName()) { + if ($caster = Config::getCaster($name)) { + return App::call($caster, ['value' => $rawValue]); + } + + return match ($name) { + 'string' => $rawValue, + 'bool' => boolval($rawValue), + 'int' => intval($rawValue), + 'float' => floatval($rawValue), + 'array' => $rawValue, + default => self::constructObject($name, $rawValue), + }; + } + + return $rawValue; } } diff --git a/src/config/dto.php b/src/config/dto.php index 634b846..201d3eb 100644 --- a/src/config/dto.php +++ b/src/config/dto.php @@ -4,11 +4,8 @@ use Icefox\DTO\Factories\CollectionFactory; use Illuminate\Support\Collection; return [ + 'cast' => [], 'rules' => [ Collection::class => CollectionFactory::rules(...), ], - 'default' => [ - 'factories' => [ - ], - ], ]; diff --git a/tests/Classes/WithMapperObject.php b/tests/Classes/WithMapperObject.php index 31e0bd3..899b8fe 100644 --- a/tests/Classes/WithMapperObject.php +++ b/tests/Classes/WithMapperObject.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Tests\Classes; use Carbon\CarbonPeriodImmutable; -use Icefox\DTO\Attributes\FromMapper; +use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\DataObject; readonly class WithMapperObject @@ -13,7 +13,7 @@ readonly class WithMapperObject use DataObject; public function __construct( - #[FromMapper(CarbonPeriodMapper::class)] + #[CastWith(CarbonPeriodMapper::class)] public CarbonPeriodImmutable $period, ) {} } From 3a26a2e0c2931ebda4feea43b3c7b657f81aa62f Mon Sep 17 00:00:00 2001 From: icefox Date: Wed, 18 Feb 2026 20:29:13 -0300 Subject: [PATCH 03/10] with cast tests --- src/DataObject.php | 4 +- 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/CarbonPeriodMapper.php | 2 +- 8 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 tests/Casters/CasterTest.php create mode 100644 tests/Casters/SimpleValue.php create mode 100644 tests/Casters/SimpleValueCaster.php create mode 100644 tests/Casters/WithGlobalCaster.php create mode 100644 tests/Casters/WithSpecificCaster.php create mode 100644 tests/Casters/WithoutCaster.php diff --git a/src/DataObject.php b/src/DataObject.php index c6b05d8..cc2fcf3 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -78,9 +78,9 @@ trait DataObject foreach ($parameters as $parameter) { $parameterName = $parameter->reflection->getName(); - if ($mapper = array_first($parameter->reflection->getAttributes(CastWith::class))) { + if ($castWith = array_first($parameter->reflection->getAttributes(CastWith::class))) { $value = App::call( - [App::make($mapper->newInstance()->class), 'map'], + [App::make($castWith->newInstance()->class), 'cast'], ['value' => $validator->getValue($parameterName)], ); $mappedInput[$parameterName] = $value; diff --git a/tests/Casters/CasterTest.php b/tests/Casters/CasterTest.php new file mode 100644 index 0000000..c8e6a59 --- /dev/null +++ b/tests/Casters/CasterTest.php @@ -0,0 +1,71 @@ + []]); + }); + + it('uses CastWith attribute over global config caster', function () { + $globalCaster = function (mixed $value): SimpleValue { + return new SimpleValue($value['value'] * 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 $value): SimpleValue { + return new SimpleValue($value['value'] * 3); + }; + config(['dto.cast.' . SimpleValue::class => $globalCaster]); + + $object = WithGlobalCaster::fromArray([ + 'value' => ['value' => 5], + ]); + + expect($object->value->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 new file mode 100644 index 0000000..1f08683 --- /dev/null +++ b/tests/Casters/SimpleValue.php @@ -0,0 +1,10 @@ + ['required', 'numeric'], + ]; + } +} diff --git a/tests/Casters/WithGlobalCaster.php b/tests/Casters/WithGlobalCaster.php new file mode 100644 index 0000000..98b39ef --- /dev/null +++ b/tests/Casters/WithGlobalCaster.php @@ -0,0 +1,16 @@ + Date: Wed, 18 Feb 2026 21:57:12 -0300 Subject: [PATCH 04/10] OverwriteRules --- src/Attributes/OverwriteRules.php | 12 +++ src/DataObject.php | 40 +++++++- src/Support/RuleFactory.php | 6 +- tests/DataObjectTest.php | 22 ++--- tests/Rules/RulesTest.php | 120 ++++++++++++++++++++++++ tests/Rules/WithEmptyOverwriteRules.php | 23 +++++ tests/Rules/WithMergedRules.php | 23 +++++ tests/Rules/WithOverwriteRules.php | 25 +++++ 8 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 src/Attributes/OverwriteRules.php create mode 100644 tests/Rules/RulesTest.php create mode 100644 tests/Rules/WithEmptyOverwriteRules.php create mode 100644 tests/Rules/WithMergedRules.php create mode 100644 tests/Rules/WithOverwriteRules.php diff --git a/src/Attributes/OverwriteRules.php b/src/Attributes/OverwriteRules.php new file mode 100644 index 0000000..e1cee40 --- /dev/null +++ b/src/Attributes/OverwriteRules.php @@ -0,0 +1,12 @@ +fails()) { @@ -101,6 +103,42 @@ trait DataObject return []; } + /** + * @return array> + */ + public static function getRules(): array + { + $parameters = RuleFactory::getParametersMeta(static::class); + $customRules = static::rules(); + $classReflection = new ReflectionClass(static::class); + $rulesMethod = $classReflection->getMethod('rules'); + + if (!empty($rulesMethod->getAttributes(OverwriteRules::class))) { + return $customRules; + } + + $inferredRules = RuleFactory::infer($parameters, ''); + return self::mergeRules($inferredRules, $customRules); + } + + /** + * @param array> $inferredRules + * @param array> $customRules + * @return array> + */ + protected static function mergeRules(array $inferredRules, array $customRules): array + { + $merged = $inferredRules; + foreach ($customRules as $key => $rules) { + if (isset($merged[$key])) { + $merged[$key] = array_values(array_unique(array_merge($merged[$key], $rules))); + } else { + $merged[$key] = $rules; + } + } + return $merged; + } + /** * @param array $data * @param array> $rules diff --git a/src/Support/RuleFactory.php b/src/Support/RuleFactory.php index a3572b6..db07382 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -60,7 +60,7 @@ class RuleFactory $paramsSub = self::getParametersMeta($type->getFqsen()->__toString()); $rules = array_merge( $rules, - self::buildRules($paramsSub, $prefix . '.'), + self::infer($paramsSub, $prefix . '.'), ); } return $rules; @@ -103,7 +103,7 @@ class RuleFactory * @param array $parameters * @return array> */ - public static function buildRules(array $parameters, string $prefix): array + public static function infer(array $parameters, string $prefix): array { $rules = []; foreach ($parameters as $parameter) { @@ -163,7 +163,7 @@ class RuleFactory $paramsSub = self::getParametersMeta($type->getName()); $rules = array_merge( $rules, - self::buildRules($paramsSub, $root . '.'), + self::infer($paramsSub, $root . '.'), ); } } diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index 1ed2ce4..4177c8b 100644 --- a/tests/DataObjectTest.php +++ b/tests/DataObjectTest.php @@ -2,12 +2,10 @@ namespace Tests; -use Icefox\DTO\Support\RuleFactory; use Illuminate\Validation\ValidationException; use Tests\Classes\ArrayDataObject; use Tests\Classes\CollectionDataObject; use Tests\Classes\FromInputObject; -use Tests\Classes\ObjectWithoutMapper; use Tests\Classes\OptionalData; use Tests\Classes\OptionalNullableData; use Tests\Classes\PrimitiveData; @@ -16,7 +14,7 @@ use Tests\Classes\WithMapperObject; describe('primitive data test', function () { it('creates required rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(PrimitiveData::class), ''); + $rules = PrimitiveData::getRules(); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['required', 'numeric'], @@ -42,7 +40,7 @@ describe('primitive data test', function () { describe('optional data', function () { it('creates optional rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalData::class), ''); + $rules = OptionalData::getRules(); expect($rules)->toMatchArray([ 'string' => ['sometimes'], 'int' => ['sometimes', 'numeric'], @@ -63,7 +61,7 @@ describe('optional data', function () { describe('nullable data', function () { it('creates nullable rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalNullableData::class), ''); + $rules = OptionalNullableData::getRules(); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['nullable', 'numeric'], @@ -93,10 +91,7 @@ describe('nullable data', function () { describe('reference other DataObject', function () { it('creates recursive rules', function () { - $rules = RuleFactory::buildRules( - RuleFactory::getParametersMeta(RecursiveDataObject::class), - '', - ); + $rules = RecursiveDataObject::getRules(); expect($rules)->toMatchArray([ 'string' => ['required'], 'extra.string' => ['required'], @@ -109,7 +104,7 @@ describe('reference other DataObject', function () { describe('primitive array', function () { it('creates array rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(ArrayDataObject::class), ''); + $rules = ArrayDataObject::getRules(); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required', 'numeric'], @@ -120,10 +115,7 @@ describe('primitive array', function () { describe('object array', function () { it('creates array rules', function () { - $rules = RuleFactory::buildRules( - RuleFactory::getParametersMeta(CollectionDataObject::class), - '', - ); + $rules = CollectionDataObject::getRules(); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required'], @@ -139,7 +131,7 @@ describe('can map input names', function () { it('creates rules with property names', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(FromInputObject::class), ''); + $rules = FromInputObject::getRules(); expect($rules)->toMatchArray([ 'text' => ['required' ], 'standard' => ['required', 'numeric'], diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php new file mode 100644 index 0000000..19ad404 --- /dev/null +++ b/tests/Rules/RulesTest.php @@ -0,0 +1,120 @@ +toBe([ + 'value' => ['required', 'numeric'], + ]); + }); + + it('returns inferred rules shape regardless of OverwriteRules attribute', function () { + $parameters = RuleFactory::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 = WithMergedRules::getRules(); + + expect($rules)->toBe([ + 'value' => ['required', 'numeric', 'max:20'], + ]); + }); + + it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () { + $rules = WithOverwriteRules::getRules(); + + expect($rules)->toBe([ + 'value' => ['numeric', 'max:20'], + ]); + }); + + it('returns empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () { + $rules = WithEmptyOverwriteRules::getRules(); + + 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 () { + $object = WithOverwriteRules::fromArray([]); + + expect($object)->toBeInstanceOf(WithOverwriteRules::class); + }); + + it('does not enforce inferred numeric rule when overwritten', function () { + $rules = WithOverwriteRules::rules(); + expect($rules)->toHaveKey('value'); + expect($rules['value'])->toBe(['numeric', 'max:20']); + }); +}); + +describe('empty rules overwrite', function () { + it('allows any value when rules are empty with OverwriteRules', function () { + $object = WithEmptyOverwriteRules::fromArray([ + 'value' => 999, + ]); + + expect($object->value)->toBe(999); + }); + + it('allows missing value when rules are empty with OverwriteRules', function () { + $object = WithEmptyOverwriteRules::fromArray([]); + + expect($object)->toBeInstanceOf(WithEmptyOverwriteRules::class); + }); +}); diff --git a/tests/Rules/WithEmptyOverwriteRules.php b/tests/Rules/WithEmptyOverwriteRules.php new file mode 100644 index 0000000..e743bed --- /dev/null +++ b/tests/Rules/WithEmptyOverwriteRules.php @@ -0,0 +1,23 @@ + ['max:20'], + ]; + } +} diff --git a/tests/Rules/WithOverwriteRules.php b/tests/Rules/WithOverwriteRules.php new file mode 100644 index 0000000..21b2145 --- /dev/null +++ b/tests/Rules/WithOverwriteRules.php @@ -0,0 +1,25 @@ + ['numeric', 'max:20'], + ]; + } +} From f1d46dacb6f5d0250cd916d410cf10b0cf4e5c55 Mon Sep 17 00:00:00 2001 From: icefox Date: Thu, 19 Feb 2026 08:44:49 -0300 Subject: [PATCH 05/10] ValidationFailure tests --- composer.json | 3 +- composer.lock | 76 ++++++++++++++- src/DataObject.php | 10 +- tests/Classes/FailsReturnsDefault.php | 23 +++++ tests/Classes/FailsReturnsNull.php | 23 +++++ tests/Classes/FailsWithHttpResponse.php | 26 +++++ tests/FailedValidation/FailsMethodTest.php | 108 +++++++++++++++++++++ 7 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 tests/Classes/FailsReturnsDefault.php create mode 100644 tests/Classes/FailsReturnsNull.php create mode 100644 tests/Classes/FailsWithHttpResponse.php create mode 100644 tests/FailedValidation/FailsMethodTest.php diff --git a/composer.json b/composer.json index ad9b9d4..be192b0 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "pestphp/pest": "^4.4", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.94", - "orchestra/testbench": "^9.16" + "orchestra/testbench": "^9.16", + "pestphp/pest-plugin-laravel": "^4.0" }, "license": "GPL-2.0-only", "autoload": { diff --git a/composer.lock b/composer.lock index 8b91f11..27eafed 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1d9eef5574135e39ab7eaa6beae3fdad", + "content-hash": "336ed1e898bc39b0e9990becc327415c", "packages": [ { "name": "brick/math", @@ -7992,6 +7992,80 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-laravel", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-laravel.git", + "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/e12a07046b826a40b1c8632fd7b80d6b8d7b628e", + "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e", + "shasum": "" + }, + "require": { + "laravel/framework": "^11.45.2|^12.25.0", + "pestphp/pest": "^4.0.0", + "php": "^8.3.0" + }, + "require-dev": { + "laravel/dusk": "^8.3.3", + "orchestra/testbench": "^9.13.0|^10.5.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Laravel\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Pest\\Laravel\\PestServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Laravel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Laravel Plugin", + "keywords": [ + "framework", + "laravel", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-08-20T12:46:37+00:00" + }, { "name": "pestphp/pest-plugin-mutate", "version": "v4.0.1", diff --git a/src/DataObject.php b/src/DataObject.php index 5487e00..163dbe4 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -43,7 +43,7 @@ trait DataObject /** * @param array $input */ - public static function fromArray(array $input): static + public static function fromArray(array $input): ?static { $parameters = RuleFactory::getParametersMeta(static::class); foreach ($parameters as $parameter) { @@ -72,8 +72,7 @@ trait DataObject $validator = static::withValidator($input, $rules); if ($validator->fails()) { - $exception = new ValidationException($validator); - throw $exception; + return static::fails($validator); } $mappedInput = []; @@ -103,6 +102,11 @@ trait DataObject return []; } + public static function fails(Validator $validator): ?static + { + throw new ValidationException($validator); + } + /** * @return array> */ diff --git a/tests/Classes/FailsReturnsDefault.php b/tests/Classes/FailsReturnsDefault.php new file mode 100644 index 0000000..7fdba3a --- /dev/null +++ b/tests/Classes/FailsReturnsDefault.php @@ -0,0 +1,23 @@ +json(['errors' => $validator->errors()], 422) + ); + } +} diff --git a/tests/FailedValidation/FailsMethodTest.php b/tests/FailedValidation/FailsMethodTest.php new file mode 100644 index 0000000..11b2ca1 --- /dev/null +++ b/tests/FailedValidation/FailsMethodTest.php @@ -0,0 +1,108 @@ + 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']); + }); +}); From 75ce822b84d29347193c163458083585efa57198 Mon Sep 17 00:00:00 2001 From: icefox Date: Thu, 19 Feb 2026 10:33:01 -0300 Subject: [PATCH 06/10] Logging --- phpstan.neon.dist | 1 - src/DataObject.php | 5 + src/Log.php | 67 ++++++++ src/config/dto.php | 10 ++ tests/Logging/CustomLogger.php | 36 +++++ tests/Logging/LogTest.php | 281 +++++++++++++++++++++++++++++++++ 6 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 src/Log.php create mode 100644 tests/Logging/CustomLogger.php create mode 100644 tests/Logging/LogTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 79b90ad..e746411 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,4 @@ parameters: paths: - src - - tests level: 5 diff --git a/src/DataObject.php b/src/DataObject.php index 163dbe4..26f35ad 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -45,6 +45,7 @@ trait DataObject */ public static function fromArray(array $input): ?static { + $logger = new Log(); $parameters = RuleFactory::getParametersMeta(static::class); foreach ($parameters as $parameter) { $parameterName = $parameter->reflection->getName(); @@ -66,12 +67,15 @@ trait DataObject continue; } } + $logger->inputRaw($input); $rules = static::getRules(); + $logger->rules($rules); $validator = static::withValidator($input, $rules); if ($validator->fails()) { + $logger->validationErrors($validator->errors()->toArray()); return static::fails($validator); } @@ -94,6 +98,7 @@ trait DataObject $parameter->reflection, ); } + $logger->input($mappedInput); return App::make(static::class, $mappedInput); } diff --git a/src/Log.php b/src/Log.php new file mode 100644 index 0000000..eb75ebf --- /dev/null +++ b/src/Log.php @@ -0,0 +1,67 @@ +logger = App::call($raw); + return; + } + if (is_object($raw)) { + $this->logger = $raw; + return; + } + if (is_string($raw) && class_exists($raw)) { + $this->logger = App::make($raw); + return; + } + $this->logger = new NullLogger(); + } + + /** + * @param array> $rules + */ + public function rules(array $rules): void + { + $level = config('dto.log.rules') ?? LogLevel::DEBUG; + $this->logger->log($level, print_r($rules, true)); + } + + /** + * @param array $input + */ + public function input(array $input): void + { + $level = config('dto.log.input') ?? LogLevel::DEBUG; + $this->logger->log($level, print_r($input, true)); + } + + /** + * @param array $input + */ + public function inputRaw(array $input): void + { + $level = config('dto.log.raw_input') ?? LogLevel::DEBUG; + $this->logger->log($level, print_r($input, true)); + } + + /** + * @param array> $errors + */ + public function validationErrors(array $errors): void + { + $level = config('dto.log.validation_errors') ?? LogLevel::INFO; + $this->logger->log($level, print_r($errors, true)); + } +} diff --git a/src/config/dto.php b/src/config/dto.php index 201d3eb..bb8ae6e 100644 --- a/src/config/dto.php +++ b/src/config/dto.php @@ -2,10 +2,20 @@ use Icefox\DTO\Factories\CollectionFactory; use Illuminate\Support\Collection; +use Psr\Log\LogLevel; +use Psr\Log\NullLogger; return [ 'cast' => [], 'rules' => [ Collection::class => CollectionFactory::rules(...), ], + 'log' => [ + 'logger' => NullLogger::class, + 'internal' => LogLevel::WARNING, + 'rules' => LogLevel::DEBUG, + 'input' => LogLevel::DEBUG, + 'raw_input' => LogLevel::DEBUG, + 'validation_errors' => LogLevel::INFO, + ], ]; diff --git a/tests/Logging/CustomLogger.php b/tests/Logging/CustomLogger.php new file mode 100644 index 0000000..c4bff3d --- /dev/null +++ b/tests/Logging/CustomLogger.php @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..6847938 --- /dev/null +++ b/tests/Logging/LogTest.php @@ -0,0 +1,281 @@ +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'); + }); +}); From 77d1aebc0a02fb13e9afca83165a22dbc88954e9 Mon Sep 17 00:00:00 2001 From: icefox Date: Thu, 19 Feb 2026 12:38:26 -0300 Subject: [PATCH 07/10] Flat wit --- src/Attributes/Flat.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/Attributes/Flat.php diff --git a/src/Attributes/Flat.php b/src/Attributes/Flat.php new file mode 100644 index 0000000..27d73ad --- /dev/null +++ b/src/Attributes/Flat.php @@ -0,0 +1,12 @@ + Date: Thu, 19 Feb 2026 15:34:33 -0300 Subject: [PATCH 08/10] refactor rules out of DataObject --- src/DataObject.php | 46 +------------------- src/ReflectionHelper.php | 48 +++++++++++++++++++++ src/Support/RuleFactory.php | 86 +++++++++++++++++++++---------------- tests/DataObjectTest.php | 16 ++++--- tests/Rules/RulesTest.php | 12 +++--- 5 files changed, 115 insertions(+), 93 deletions(-) create mode 100644 src/ReflectionHelper.php diff --git a/src/DataObject.php b/src/DataObject.php index 26f35ad..1c1fc1e 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -46,7 +46,7 @@ trait DataObject public static function fromArray(array $input): ?static { $logger = new Log(); - $parameters = RuleFactory::getParametersMeta(static::class); + $parameters = ReflectionHelper::getParametersMeta(static::class); foreach ($parameters as $parameter) { $parameterName = $parameter->reflection->getName(); @@ -69,8 +69,7 @@ trait DataObject } $logger->inputRaw($input); - $rules = static::getRules(); - $logger->rules($rules); + $rules = (new RuleFactory($logger))->make(static::class); $validator = static::withValidator($input, $rules); @@ -102,52 +101,11 @@ trait DataObject return App::make(static::class, $mappedInput); } - public static function rules(): array - { - return []; - } - public static function fails(Validator $validator): ?static { throw new ValidationException($validator); } - /** - * @return array> - */ - public static function getRules(): array - { - $parameters = RuleFactory::getParametersMeta(static::class); - $customRules = static::rules(); - $classReflection = new ReflectionClass(static::class); - $rulesMethod = $classReflection->getMethod('rules'); - - if (!empty($rulesMethod->getAttributes(OverwriteRules::class))) { - return $customRules; - } - - $inferredRules = RuleFactory::infer($parameters, ''); - return self::mergeRules($inferredRules, $customRules); - } - - /** - * @param array> $inferredRules - * @param array> $customRules - * @return array> - */ - protected static function mergeRules(array $inferredRules, array $customRules): array - { - $merged = $inferredRules; - foreach ($customRules as $key => $rules) { - if (isset($merged[$key])) { - $merged[$key] = array_values(array_unique(array_merge($merged[$key], $rules))); - } else { - $merged[$key] = $rules; - } - } - return $merged; - } - /** * @param array $data * @param array> $rules diff --git a/src/ReflectionHelper.php b/src/ReflectionHelper.php new file mode 100644 index 0000000..5c92a22 --- /dev/null +++ b/src/ReflectionHelper.php @@ -0,0 +1,48 @@ + + */ + public static function getParametersMeta(string $class): array + { + if (array_key_exists($class, self::$cache)) { + return self::$cache[$class]; + } + + $reflection = new ReflectionClass($class); + $constructor = $reflection->getConstructor(); + try { + $docblockParams = (DocBlockFactory::createInstance())->create( + $constructor->getDocComment(), + (new ContextFactory())->createFromReflector($constructor), + )->getTagsByName('param'); + } catch (\Exception) { + $docblockParams = []; + } + self::$cache[$class] = array_map( + fn(ReflectionParameter $p) => new ParameterMeta( + $p, + array_find( + $docblockParams, + fn(Tag $tag) => $tag instanceof Param ? $tag->getVariableName() == $p->getName() : false, + ), + ), + $constructor->getParameters(), + ); + return self::$cache[$class]; + } +} + diff --git a/src/Support/RuleFactory.php b/src/Support/RuleFactory.php index db07382..bd48324 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -5,9 +5,12 @@ declare(strict_types=1); namespace Icefox\DTO\Support; use Icefox\DTO\Attributes\CastWith; +use Icefox\DTO\Attributes\OverwriteRules; use Icefox\DTO\Config; use Icefox\DTO\Attributes\FromMapper; +use Icefox\DTO\Log; use Icefox\DTO\ParameterMeta; +use Icefox\DTO\ReflectionHelper; use Illuminate\Support\Facades\App; use Illuminate\Validation\Rule; use ReflectionClass; @@ -29,8 +32,6 @@ use phpDocumentor\Reflection\Types\Object_; class RuleFactory { - protected static array $cache = []; - /** * @return array> */ @@ -57,7 +58,7 @@ class RuleFactory } elseif ($type instanceof Float_ || $type instanceof Integer) { $rules[$prefix][] = 'numeric'; } elseif ($type instanceof Object_) { - $paramsSub = self::getParametersMeta($type->getFqsen()->__toString()); + $paramsSub = ReflectionHelper::getParametersMeta($type->getFqsen()->__toString()); $rules = array_merge( $rules, self::infer($paramsSub, $prefix . '.'), @@ -66,39 +67,6 @@ class RuleFactory return $rules; } - /** - * @param class-string $class - * @return array - */ - public static function getParametersMeta(string $class): array - { - if (array_key_exists($class, self::$cache)) { - return self::$cache[$class]; - } - - $reflection = new ReflectionClass($class); - $constructor = $reflection->getConstructor(); - try { - $docblockParams = (DocBlockFactory::createInstance())->create( - $constructor->getDocComment(), - (new ContextFactory())->createFromReflector($constructor), - )->getTagsByName('param'); - } catch (\Exception) { - $docblockParams = []; - } - self::$cache[$class] = array_map( - fn(ReflectionParameter $p) => new ParameterMeta( - $p, - array_find( - $docblockParams, - fn(Tag $tag) => $tag instanceof Param ? $tag->getVariableName() == $p->getName() : false, - ), - ), - $constructor->getParameters(), - ); - return self::$cache[$class]; - } - /** * @param array $parameters * @return array> @@ -160,7 +128,7 @@ class RuleFactory $rules[$root][] = Rule::enum($name); } } else { - $paramsSub = self::getParametersMeta($type->getName()); + $paramsSub = ReflectionHelper::getParametersMeta($type->getName()); $rules = array_merge( $rules, self::infer($paramsSub, $root . '.'), @@ -190,4 +158,48 @@ class RuleFactory return $rules; } + + public function __construct(public Log $log) {} + + /** + * @param class-string $class + * @return array> + */ + public function make(string $class): array + { + $parameters = ReflectionHelper::getParametersMeta($class); + + $classReflection = new ReflectionClass($class); + $hasRulesMethod = $classReflection->hasMethod('rules'); + + $customRules = $hasRulesMethod ? App::call("$class::rules", []) : []; + + + if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::class))) { + $rules = $customRules; + } else { + $inferredRules = RuleFactory::infer($parameters, ''); + $rules = self::mergeRules($inferredRules, $customRules); + } + $this->log->rules($rules); + return $rules; + } + + /** + * @param array> $inferredRules + * @param array> $customRules + * @return array> + */ + protected function mergeRules(array $inferredRules, array $customRules): array + { + $merged = $inferredRules; + foreach ($customRules as $key => $rules) { + if (isset($merged[$key])) { + $merged[$key] = array_values(array_unique(array_merge($merged[$key], $rules))); + } else { + $merged[$key] = $rules; + } + } + return $merged; + } } diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index 4177c8b..b68a4b7 100644 --- a/tests/DataObjectTest.php +++ b/tests/DataObjectTest.php @@ -2,6 +2,8 @@ namespace Tests; +use Icefox\DTO\Log; +use Icefox\DTO\Support\RuleFactory; use Illuminate\Validation\ValidationException; use Tests\Classes\ArrayDataObject; use Tests\Classes\CollectionDataObject; @@ -14,7 +16,7 @@ use Tests\Classes\WithMapperObject; describe('primitive data test', function () { it('creates required rules', function () { - $rules = PrimitiveData::getRules(); + $rules = (new RuleFactory(new Log()))->make(PrimitiveData::class); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['required', 'numeric'], @@ -40,7 +42,7 @@ describe('primitive data test', function () { describe('optional data', function () { it('creates optional rules', function () { - $rules = OptionalData::getRules(); + $rules = (new RuleFactory(new Log()))->make(OptionalData::class); expect($rules)->toMatchArray([ 'string' => ['sometimes'], 'int' => ['sometimes', 'numeric'], @@ -61,7 +63,7 @@ describe('optional data', function () { describe('nullable data', function () { it('creates nullable rules', function () { - $rules = OptionalNullableData::getRules(); + $rules = (new RuleFactory(new Log()))->make(OptionalNullableData::class); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['nullable', 'numeric'], @@ -91,7 +93,7 @@ describe('nullable data', function () { describe('reference other DataObject', function () { it('creates recursive rules', function () { - $rules = RecursiveDataObject::getRules(); + $rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class); expect($rules)->toMatchArray([ 'string' => ['required'], 'extra.string' => ['required'], @@ -104,7 +106,7 @@ describe('reference other DataObject', function () { describe('primitive array', function () { it('creates array rules', function () { - $rules = ArrayDataObject::getRules(); + $rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required', 'numeric'], @@ -115,7 +117,7 @@ describe('primitive array', function () { describe('object array', function () { it('creates array rules', function () { - $rules = CollectionDataObject::getRules(); + $rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required'], @@ -131,7 +133,7 @@ describe('can map input names', function () { it('creates rules with property names', function () { - $rules = FromInputObject::getRules(); + $rules = (new RuleFactory(new Log()))->make(FromInputObject::class); expect($rules)->toMatchArray([ 'text' => ['required' ], 'standard' => ['required', 'numeric'], diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index 19ad404..c769acd 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Tests\Rules; +use Icefox\DTO\Log; +use Icefox\DTO\ReflectionHelper; use Icefox\DTO\Support\RuleFactory; use Illuminate\Validation\ValidationException; use Tests\Rules\WithEmptyOverwriteRules; @@ -12,7 +14,7 @@ use Tests\Rules\WithOverwriteRules; describe('rules array shape', function () { it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () { - $parameters = RuleFactory::getParametersMeta(WithMergedRules::class); + $parameters = ReflectionHelper::getParametersMeta(WithMergedRules::class); $rules = RuleFactory::infer($parameters, ''); expect($rules)->toBe([ @@ -21,7 +23,7 @@ describe('rules array shape', function () { }); it('returns inferred rules shape regardless of OverwriteRules attribute', function () { - $parameters = RuleFactory::getParametersMeta(WithOverwriteRules::class); + $parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class); $rules = RuleFactory::infer($parameters, ''); expect($rules)->toBe([ @@ -32,7 +34,7 @@ describe('rules array shape', function () { describe('getRules method', function () { it('returns merged rules from DataObject::getRules()', function () { - $rules = WithMergedRules::getRules(); + $rules = (new RuleFactory(new Log()))->make(WithMergedRules::class); expect($rules)->toBe([ 'value' => ['required', 'numeric', 'max:20'], @@ -40,7 +42,7 @@ describe('getRules method', function () { }); it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () { - $rules = WithOverwriteRules::getRules(); + $rules = (new RuleFactory(new Log()))->make(WithOverwriteRules::class); expect($rules)->toBe([ 'value' => ['numeric', 'max:20'], @@ -48,7 +50,7 @@ describe('getRules method', function () { }); it('returns empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () { - $rules = WithEmptyOverwriteRules::getRules(); + $rules = (new RuleFactory(new Log()))->make(WithEmptyOverwriteRules::class); expect($rules)->toBe([]); }); From d83a324eb0665695caf8ec1546f3c55976527a6e Mon Sep 17 00:00:00 2001 From: icefox Date: Thu, 19 Feb 2026 16:38:35 -0300 Subject: [PATCH 09/10] wip flattening --- src/Support/RuleFactory.php | 41 +++++++++++------------ tests/Flattening/Classes/BasicRoot.php | 12 +++++++ tests/Flattening/Classes/RequiredLeaf.php | 12 +++++++ tests/Flattening/FlatteningTest.php | 18 ++++++++++ tests/Rules/RulesTest.php | 4 +-- 5 files changed, 64 insertions(+), 23 deletions(-) create mode 100644 tests/Flattening/Classes/BasicRoot.php create mode 100644 tests/Flattening/Classes/RequiredLeaf.php create mode 100644 tests/Flattening/FlatteningTest.php diff --git a/src/Support/RuleFactory.php b/src/Support/RuleFactory.php index bd48324..05e4557 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -5,9 +5,9 @@ declare(strict_types=1); namespace Icefox\DTO\Support; use Icefox\DTO\Attributes\CastWith; +use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\OverwriteRules; use Icefox\DTO\Config; -use Icefox\DTO\Attributes\FromMapper; use Icefox\DTO\Log; use Icefox\DTO\ParameterMeta; use Icefox\DTO\ReflectionHelper; @@ -15,16 +15,12 @@ use Illuminate\Support\Facades\App; use Illuminate\Validation\Rule; use ReflectionClass; use ReflectionNamedType; -use ReflectionParameter; use ReflectionUnionType; use BackedEnum; -use phpDocumentor\Reflection\DocBlockFactory; -use phpDocumentor\Reflection\DocBlock\Tag; use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\AbstractList; use phpDocumentor\Reflection\Types\Boolean; -use phpDocumentor\Reflection\Types\ContextFactory; use phpDocumentor\Reflection\Types\Float_; use phpDocumentor\Reflection\Types\Integer; use phpDocumentor\Reflection\Types\Nullable; @@ -61,7 +57,7 @@ class RuleFactory $paramsSub = ReflectionHelper::getParametersMeta($type->getFqsen()->__toString()); $rules = array_merge( $rules, - self::infer($paramsSub, $prefix . '.'), + self::infer($paramsSub, $prefix), ); } return $rules; @@ -71,10 +67,13 @@ class RuleFactory * @param array $parameters * @return array> */ - public static function infer(array $parameters, string $prefix): array + public static function infer(array $parameters, string $basePrefix): array { $rules = []; foreach ($parameters as $parameter) { + $prefix = $basePrefix + . (empty($basePrefix) ? '' : '.') + . (empty($parameter->reflection->getAttributes(Flat::class)) ? $parameter->reflection->getName() : ''); foreach (self::buildParameterRule($parameter, $prefix) as $key => $newRules) { $rules[$key] = $newRules; } @@ -89,18 +88,17 @@ class RuleFactory { $type = $parameter->reflection->getType(); - $root = $prefix . $parameter->reflection->getName(); if (empty($type)) { - return [$root => $parameter->reflection->isOptional() ? ['sometimes'] : ['required']]; + return [$prefix => $parameter->reflection->isOptional() ? ['sometimes'] : ['required']]; } - $rules = [$root => []]; + $rules = [$prefix => []]; if ($parameter->reflection->isOptional()) { - $rules[$root][] = 'sometimes'; + $rules[$prefix][] = 'sometimes'; } elseif ($type->allowsNull()) { - $rules[$root][] = 'nullable'; + $rules[$prefix][] = 'nullable'; } else { - $rules[$root][] = 'required'; + $rules[$prefix][] = 'required'; } if ($type instanceof ReflectionUnionType) { @@ -111,27 +109,27 @@ class RuleFactory if ($type instanceof ReflectionNamedType && $name = $type->getName()) { if ($globalRules = Config::getRules($name)) { foreach ($globalRules($parameter->reflection, $parameter->tag->getType()) as $scopedPrefix => $values) { - $realPrefix = $root . $scopedPrefix; + $realPrefix = $prefix . $scopedPrefix; $rules[$realPrefix] = array_values(array_unique(array_merge($rules[$realPrefix] ?? [], $values))); } } if ($name === 'string') { } elseif ($name === 'bool') { - $rules[$root][] = 'boolean'; + $rules[$prefix][] = 'boolean'; } elseif ($name === 'int' || $name === 'float') { - $rules[$root][] = 'numeric'; + $rules[$prefix][] = 'numeric'; } elseif ($name === 'array') { - $rules[$root][] = 'array'; + $rules[$prefix][] = 'array'; } elseif (enum_exists($name)) { $ref = new ReflectionClass($name); if ($ref->isSubclassOf(BackedEnum::class)) { - $rules[$root][] = Rule::enum($name); + $rules[$prefix][] = Rule::enum($name); } } else { $paramsSub = ReflectionHelper::getParametersMeta($type->getName()); $rules = array_merge( $rules, - self::infer($paramsSub, $root . '.'), + self::infer($paramsSub, $prefix), ); } } @@ -141,15 +139,16 @@ class RuleFactory if (method_exists($mapperClass, 'rules')) { $subRules = App::call("$mapperClass@rules"); foreach ($subRules as $key => &$value) { - $path = empty($key) ? $root : ($root . '.' . $key); + $path = empty($key) ? $prefix : ($prefix . '.' . $key); $rules[$path] = array_values(array_unique(array_merge($rules[$path] ?? [], $value))); } } } + if ($parameter->tag instanceof Param) { $docblockRules = self::getRulesFromDocBlock( $parameter->tag->getType(), - $prefix . $parameter->reflection->getName(), + $prefix, ); foreach ($docblockRules as $key => &$values) { $rules[$key] = array_values(array_unique(array_merge($rules[$key] ?? [], $values))); diff --git a/tests/Flattening/Classes/BasicRoot.php b/tests/Flattening/Classes/BasicRoot.php new file mode 100644 index 0000000..299f858 --- /dev/null +++ b/tests/Flattening/Classes/BasicRoot.php @@ -0,0 +1,12 @@ +make(BasicRoot::class); + expect($rules)->toMatchArray([ + 'text' => ['required'], + 'value' => ['required', 'numeric'], + ]); + }); +}); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index c769acd..fca9610 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -15,7 +15,7 @@ use Tests\Rules\WithOverwriteRules; describe('rules array shape', function () { it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () { $parameters = ReflectionHelper::getParametersMeta(WithMergedRules::class); - $rules = RuleFactory::infer($parameters, ''); + $rules = RuleFactory::infer($parameters, '', ''); expect($rules)->toBe([ 'value' => ['required', 'numeric'], @@ -24,7 +24,7 @@ describe('rules array shape', function () { it('returns inferred rules shape regardless of OverwriteRules attribute', function () { $parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class); - $rules = RuleFactory::infer($parameters, ''); + $rules = RuleFactory::infer($parameters, '', ''); expect($rules)->toBe([ 'value' => ['required', 'numeric'], From 367858c97cb9b911027597a81a4d2401a08fb4fb Mon Sep 17 00:00:00 2001 From: icefox Date: Mon, 23 Feb 2026 21:09:02 -0300 Subject: [PATCH 10/10] workbench, tests --- composer.json | 29 +++- composer.lock | 2 +- flake.nix | 12 +- src/Config.php | 53 ++++++- src/DataObject.php | 98 +------------ src/DataObjectFactory.php | 81 +++++++++++ src/DataObjectServiceProvider.php | 27 ---- src/Factories/CollectionFactory.php | 18 ++- src/InputFactory.php | 41 ++++++ src/Log.php | 67 --------- src/Support/RuleFactory.php | 95 +++++++------ src/Support/ValueFactory.php | 114 +++++++++------ src/config/dto.php | 21 --- tests/Casters/CasterTest.php | 12 +- tests/Casters/SimpleValueCaster.php | 4 +- tests/Casters/WithGlobalCaster.php | 2 +- tests/Classes/CarbonPeriodMapper.php | 4 +- tests/Flattening/Classes/BasicRoot.php | 12 -- tests/Flattening/Classes/RequiredLeaf.php | 12 -- tests/Flattening/FlatteningTest.php | 18 --- tests/Rules/RulesTest.php | 26 +--- tests/RulesTest.php | 161 ++++++++++++++++++++++ tests/TestCase.php | 14 +- workbench/bootstrap/app.php | 7 - workbench/config/dto.php | 15 ++ workbench/config/logging.php | 16 +++ workbench/phpunit.xml | 17 +++ 27 files changed, 568 insertions(+), 410 deletions(-) create mode 100644 src/DataObjectFactory.php delete mode 100644 src/DataObjectServiceProvider.php create mode 100644 src/InputFactory.php delete mode 100644 src/Log.php delete mode 100644 src/config/dto.php delete mode 100644 tests/Flattening/Classes/BasicRoot.php delete mode 100644 tests/Flattening/Classes/RequiredLeaf.php delete mode 100644 tests/Flattening/FlatteningTest.php create mode 100644 tests/RulesTest.php delete mode 100644 workbench/bootstrap/app.php create mode 100644 workbench/config/dto.php create mode 100644 workbench/config/logging.php create mode 100644 workbench/phpunit.xml diff --git a/composer.json b/composer.json index be192b0..b415c1c 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.94", "orchestra/testbench": "^9.16", - "pestphp/pest-plugin-laravel": "^4.0" + "pestphp/pest-plugin-laravel": "^4.0", + "laravel/pail": "^1.2" }, "license": "GPL-2.0-only", "autoload": { @@ -22,7 +23,10 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "authors": [ @@ -35,5 +39,26 @@ "allow-plugins": { "pestphp/pest-plugin": true } + }, + "scripts": { + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve --ansi" + ], + "lint": [ + "@php vendor/bin/phpstan analyse --verbose --ansi" + ], + "test": [ + "@clear", + "@php vendor/bin/pest" + ] } } diff --git a/composer.lock b/composer.lock index 27eafed..d018f04 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "336ed1e898bc39b0e9990becc327415c", + "content-hash": "0ecb4cd71aa6ab475ed1db205f47b632", "packages": [ { "name": "brick/math", diff --git a/flake.nix b/flake.nix index ee80124..49574dd 100644 --- a/flake.nix +++ b/flake.nix @@ -16,10 +16,20 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; + php = ( + pkgs.php.withExtensions ( + { enabled, all }: + enabled + ++ [ + all.pcntl + all.xdebug + ] + ) + ); in { devShells.default = pkgs.mkShell { - packages = with pkgs; [ + packages = [ php php.packages.composer ]; diff --git a/src/Config.php b/src/Config.php index a25d8df..f827157 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,15 +4,60 @@ declare(strict_types=1); namespace Icefox\DTO; +use Icefox\DTO\Support\RuleFactory; +use Illuminate\Support\Collection; +use phpDocumentor\Reflection\PseudoTypes\Generic; + class Config { - public static function getCaster(string $className): ?callable + /** + * @param class-string $class + **/ + public static function getCaster(string $class): ?callable { - return config('dto.cast.' . $className, null); + return config('dto.cast.' . $class, null); } - public static function getRules(string $className): ?callable + /** + * @param class-string $class + **/ + public static function getRules(string $class): ?callable { - return config('dto.rules.' . $className, null); + if ($userDefined = config('dto.rules.' . $class, null)) { + return $userDefined; + } + return match ($class) { + Collection::class => 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/DataObject.php b/src/DataObject.php index 1c1fc1e..95a7235 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -4,40 +4,13 @@ declare(strict_types=1); namespace Icefox\DTO; -use Icefox\DTO\Attributes\FromInput; -use Icefox\DTO\Attributes\CastWith; -use Icefox\DTO\Attributes\FromRouteParameter; -use Icefox\DTO\Attributes\OverwriteRules; -use Icefox\DTO\Support\RuleFactory; -use Icefox\DTO\Support\ValueFactory; use Illuminate\Http\Request; -use Illuminate\Support\Facades\App; -use Illuminate\Validation\Rule; -use Illuminate\Validation\ValidationException; -use Illuminate\Validation\Validator; -use ReflectionClass; -use phpDocumentor\Reflection\DocBlock\Tags\Param; trait DataObject { public static function fromRequest(Request $request): mixed { - $reflection = new ReflectionClass(static::class); - $constructor = $reflection->getConstructor(); - - $input = []; - foreach ($constructor->getParameters() as $parameter) { - $parameterName = $parameter->getName(); - - foreach ($parameter->getAttributes(FromRouteParameter::class) as $attr) { - $name = $attr->newInstance()->name; - if ($value = $request->input($name, null)) { - $input[$parameterName] = $value; - continue 2; - } - } - } - return static::fromArray($input); + return DataObjectFactory::fromRequest(static::class, $request); } /** @@ -45,73 +18,6 @@ trait DataObject */ public static function fromArray(array $input): ?static { - $logger = new Log(); - $parameters = ReflectionHelper::getParametersMeta(static::class); - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - 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->inputRaw($input); - - $rules = (new RuleFactory($logger))->make(static::class); - - $validator = static::withValidator($input, $rules); - - if ($validator->fails()) { - $logger->validationErrors($validator->errors()->toArray()); - return static::fails($validator); - } - - $mappedInput = []; - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - if ($castWith = array_first($parameter->reflection->getAttributes(CastWith::class))) { - $value = App::call( - [App::make($castWith->newInstance()->class), 'cast'], - ['value' => $validator->getValue($parameterName)], - ); - $mappedInput[$parameterName] = $value; - continue; - } - - $mappedInput[$parameterName] = ValueFactory::resolveValue( - $validator->getValue($parameterName), - $parameter->tag instanceof Param ? $parameter->tag->getType() : null, - $parameter->reflection, - ); - } - $logger->input($mappedInput); - return App::make(static::class, $mappedInput); - } - - public static function fails(Validator $validator): ?static - { - throw new ValidationException($validator); - } - - /** - * @param array $data - * @param array> $rules - */ - public static function withValidator(array $data, array $rules): Validator - { - return App::makeWith(Validator::class, ['data' => $data, 'rules' => $rules]); + return DataObjectFactory::fromArray(static::class, $input); } } diff --git a/src/DataObjectFactory.php b/src/DataObjectFactory.php new file mode 100644 index 0000000..adbf200 --- /dev/null +++ b/src/DataObjectFactory.php @@ -0,0 +1,81 @@ +route() instanceof Route ? $request->route()->parameters() : []; + return static::fromArray($class, $request->input(), $routeParameters); + } + + /** + * @param class-string $class + * @param array $input + * @param array $routeParameters + */ + public static function fromArray(string $class, array $input, array $routeParameters): ?object + { + $logger = new Log(); + $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->inputRaw($input); + + $rules = (new RuleFactory($logger))->make($class); + + $validator = method_exists($class, 'withValidator') + ? App::call("$class::withValidator", ['data' => $input, 'rules' => $rules]) + : App::makeWith(Validator::class, ['data' => $input, 'rules' => $rules]); + + if ($validator->fails()) { + $logger->validationErrors($validator->errors()->toArray()); + if (method_exists($class, 'fails')) { + return App::call("$class::fails", ['validator' => $validator ]); + } + throw new ValidationException($validator); + } + + return ValueFactory::make($class, $validator->validated()); + } +} diff --git a/src/DataObjectServiceProvider.php b/src/DataObjectServiceProvider.php deleted file mode 100644 index b92cd00..0000000 --- a/src/DataObjectServiceProvider.php +++ /dev/null @@ -1,27 +0,0 @@ -mergeConfigFrom(__DIR__ . '/config/dto.php', 'dto'); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - // - } -} diff --git a/src/Factories/CollectionFactory.php b/src/Factories/CollectionFactory.php index 5696dad..bfd0fe0 100644 --- a/src/Factories/CollectionFactory.php +++ b/src/Factories/CollectionFactory.php @@ -2,24 +2,22 @@ namespace Icefox\DTO\Factories; +use Icefox\DTO\ParameterMeta; use Icefox\DTO\Support\RuleFactory; -use Illuminate\Support\Collection; -use Illuminate\Validation\Rule; -use ReflectionParameter; use phpDocumentor\Reflection\PseudoTypes\Generic; -use phpDocumentor\Reflection\Type; class CollectionFactory { /** - * @return array + * @return array */ - public static function rules(ReflectionParameter $parameter, ?Type $type): array + public static function rules(ParameterMeta $parameter, RuleFactory $factory): array { - if (is_null($type)) { + if (is_null($parameter->tag)) { return []; } + $type = $parameter->tag->getType(); if (!$type instanceof Generic) { return []; } @@ -27,14 +25,14 @@ class CollectionFactory $subtypes = $type->getTypes(); if (count($subtypes) == 0) { - return []; + return ['' => ['array']]; } $subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1]; - return array_merge( + return $factory->mergeRules( ['' => ['array']], - RuleFactory::getRulesFromDocBlock($subtype, '.*'), + $factory->getRulesFromDocBlock($subtype, '.*'), ); } } diff --git a/src/InputFactory.php b/src/InputFactory.php new file mode 100644 index 0000000..7109eee --- /dev/null +++ b/src/InputFactory.php @@ -0,0 +1,41 @@ +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/Log.php b/src/Log.php deleted file mode 100644 index eb75ebf..0000000 --- a/src/Log.php +++ /dev/null @@ -1,67 +0,0 @@ -logger = App::call($raw); - return; - } - if (is_object($raw)) { - $this->logger = $raw; - return; - } - if (is_string($raw) && class_exists($raw)) { - $this->logger = App::make($raw); - return; - } - $this->logger = new NullLogger(); - } - - /** - * @param array> $rules - */ - public function rules(array $rules): void - { - $level = config('dto.log.rules') ?? LogLevel::DEBUG; - $this->logger->log($level, print_r($rules, true)); - } - - /** - * @param array $input - */ - public function input(array $input): void - { - $level = config('dto.log.input') ?? LogLevel::DEBUG; - $this->logger->log($level, print_r($input, true)); - } - - /** - * @param array $input - */ - public function inputRaw(array $input): void - { - $level = config('dto.log.raw_input') ?? LogLevel::DEBUG; - $this->logger->log($level, print_r($input, true)); - } - - /** - * @param array> $errors - */ - public function validationErrors(array $errors): void - { - $level = config('dto.log.validation_errors') ?? LogLevel::INFO; - $this->logger->log($level, print_r($errors, true)); - } -} diff --git a/src/Support/RuleFactory.php b/src/Support/RuleFactory.php index 05e4557..9aa1da5 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -4,15 +4,15 @@ declare(strict_types=1); namespace Icefox\DTO\Support; -use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\OverwriteRules; use Icefox\DTO\Config; -use Icefox\DTO\Log; use Icefox\DTO\ParameterMeta; use Icefox\DTO\ReflectionHelper; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; +use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionNamedType; use ReflectionUnionType; @@ -26,19 +26,19 @@ use phpDocumentor\Reflection\Types\Integer; use phpDocumentor\Reflection\Types\Nullable; use phpDocumentor\Reflection\Types\Object_; -class RuleFactory +final class RuleFactory { /** * @return array> */ - public static function getRulesFromDocBlock( + public function getRulesFromDocBlock( Type $type, string $prefix, ): array { $rules = []; if ($type instanceof Nullable) { $rules[$prefix] = ['nullable']; - $rules = array_merge($rules, self::getRulesFromDocBlock($type->getActualType(), $prefix)); + $type = $type->getActualType(); } else { $rules[$prefix] = ['required']; } @@ -47,7 +47,7 @@ class RuleFactory $rules[$prefix][] = 'array'; $valueType = $type->getValueType(); - $rules = array_merge($rules, self::getRulesFromDocBlock($valueType, $prefix . '.*')); + $rules = $this->mergeRules($rules, $this->getRulesFromDocBlock($valueType, $prefix . '.*')); } if ($type instanceof Boolean) { $rules[$prefix][] = 'boolean'; @@ -55,10 +55,7 @@ class RuleFactory $rules[$prefix][] = 'numeric'; } elseif ($type instanceof Object_) { $paramsSub = ReflectionHelper::getParametersMeta($type->getFqsen()->__toString()); - $rules = array_merge( - $rules, - self::infer($paramsSub, $prefix), - ); + $rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix)); } return $rules; } @@ -67,14 +64,14 @@ class RuleFactory * @param array $parameters * @return array> */ - public static function infer(array $parameters, string $basePrefix): array + public function infer(array $parameters, string $basePrefix): array { $rules = []; foreach ($parameters as $parameter) { $prefix = $basePrefix . (empty($basePrefix) ? '' : '.') . (empty($parameter->reflection->getAttributes(Flat::class)) ? $parameter->reflection->getName() : ''); - foreach (self::buildParameterRule($parameter, $prefix) as $key => $newRules) { + foreach ($this->buildParameterRule($parameter, $prefix) as $key => $newRules) { $rules[$key] = $newRules; } } @@ -84,7 +81,7 @@ class RuleFactory /** * @return array> */ - public static function buildParameterRule(ParameterMeta $parameter, string $prefix): array + public function buildParameterRule(ParameterMeta $parameter, string $prefix): array { $type = $parameter->reflection->getType(); @@ -93,12 +90,14 @@ class RuleFactory } $rules = [$prefix => []]; - if ($parameter->reflection->isOptional()) { - $rules[$prefix][] = 'sometimes'; - } elseif ($type->allowsNull()) { - $rules[$prefix][] = 'nullable'; - } else { - $rules[$prefix][] = 'required'; + if (!empty($prefix)) { + if ($parameter->reflection->isOptional()) { + $rules[$prefix][] = 'sometimes'; + } elseif ($type->allowsNull()) { + $rules[$prefix][] = 'nullable'; + } else { + $rules[$prefix][] = 'required'; + } } if ($type instanceof ReflectionUnionType) { @@ -108,11 +107,13 @@ class RuleFactory if ($type instanceof ReflectionNamedType && $name = $type->getName()) { if ($globalRules = Config::getRules($name)) { - foreach ($globalRules($parameter->reflection, $parameter->tag->getType()) as $scopedPrefix => $values) { + foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { $realPrefix = $prefix . $scopedPrefix; - $rules[$realPrefix] = array_values(array_unique(array_merge($rules[$realPrefix] ?? [], $values))); + $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); } + return $rules; } + if ($name === 'string') { } elseif ($name === 'bool') { $rules[$prefix][] = 'boolean'; @@ -127,38 +128,24 @@ class RuleFactory } } else { $paramsSub = ReflectionHelper::getParametersMeta($type->getName()); - $rules = array_merge( - $rules, - self::infer($paramsSub, $prefix), - ); - } - } - - foreach ($parameter->reflection->getAttributes(CastWith::class) as $attr) { - $mapperClass = $attr->newInstance()->class; - if (method_exists($mapperClass, 'rules')) { - $subRules = App::call("$mapperClass@rules"); - foreach ($subRules as $key => &$value) { - $path = empty($key) ? $prefix : ($prefix . '.' . $key); - $rules[$path] = array_values(array_unique(array_merge($rules[$path] ?? [], $value))); - } + $rules = $this->mergeRules($rules, $this->infer($paramsSub, $prefix)); } } if ($parameter->tag instanceof Param) { - $docblockRules = self::getRulesFromDocBlock( + $docblockRules = $this->getRulesFromDocBlock( $parameter->tag->getType(), $prefix, ); - foreach ($docblockRules as $key => &$values) { - $rules[$key] = array_values(array_unique(array_merge($rules[$key] ?? [], $values))); - } + $rules = $this->mergeRules($rules, $docblockRules); + } + if (empty($rules[$prefix])) { + unset($rules[$prefix]); } - return $rules; } - public function __construct(public Log $log) {} + public function __construct(public LoggerInterface $log) {} /** * @param class-string $class @@ -178,21 +165,21 @@ class RuleFactory $rules = $customRules; } else { $inferredRules = RuleFactory::infer($parameters, ''); - $rules = self::mergeRules($inferredRules, $customRules); + $rules = $this->mergeRules($inferredRules, $customRules); } - $this->log->rules($rules); + $this->log->info('Constructed rules for class ' . $class, $rules); return $rules; } /** - * @param array> $inferredRules - * @param array> $customRules + * @param array> $first + * @param array> $second * @return array> */ - protected function mergeRules(array $inferredRules, array $customRules): array + public function mergeRules(array $first, array $second): array { - $merged = $inferredRules; - foreach ($customRules as $key => $rules) { + $merged = $first; + foreach ($second as $key => $rules) { if (isset($merged[$key])) { $merged[$key] = array_values(array_unique(array_merge($merged[$key], $rules))); } else { @@ -201,4 +188,14 @@ class RuleFactory } return $merged; } + + private static self $_instance; + + public static function instance(?LoggerInterface $log = null): static + { + if (empty(self::$_instance)) { + static::$_instance = new self($log ?? Log::channel(config('dto.logging.channel'))); + } + return static::$_instance; + } } diff --git a/src/Support/ValueFactory.php b/src/Support/ValueFactory.php index 992402e..2781ae2 100644 --- a/src/Support/ValueFactory.php +++ b/src/Support/ValueFactory.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace Icefox\DTO\Support; 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; use phpDocumentor\Reflection\Types\AbstractList; @@ -35,31 +39,37 @@ class ValueFactory return App::makeWith($className, $rawValue); } - public static function resolveTypedValue(mixed $rawValue, Type $type): mixed + public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed { if ($type instanceof Nullable) { $type = $type->getActualType(); } - if ($type instanceof Generic) { - $types = $type->getTypes(); - $innerType = count($types) === 2 ? $types[1] : $types[0]; - $result = []; - foreach ($rawValue as $key => $value) { - $result[$key] = self::resolveTypedValue($value, $innerType); - } - return new ($type->getFqsen()->__toString())($result); - } - if ($type instanceof AbstractList) { $innerType = $type->getValueType(); $result = []; foreach ($rawValue as $key => $value) { - $result[$key] = self::resolveTypedValue($value, $innerType); + $result[$key] = self::resolveAnnotatedValue($innerType, $value); } return $result; } + if ($type instanceof Generic) { + $types = $type->getTypes(); + $innerType = count($types) === 2 ? $types[1] : $types[0]; + if (is_array($rawValue)) { + $innerValues = []; + foreach ($rawValue as $key => $value) { + $innerValues[$key] = self::resolveAnnotatedValue($innerType, $value); + } + return array_key_exists(0, $rawValue) + ? new ($type->getFqsen()->__toString())($innerValues) + : App::makeWith($type->getFqsen()->__toString(), $innerValues); + } + $value = self::resolveAnnotatedValue($innerType, $rawValue); + return new ($type->getFqsen()->__toString())($value); + } + if ($type instanceof Boolean) { return boolval($rawValue); } @@ -73,44 +83,70 @@ class ValueFactory } if ($type instanceof Object_) { - return self::constructObject($type->getFqsen()->__toString(), $rawValue); + return self::make($type->getFqsen()->__toString(), $rawValue); } return $rawValue; } - public static function resolveValue(mixed $rawValue, ?Type $type, ReflectionParameter $reflection): mixed + public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed { - if ($reflection->allowsNull() && is_null($rawValue)) { - return null; - } + return match ($parameter->getName()) { + 'string' => $rawValue, + 'bool' => boolval($rawValue), + 'int' => intval($rawValue), + 'float' => floatval($rawValue), + 'array' => $rawValue, + default => self::make($parameter->getName(), $rawValue), - $castWithAttrs = $reflection->getAttributes(CastWith::class); - if ($withCast = $reflection->getAttributes(CastWith::class)[0] ?? null) { - $caster = $withCast->newInstance()->class; - return App::call("$caster@cast", ['value' => $rawValue]); - } + }; + } - if (!is_null($type)) { - return self::resolveTypedValue($rawValue, $type); - } + public static function make(string $class, array $input): object + { + $parameters = ReflectionHelper::getParametersMeta($class); + $arguments = []; + foreach ($parameters as $parameter) { + $name = $parameter->reflection->getName(); - $reflectedType = $reflection->getType(); - if ($reflectedType instanceof ReflectionNamedType && $name = $reflectedType->getName()) { - if ($caster = Config::getCaster($name)) { - return App::call($caster, ['value' => $rawValue]); + $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input; + + if (is_null($parameterArgs)) { + if ($parameter->reflection->allowsNull()) { + $arguments[$name] = null; + } + continue; } - return match ($name) { - 'string' => $rawValue, - 'bool' => boolval($rawValue), - 'int' => intval($rawValue), - 'float' => floatval($rawValue), - 'array' => $rawValue, - default => self::constructObject($name, $rawValue), - }; - } + if ($caster = $parameter->reflection->getAttributes(CastWith::class)[0] ?? null) { + $caster = $caster->newInstance()->class; + $arguments[$name] = App::call("$caster@cast", ['data' => $parameterArgs]); + continue; + } - return $rawValue; + $parameterClass = $parameter->reflection->getClass()?->getName(); + + $type = $parameter->tag?->getType(); + if (empty($parameterClass) && $type instanceof Object_) { + $parameterClass = $type->getFqsen(); + } + if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) { + $arguments[$name] = App::call($caster, ['data' => $parameterArgs]); + continue; + } + + if ($parameter->tag instanceof Param) { + $arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs); + continue; + } + + if ($parameter->reflection->getType() instanceof ReflectionNamedType) { + $arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs); + continue; + } + + $arguments[$name] = $parameterArgs; + } + return App::makeWith($class, $arguments); } } diff --git a/src/config/dto.php b/src/config/dto.php deleted file mode 100644 index bb8ae6e..0000000 --- a/src/config/dto.php +++ /dev/null @@ -1,21 +0,0 @@ - [], - 'rules' => [ - Collection::class => CollectionFactory::rules(...), - ], - 'log' => [ - 'logger' => NullLogger::class, - 'internal' => LogLevel::WARNING, - 'rules' => LogLevel::DEBUG, - 'input' => LogLevel::DEBUG, - 'raw_input' => LogLevel::DEBUG, - 'validation_errors' => LogLevel::INFO, - ], -]; diff --git a/tests/Casters/CasterTest.php b/tests/Casters/CasterTest.php index c8e6a59..93fc5ce 100644 --- a/tests/Casters/CasterTest.php +++ b/tests/Casters/CasterTest.php @@ -17,8 +17,8 @@ describe('caster priority', function () { }); it('uses CastWith attribute over global config caster', function () { - $globalCaster = function (mixed $value): SimpleValue { - return new SimpleValue($value['value'] * 3); + $globalCaster = function (mixed $data): SimpleValue { + return new SimpleValue($data * 3); }; config(['dto.cast.' . SimpleValue::class => $globalCaster]); @@ -30,16 +30,16 @@ describe('caster priority', function () { }); it('falls back to global config caster when no CastWith attribute', function () { - $globalCaster = function (mixed $value): SimpleValue { - return new SimpleValue($value['value'] * 3); + $globalCaster = function (mixed $data): SimpleValue { + return new SimpleValue($data['value'] * 3); }; config(['dto.cast.' . SimpleValue::class => $globalCaster]); $object = WithGlobalCaster::fromArray([ - 'value' => ['value' => 5], + 'simple' => ['value' => 5], ]); - expect($object->value->value)->toBe(15); // 5 * 3 + expect($object->simple->value)->toBe(15); // 5 * 3 }); it('falls back to default construction when no caster exists', function () { diff --git a/tests/Casters/SimpleValueCaster.php b/tests/Casters/SimpleValueCaster.php index 05319c5..fe8e0aa 100644 --- a/tests/Casters/SimpleValueCaster.php +++ b/tests/Casters/SimpleValueCaster.php @@ -6,9 +6,9 @@ namespace Tests\Casters; class SimpleValueCaster { - public function cast(mixed $value): SimpleValue + public function cast(mixed $data): SimpleValue { - return new SimpleValue($value['value'] * 2); + return new SimpleValue($data['value'] * 2); } public static function rules(): array diff --git a/tests/Casters/WithGlobalCaster.php b/tests/Casters/WithGlobalCaster.php index 98b39ef..d829310 100644 --- a/tests/Casters/WithGlobalCaster.php +++ b/tests/Casters/WithGlobalCaster.php @@ -11,6 +11,6 @@ readonly class WithGlobalCaster use DataObject; public function __construct( - public SimpleValue $value, + public SimpleValue $simple, ) {} } diff --git a/tests/Classes/CarbonPeriodMapper.php b/tests/Classes/CarbonPeriodMapper.php index 6d7d8d4..4edee01 100644 --- a/tests/Classes/CarbonPeriodMapper.php +++ b/tests/Classes/CarbonPeriodMapper.php @@ -7,9 +7,9 @@ use Illuminate\Support\Carbon; class CarbonPeriodMapper { - public function cast(mixed $value): CarbonPeriodImmutable + public function cast(mixed $data): CarbonPeriodImmutable { - return new CarbonPeriodImmutable(Carbon::parse($value['start']), Carbon::parse($value['end'])); + return new CarbonPeriodImmutable(Carbon::parse($data['start']), Carbon::parse($data['end'])); } public static function rules(): array diff --git a/tests/Flattening/Classes/BasicRoot.php b/tests/Flattening/Classes/BasicRoot.php deleted file mode 100644 index 299f858..0000000 --- a/tests/Flattening/Classes/BasicRoot.php +++ /dev/null @@ -1,12 +0,0 @@ -make(BasicRoot::class); - expect($rules)->toMatchArray([ - 'text' => ['required'], - 'value' => ['required', 'numeric'], - ]); - }); -}); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index fca9610..5f7ed9f 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -15,7 +15,7 @@ use Tests\Rules\WithOverwriteRules; describe('rules array shape', function () { it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () { $parameters = ReflectionHelper::getParametersMeta(WithMergedRules::class); - $rules = RuleFactory::infer($parameters, '', ''); + $rules = RuleFactory::infer($parameters, ''); expect($rules)->toBe([ 'value' => ['required', 'numeric'], @@ -24,7 +24,7 @@ describe('rules array shape', function () { it('returns inferred rules shape regardless of OverwriteRules attribute', function () { $parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class); - $rules = RuleFactory::infer($parameters, '', ''); + $rules = RuleFactory::infer($parameters, ''); expect($rules)->toBe([ 'value' => ['required', 'numeric'], @@ -93,30 +93,8 @@ describe('rules overwrite', function () { }); it('does not enforce inferred required rule when overwritten', function () { - $object = WithOverwriteRules::fromArray([]); - - expect($object)->toBeInstanceOf(WithOverwriteRules::class); - }); - - it('does not enforce inferred numeric rule when overwritten', function () { $rules = WithOverwriteRules::rules(); expect($rules)->toHaveKey('value'); expect($rules['value'])->toBe(['numeric', 'max:20']); }); }); - -describe('empty rules overwrite', function () { - it('allows any value when rules are empty with OverwriteRules', function () { - $object = WithEmptyOverwriteRules::fromArray([ - 'value' => 999, - ]); - - expect($object->value)->toBe(999); - }); - - it('allows missing value when rules are empty with OverwriteRules', function () { - $object = WithEmptyOverwriteRules::fromArray([]); - - expect($object)->toBeInstanceOf(WithEmptyOverwriteRules::class); - }); -}); diff --git a/tests/RulesTest.php b/tests/RulesTest.php new file mode 100644 index 0000000..8ffeff5 --- /dev/null +++ b/tests/RulesTest.php @@ -0,0 +1,161 @@ +make(BasicPrimitives::class))->toBe([ + 'text' => ['required'], + 'number' => ['required', 'numeric'], + 'flag' => ['required', 'boolean'], + 'items' => ['nullable', 'array'], + 'floating' => ['sometimes', 'numeric'], + ]); +}); + +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/tests/TestCase.php b/tests/TestCase.php index b18ec3c..ae817b2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,15 +2,11 @@ namespace Tests; -use Icefox\DTO\DataObjectServiceProvider; -use Orchestra\Testbench\TestCase as BaseTestCase; +use Illuminate\Contracts\Config\Repository; +use Monolog\Formatter\JsonFormatter; +use Orchestra\Testbench\Concerns\WithWorkbench; -abstract class TestCase extends BaseTestCase +abstract class TestCase extends \Orchestra\Testbench\TestCase { - protected function getPackageProviders($app) - { - return [ - DataObjectServiceProvider::class, - ]; - } + use WithWorkbench; } diff --git a/workbench/bootstrap/app.php b/workbench/bootstrap/app.php deleted file mode 100644 index f7a5caa..0000000 --- a/workbench/bootstrap/app.php +++ /dev/null @@ -1,7 +0,0 @@ -create(); diff --git a/workbench/config/dto.php b/workbench/config/dto.php new file mode 100644 index 0000000..c89046d --- /dev/null +++ b/workbench/config/dto.php @@ -0,0 +1,15 @@ + [ + 'channel' => 'dto', + 'context' => [ + 'rules' => LogLevel::NOTICE, + 'input' => LogLevel::INFO, + 'casts' => LogLevel::INFO, + 'internals' => LogLevel::DEBUG, + ], + ], +]; diff --git a/workbench/config/logging.php b/workbench/config/logging.php new file mode 100644 index 0000000..f111b97 --- /dev/null +++ b/workbench/config/logging.php @@ -0,0 +1,16 @@ + env('LOG_CHANNEL', 'single'), + 'channels' => [ + 'dto' => [ + 'driver' => 'single', + 'path' => getcwd() . '/logs/dto.log', + 'level' => 'debug', + 'replace_placeholders' => true, + 'formatter' => JsonFormatter::class, + ], + ], +]; diff --git a/workbench/phpunit.xml b/workbench/phpunit.xml new file mode 100644 index 0000000..0788ab5 --- /dev/null +++ b/workbench/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + app + + +