From bba10b455f9d11ee6d68b886c1e58babc12ec541 Mon Sep 17 00:00:00 2001 From: icefox Date: Wed, 25 Feb 2026 12:29:47 -0300 Subject: [PATCH] tests --- .../{OverwriteRules.php => Overwrite.php} | 4 +- src/DataObjectFactory.php | 76 +++--- src/RuleFactory.php | 18 +- tests/DataObjectTest.php | 234 +++------------- tests/Rules/RulesTest.php | 100 ------- tests/Rules/WithEmptyOverwriteRules.php | 23 -- tests/Rules/WithMergedRules.php | 23 -- tests/Rules/WithOverwriteRules.php | 25 -- tests/RulesTest.php | 52 ++++ tests/ValuesTest.php | 255 +++++++++++------- 10 files changed, 296 insertions(+), 514 deletions(-) rename src/Attributes/{OverwriteRules.php => Overwrite.php} (82%) delete mode 100644 tests/Rules/RulesTest.php delete mode 100644 tests/Rules/WithEmptyOverwriteRules.php delete mode 100644 tests/Rules/WithMergedRules.php delete mode 100644 tests/Rules/WithOverwriteRules.php diff --git a/src/Attributes/OverwriteRules.php b/src/Attributes/Overwrite.php similarity index 82% rename from src/Attributes/OverwriteRules.php rename to src/Attributes/Overwrite.php index e1cee40..63a6453 100644 --- a/src/Attributes/OverwriteRules.php +++ b/src/Attributes/Overwrite.php @@ -7,6 +7,4 @@ namespace Icefox\DTO\Attributes; use Attribute; #[Attribute(Attribute::TARGET_METHOD)] -class OverwriteRules -{ -} +class Overwrite {} diff --git a/src/DataObjectFactory.php b/src/DataObjectFactory.php index 78ece59..3a14c7e 100644 --- a/src/DataObjectFactory.php +++ b/src/DataObjectFactory.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; +use Psr\Log\LoggerInterface; class DataObjectFactory { @@ -26,41 +27,13 @@ class DataObjectFactory /** * @param class-string $class - * @param array $input + * @param array $rawInput * @param array $routeParameters */ - public static function fromArray(string $class, array $input, array $routeParameters): ?object + public static function fromArray(string $class, array $rawInput, array $routeParameters): ?object { $logger = Log::channel('dto'); - $parameters = ReflectionHelper::getParametersMeta($class); - foreach ($parameters as $parameter) { - $parameterName = $parameter->reflection->getName(); - - foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $fromRouteParameter) { - if ($value = $routeParameters[$fromRouteParameter->newInstance()->name] ?? null) { - $input[$parameterName] = $value; - continue 2; - } - } - - foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { - if ($value = $input[$attr->newInstance()->name] ?? null) { - $input[$parameterName] = $value; - continue 2; - } - } - - if ($value = $input[$parameterName] ?? null) { - $input[$parameterName] = $value; - continue; - } - - // if ($parameter->reflection->isDefaultValueAvailable()) { - // $input[$parameterName] = $parameter->reflection->getDefaultValue(); - // continue; - // } - } - $logger->debug('input', $input); + $input = self::mapInput($class, $rawInput, $routeParameters, $logger); $rules = (new RuleFactory($logger))->make($class); @@ -78,4 +51,45 @@ class DataObjectFactory return ValueFactory::make($class, $validator->validated()); } + + + /** + * @param class-string $class + * @param array $rawInput + * @param array $routeParameters + * @return array + */ + public static function mapInput( + string $class, + array $rawInput, + array $routeParameters, + LoggerInterface $logger, + ): array { + $input = []; + $parameters = ReflectionHelper::getParametersMeta($class); + foreach ($parameters as $parameter) { + $parameterName = $parameter->reflection->getName(); + + foreach ($parameter->reflection->getAttributes(FromRouteParameter::class) as $fromRouteParameter) { + if ($value = $routeParameters[$fromRouteParameter->newInstance()->name] ?? null) { + $input[$parameterName] = $value; + continue 2; + } + } + + foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) { + if ($value = $rawInput[$attr->newInstance()->name] ?? null) { + $input[$parameterName] = $value; + continue 2; + } + } + + if ($value = $rawInput[$parameterName] ?? null) { + $input[$parameterName] = $value; + continue; + } + } + $logger->debug('input', $input); + return $input; + } } diff --git a/src/RuleFactory.php b/src/RuleFactory.php index c7a9e05..7ff6627 100644 --- a/src/RuleFactory.php +++ b/src/RuleFactory.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Icefox\DTO; use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Attributes\OverwriteRules; +use Icefox\DTO\Attributes\Overwrite; use Icefox\DTO\Config; use Icefox\DTO\ParameterMeta; use Icefox\DTO\ReflectionHelper; @@ -30,7 +30,7 @@ use phpDocumentor\Reflection\Types\Object_; final class RuleFactory { /** - * @return array> + * @return array> */ public function getRulesFromDocBlock( Type $type, @@ -63,7 +63,7 @@ final class RuleFactory /** * @param array $parameters - * @return array> + * @return array> */ public function infer(array $parameters, string $basePrefix): array { @@ -80,7 +80,7 @@ final class RuleFactory } /** - * @return array> + * @return array> */ public function buildParameterRule(ParameterMeta $parameter, string $prefix): array { @@ -150,7 +150,7 @@ final class RuleFactory /** * @param class-string $class - * @return array> + * @return array> */ public function make(string $class): array { @@ -162,7 +162,7 @@ final class RuleFactory $customRules = $hasRulesMethod ? App::call("$class::rules", []) : []; - if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::class))) { + if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(Overwrite::class))) { $rules = $customRules; } else { $inferredRules = RuleFactory::infer($parameters, ''); @@ -173,9 +173,9 @@ final class RuleFactory } /** - * @param array> $first - * @param array> $second - * @return array> + * @param array> $first + * @param array> $second + * @return array> */ public function mergeRules(array $first, array $second): array { diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index de463e5..8886ac2 100644 --- a/tests/DataObjectTest.php +++ b/tests/DataObjectTest.php @@ -2,211 +2,43 @@ namespace Tests; -use Icefox\DTO\Log; -use Icefox\DTO\RuleFactory; -use Illuminate\Validation\ValidationException; -use Tests\Classes\ArrayDataObject; -use Tests\Classes\CollectionDataObject; -use Tests\Classes\FromInputObject; -use Tests\Classes\OptionalData; -use Tests\Classes\OptionalNullableData; -use Tests\Classes\PrimitiveData; -use Tests\Classes\RecursiveDataObject; -use Tests\Classes\WithMapperObject; +use Icefox\DTO\Attributes\FromInput; +use Icefox\DTO\DataObjectFactory; +use Illuminate\Support\Collection; +use Psr\Log\NullLogger; -describe('primitive data test', function () { - it('creates required rules', function () { - $rules = (new RuleFactory(new Log()))->make(PrimitiveData::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'int' => ['required', 'numeric'], - 'float' => ['required', 'numeric'], - 'bool' => ['required', 'boolean'], - ]); - }); +readonly class MappedCollectionItem +{ + public function __construct(#[FromInput('id_item')] public int $idItem) {} +} - it('creates object with all required properties', function () { - $object = PrimitiveData::fromArray([ - 'string' => 'abc', - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); - expect($object)->toBeInstanceOf(PrimitiveData::class); - expect($object->string)->toBe('abc'); - expect($object->int)->toBe(0); - expect($object->float)->toEqualWithDelta(3.14, 0.0001); - expect($object->bool)->toBeTrue(); - }); -}); +readonly class MappedCollectionRoot +{ + /** + * @param Collection $items + */ + public function __construct(public string $text, #[FromInput('data')] public Collection $items) {} +} -describe('optional data', function () { - it('creates optional rules', function () { - $rules = (new RuleFactory(new Log()))->make(OptionalData::class); - expect($rules)->toMatchArray([ - 'string' => ['sometimes'], - 'int' => ['sometimes', 'numeric'], - 'float' => ['sometimes', 'numeric'], - 'bool' => ['sometimes', 'boolean'], - ]); - }); +test('using from input', function () { + $mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [ + 'text' => 'abc', + 'data' => [ + [ 'id_item' => 1 ], + [ 'id_item' => 2 ], + [ 'id_item' => 4 ], + [ 'id_item' => 8 ], + ], + ], [], new NullLogger()); + var_dump($mapped); - it('creates object with default values', function () { - $object = OptionalData::fromArray([]); - expect($object)->toBeInstanceOf(OptionalData::class); - expect($object->string)->toBe('xyz'); - expect($object->int)->toBe(3); - expect($object->float)->toEqualWithDelta(0.777, 0.0001); - expect($object->bool)->toBeFalse(); - }); -}); - -describe('nullable data', function () { - it('creates nullable rules', function () { - $rules = (new RuleFactory(new Log()))->make(OptionalNullableData::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'int' => ['nullable', 'numeric'], - 'float' => ['sometimes', 'numeric'], - 'bool' => ['sometimes', 'boolean'], - ]); - }); - - it('accepts explicit null', function () { - $object = OptionalNullableData::fromArray([ - 'string' => 'ijk', - 'int' => null, - ]); - expect($object)->toBeInstanceOf(OptionalNullableData::class); - expect($object->string)->toBe('ijk'); - expect($object->int)->toBeNull(); - }); - - it('accepts implicit null', function () { - $object = OptionalNullableData::fromArray(['string' => 'dfg']); - expect($object)->toBeInstanceOf(OptionalNullableData::class); - expect($object->string)->toBe('dfg'); - expect($object->int)->toBeNull(); - }); -}); - -describe('reference other DataObject', function () { - - it('creates recursive rules', function () { - $rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class); - expect($rules)->toMatchArray([ - 'string' => ['required'], - 'extra.string' => ['required'], - 'extra.int' => ['required', 'numeric'], - 'extra.float' => ['required', 'numeric'], - 'extra.bool' => ['required', 'boolean'], - ]); - }); -}); - -describe('primitive array', function () { - it('creates array rules', function () { - $rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class); - expect($rules)->toMatchArray([ - 'values' => ['required', 'array'], - 'values.*' => ['required', 'numeric'], - ]); - }); -}); - - -describe('object array', function () { - it('creates array rules', function () { - $rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class); - expect($rules)->toMatchArray([ - 'values' => ['required', 'array'], - 'values.*' => ['required'], - 'values.*.string' => ['required'], - 'values.*.int' => ['nullable', 'numeric'], - 'values.*.float' => ['sometimes', 'numeric'], - 'values.*.bool' => ['sometimes', 'boolean'], - ]); - }); -}); - -describe('can map input names', function () { - - it('creates rules with property names', function () { - - $rules = (new RuleFactory(new Log()))->make(FromInputObject::class); - expect($rules)->toMatchArray([ - 'text' => ['required' ], - 'standard' => ['required', 'numeric'], - ]); - }); - - it('maps input name', function () { - $object = FromInputObject::fromArray([ - 'other_name' => 'xyz', - 'standard' => 1, - ]); - expect($object->text)->toBe('xyz'); - expect($object->standard)->toBe(1); - }); - - it('prioritizes the mapped input', function () { - $object = FromInputObject::fromArray([ - 'other_name' => 'xyz', - 'text' => 'abc', - 'standard' => 1, - ]); - expect($object->text)->toBe('xyz'); - expect($object->standard)->toBe(1); - }); -}); - -describe('with mapper object', function () { - it('uses mapper', function () { - $object = WithMapperObject::fromArray([ - 'period' => [ - 'start' => '1980-01-01', - 'end' => '1990-01-01', - ], - 'standard' => 1, - ]); - expect($object->period->startsAt('1980-01-01'))->toBeTrue(); - expect($object->period->endsAt('1990-01-01'))->toBeTrue(); - }); - - it('uses mapper as validator', function () { - $object = WithMapperObject::fromArray([ - 'period' => [ - 'end' => '1990-01-01', - ], - 'standard' => 1, - ]); - })->throws(ValidationException::class); -}); - -test('failed validation throws ValidationException', function () { - $object = PrimitiveData::fromArray([ - 'int' => 0, - 'float' => 3.14, - 'bool' => true, - ]); -})->throws(ValidationException::class); - - -test('creates collection', function () { - $object = CollectionDataObject::fromArray([ - 'values' => [ - [ - 'string' => 'x', - 'int' => 1, - 'float' => 3.3, - ], - [ - 'string' => 'y', - 'int' => null, - ], + expect($mapped)->toBe([ + 'text' => 'abc', + 'items' => [ + [ 'idItem' => 1 ], + [ 'idItem' => 2 ], + [ 'idItem' => 4 ], + [ 'idItem' => 8 ], ], ]); - expect($object->values->count())->toBe(2); - expect($object->values[0]->string)->toBe('x'); - expect($object->values[1]->int)->toBeNull(); }); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php deleted file mode 100644 index c2a2da2..0000000 --- a/tests/Rules/RulesTest.php +++ /dev/null @@ -1,100 +0,0 @@ -toBe([ - 'value' => ['required', 'numeric'], - ]); - }); - - it('returns inferred rules shape regardless of OverwriteRules attribute', function () { - $parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class); - $rules = RuleFactory::infer($parameters, ''); - - expect($rules)->toBe([ - 'value' => ['required', 'numeric'], - ]); - }); -}); - -describe('getRules method', function () { - it('returns merged rules from DataObject::getRules()', function () { - $rules = (new RuleFactory(new Log()))->make(WithMergedRules::class); - - expect($rules)->toBe([ - 'value' => ['required', 'numeric', 'max:20'], - ]); - }); - - it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () { - $rules = (new RuleFactory(new Log()))->make(WithOverwriteRules::class); - - expect($rules)->toBe([ - 'value' => ['numeric', 'max:20'], - ]); - }); - - it('returns empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () { - $rules = (new RuleFactory(new Log()))->make(WithEmptyOverwriteRules::class); - - expect($rules)->toBe([]); - }); -}); - -describe('rules merging', function () { - it('merges custom rules with inferred rules by default', function () { - $object = WithMergedRules::fromArray([ - 'value' => 10, - ]); - - expect($object->value)->toBe(10); - }); - - it('fails validation when merged rule is violated', function () { - expect(fn() => WithMergedRules::fromArray([ - 'value' => 25, - ]))->toThrow(ValidationException::class); - }); - - it('fails validation when required rule is violated (inferred)', function () { - expect(fn() => WithMergedRules::fromArray([ - ]))->toThrow(ValidationException::class); - }); -}); - -describe('rules overwrite', function () { - it('uses only custom rules when OverwriteRules attribute is present', function () { - $object = WithOverwriteRules::fromArray([ - 'value' => 10, - ]); - - expect($object->value)->toBe(10); - }); - - it('fails validation when custom rule is violated', function () { - expect(fn() => WithOverwriteRules::fromArray([ - 'value' => 25, - ]))->toThrow(ValidationException::class); - }); - - it('does not enforce inferred required rule when overwritten', function () { - $rules = WithOverwriteRules::rules(); - expect($rules)->toHaveKey('value'); - expect($rules['value'])->toBe(['numeric', 'max:20']); - }); -}); diff --git a/tests/Rules/WithEmptyOverwriteRules.php b/tests/Rules/WithEmptyOverwriteRules.php deleted file mode 100644 index e743bed..0000000 --- a/tests/Rules/WithEmptyOverwriteRules.php +++ /dev/null @@ -1,23 +0,0 @@ - ['max:20'], - ]; - } -} diff --git a/tests/Rules/WithOverwriteRules.php b/tests/Rules/WithOverwriteRules.php deleted file mode 100644 index 21b2145..0000000 --- a/tests/Rules/WithOverwriteRules.php +++ /dev/null @@ -1,25 +0,0 @@ - ['numeric', 'max:20'], - ]; - } -} diff --git a/tests/RulesTest.php b/tests/RulesTest.php index 8e06401..34c48cf 100644 --- a/tests/RulesTest.php +++ b/tests/RulesTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests; use Icefox\DTO\Attributes\Flat; +use Icefox\DTO\Attributes\Overwrite; use Icefox\DTO\RuleFactory; use Illuminate\Support\Collection; @@ -157,3 +158,54 @@ test('annotated collection', function () { 'group.*.value' => ['required', 'numeric'], ]); }); + + +readonly class MergedRules +{ + public function __construct(public int $value, public string $text) {} + + /** + * @return array> + */ + public static function rules(): array + { + // only customized fields need to be provided + return [ + 'value' => ['min:20'], + ]; + } +} + +test('merging rules', function () { + expect(RuleFactory::instance()->make(MergedRules::class))->toBe([ + 'value' => ['required', 'numeric', 'min:20'], + 'text' => ['required'], + ]); +}); + +readonly class OverwriteRules +{ + // union types are not supported, generated rules are undefined + public function __construct(public int|bool $value, public string $text) {} + + /** + * @return array> + */ + #[Overwrite] + public static function rules(): array + { + // when overwriting, all fields must be provided with all rules, disables rules inference. + return [ + 'value' => ['required', function ($attribute, $value, $fail) { + if (!is_int($value) && !is_bool($value)) { + $fail("$attribute must be an integer or an array."); + } + }], + 'text' => ['required'], + ]; + } +} + +test('overwriting rules', function () { + expect(RuleFactory::instance()->make(OverwriteRules::class))->toHaveKeys(['value', 'text']); +}); diff --git a/tests/ValuesTest.php b/tests/ValuesTest.php index 05d2ac8..381900d 100644 --- a/tests/ValuesTest.php +++ b/tests/ValuesTest.php @@ -4,9 +4,14 @@ declare(strict_types=1); namespace Tests; +use Carbon\CarbonPeriod; +use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\Flat; +use Icefox\DTO\Attributes\FromInput; +use Icefox\DTO\Attributes\Overwrite; use Icefox\DTO\RuleFactory; use Icefox\DTO\ValueFactory; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; readonly class BasicPrimitives @@ -16,150 +21,202 @@ readonly class BasicPrimitives public int $number, public bool $flag, public ?array $items, - public float $floating = 0.0, + public float $floating = 4.7, ) {} } -test('required rules', function () { +test('basic creation works', function () { $object = ValueFactory::make(BasicPrimitives::class, [ 'text' => 'abc', 'number' => 42, - 'flag' => false, - 'items' => ['a', 2, true], - 'floating' => 4.2, + 'flag' => true, + 'items' => ['a', 2, false], + 'floating' => 32.6, ]); + expect($object->text)->toBe('abc'); expect($object->number)->toBe(42); - expect($object->flag)->toBe(false); - expect($object->items)->toBe(['a', 2, true]); - expect($object->floating)->toEqualWithDelta(4.2, 0.000001); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(32.6); }); -readonly class AnnotatedArray -{ - /** - * @param array $items - */ - public function __construct(public array $items) {} -} -test('annotated array', function () { - expect(RuleFactory::instance()->make(AnnotatedArray::class))->toBe([ - 'items' => ['required', 'array'], - 'items.*' => ['required', 'numeric'], +test('uses default values', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => ['a', 2, false], ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(4.7); }); -readonly class AnnotatedArrayNullableValue -{ - /** - * @param array $items - */ - public function __construct(public array $items) {} -} -test('annotated array with nullable items', function () { - expect(RuleFactory::instance()->make(AnnotatedArrayNullableValue::class))->toBe([ - 'items' => ['required', 'array'], - 'items.*' => ['nullable', 'numeric'], +test('uses default when null and not nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => ['a', 2, false], + 'floating' => null, ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(['a', 2, false]); + expect($object->floating)->toBe(4.7); }); -readonly class PlainLeaf -{ - public function __construct(public string $name) {} -} -readonly class PlainRoot -{ - public function __construct(public int $value, public PlainLeaf $leaf) {} -} - -test('plain nesting', function () { - expect(RuleFactory::instance()->make(PlainRoot::class))->toBe([ - 'value' => ['required', 'numeric'], - 'leaf' => ['required'], - 'leaf.name' => ['required'], +test('accepts null when nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + 'items' => null, ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(null); + expect($object->floating)->toBe(4.7); +}); + +test('accepts missing as null when nullable', function () { + $object = ValueFactory::make(BasicPrimitives::class, [ + 'text' => 'abc', + 'number' => 42, + 'flag' => true, + ]); + + expect($object->text)->toBe('abc'); + expect($object->number)->toBe(42); + expect($object->flag)->toBe(true); + expect($object->items)->toBe(null); + expect($object->floating)->toBe(4.7); +}); + +readonly class NestedLeaf +{ + public function __construct(public bool $flag) {} +} + +readonly class RootWithNestedLeaf +{ + public function __construct(public int $value, public NestedLeaf $leaf) {} +} + +test('creates nested object', function () { + $root = ValueFactory::make(RootWithNestedLeaf::class, [ + 'value' => 42, + 'leaf' => [ + 'flag' => true, + ], + ]); + + expect($root->value)->toBe(42); + expect($root->leaf->flag)->toBe(true); }); -readonly class AnnotatedArrayItem +readonly class CollectionItem { public function __construct(public int $value) {} } -readonly class AnnotatedArrayObject +readonly class CollectionRoot { /** - * @param ?array $items + * @param Collection $items */ - public function __construct(public ?array $items) {} + public function __construct(public string $text, public Collection $items) {} } -test('annotated array with object', function () { - expect(RuleFactory::instance()->make(AnnotatedArrayObject::class))->toBe([ - 'items' => ['nullable', 'array'], - 'items.*' => ['required'], - 'items.*.value' => ['required', 'numeric'], +test('creates collection object', function () { + $root = ValueFactory::make(CollectionRoot::class, [ + 'text' => 'abc', + 'items' => [ + [ 'value' => 1 ], + [ 'value' => 2 ], + [ 'value' => 4 ], + [ 'value' => 8 ], + ], ]); + + expect($root->text)->toBe('abc'); + expect($root->items)->toBeInstanceOf(Collection::class); + expect($root->items->count())->toBe(4); + expect($root->items[0]->value)->toBe(1); + expect($root->items[1]->value)->toBe(2); + expect($root->items[2]->value)->toBe(4); + expect($root->items[3]->value)->toBe(8); }); -readonly class FlattenedLeaf +readonly class DoubleCast { - public function __construct(public ?bool $flag) {} + public static function cast(int $data): int + { + return $data * 2; + } } -readonly class NotFlattenedLeaf -{ - public function __construct(public string $description) {} -} - -readonly class FlattenedNode -{ - public function __construct( - public string $id, - public NotFlattenedLeaf $leaf, - #[Flat] - public FlattenedLeaf $squish, - public int $level = 1, - ) {} -} - -readonly class FlattenedRoot +readonly class WithExplicitCast { public function __construct( + #[CastWith(DoubleCast::class)] public int $value, - #[Flat] - public FlattenedNode $node, ) {} } -test('flattened basic', function () { - expect(RuleFactory::instance()->make(FlattenedRoot::class))->toBe([ - 'value' => ['required', 'numeric'], - 'id' => ['required' ], - 'leaf' => ['required'], - 'leaf.description' => ['required'], - 'flag' => ['nullable', 'boolean'], - 'level' => ['sometimes', 'numeric'], - ]); -}); - -readonly class AnnotatedCollectionItem -{ - public function __construct(public int $value) {} -} - -readonly class AnnotatedCollection +readonly class WithNestedCast { /** - * @param Collection $group + * @param array $items */ - public function __construct(public Collection $group) {} + public function __construct(public array $items) {} } -test('annotated collection', function () { - expect(RuleFactory::instance()->make(AnnotatedCollection::class))->toBe([ - 'group' => ['required', 'array'], - 'group.*' => ['required'], - 'group.*.value' => ['required', 'numeric'], - ]); +test('with explicit cast', function () { + $object = ValueFactory::make(WithExplicitCast::class, ['value' => 32]); + expect($object->value)->toBe(64); +}); + +test('with nested cast', function () { + $object = ValueFactory::make(WithNestedCast::class, ['items' => [ ['value' => 2], ['value' => 3], ['value' => 5]]]); + + expect($object->items[0]->value)->toBe(4); + expect($object->items[1]->value)->toBe(6); + expect($object->items[2]->value)->toBe(10); +}); + +readonly class CarbonPeriodCast +{ + /** + * @param array $data + */ + public static function cast(array $data): CarbonPeriod + { + return new CarbonPeriod(Carbon::parse($data['start']), Carbon::parse($data['end'])); + } +} + +readonly class WithObjectCast +{ + public function __construct( + #[CastWith(CarbonPeriodCast::class)] + public CarbonPeriod $period, + ) {} +} + +test('with object cast', function () { + $object = ValueFactory::make(WithObjectCast::class, ['period' => ['start' => '1980-10-01', 'end' => '1990-06-01']]); + + expect($object->period)->toBeInstanceOf(CarbonPeriod::class); + expect($object->period->start->format('Y-m-d'))->toBe('1980-10-01'); + expect($object->period->end->format('Y-m-d'))->toBe('1990-06-01'); });