This commit is contained in:
icefox 2026-02-25 12:29:47 -03:00
parent 6b1a385292
commit bba10b455f
No known key found for this signature in database
10 changed files with 296 additions and 514 deletions

View file

@ -7,6 +7,4 @@ namespace Icefox\DTO\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class OverwriteRules
{
}
class Overwrite {}

View file

@ -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<string,mixed> $input
* @param array<string,mixed> $rawInput
* @param array<string,mixed> $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<string,mixed> $rawInput
* @param array<string,mixed> $routeParameters
* @return array<string,mixed>
*/
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;
}
}

View file

@ -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<string, array<string|Rule>>
* @return array<string, array<int, mixed>>
*/
public function getRulesFromDocBlock(
Type $type,
@ -63,7 +63,7 @@ final class RuleFactory
/**
* @param array<ParameterMeta> $parameters
* @return array<string,array<int,string|Rule>>
* @return array<string,array<int,mixed>>
*/
public function infer(array $parameters, string $basePrefix): array
{
@ -80,7 +80,7 @@ final class RuleFactory
}
/**
* @return array<string, array<int, string|Rule>>
* @return array<string, array<int, mixed>>
*/
public function buildParameterRule(ParameterMeta $parameter, string $prefix): array
{
@ -150,7 +150,7 @@ final class RuleFactory
/**
* @param class-string $class
* @return array<string,array<int, string>>
* @return array<string,array<int, mixed>>
*/
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<string,array<int, string>> $first
* @param array<string,array<int, string>> $second
* @return array<string,array<int, string>>
* @param array<string,array<int, mixed>> $first
* @param array<string,array<int, mixed>> $second
* @return array<string,array<int, mixed>>
*/
public function mergeRules(array $first, array $second): array
{

View file

@ -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<int, MappedCollectionItem> $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'],
]);
});
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',
test('using from input', function () {
$mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [
'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',
'data' => [
[ 'id_item' => 1 ],
[ 'id_item' => 2 ],
[ 'id_item' => 4 ],
[ 'id_item' => 8 ],
],
'standard' => 1,
]);
expect($object->period->startsAt('1980-01-01'))->toBeTrue();
expect($object->period->endsAt('1990-01-01'))->toBeTrue();
});
], [], new NullLogger());
var_dump($mapped);
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();
});

View file

@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Rules;
use Icefox\DTO\Log;
use Icefox\DTO\ReflectionHelper;
use Icefox\DTO\RuleFactory;
use Illuminate\Validation\ValidationException;
use Tests\Rules\WithEmptyOverwriteRules;
use Tests\Rules\WithMergedRules;
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, '');
expect($rules)->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']);
});
});

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Rules;
use Icefox\DTO\Attributes\OverwriteRules;
use Icefox\DTO\DataObject;
readonly class WithEmptyOverwriteRules
{
use DataObject;
public function __construct(
public int $value,
) {}
#[OverwriteRules]
public static function rules(): array
{
return [];
}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Rules;
use Icefox\DTO\DataObject;
readonly class WithMergedRules
{
use DataObject;
public function __construct(
public int $value,
) {}
public static function rules(): array
{
return [
'value' => ['max:20'],
];
}
}

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Rules;
use Icefox\DTO\Attributes\OverwriteRules;
use Icefox\DTO\DataObject;
readonly class WithOverwriteRules
{
use DataObject;
public function __construct(
public int $value,
) {}
#[OverwriteRules]
public static function rules(): array
{
return [
'value' => ['numeric', 'max:20'],
];
}
}

View file

@ -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<string,array<int,string>>
*/
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<string,array<int,mixed>>
*/
#[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']);
});

View file

@ -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<int,float> $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<?float> $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<AnnotatedArrayItem> $items
* @param Collection<int, CollectionItem> $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<AnnotatedCollectionItem> $group
* @param array<int,WithExplicitCast> $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<int,mixed> $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');
});