tests
This commit is contained in:
parent
6b1a385292
commit
bba10b455f
10 changed files with 296 additions and 514 deletions
|
|
@ -7,6 +7,4 @@ namespace Icefox\DTO\Attributes;
|
||||||
use Attribute;
|
use Attribute;
|
||||||
|
|
||||||
#[Attribute(Attribute::TARGET_METHOD)]
|
#[Attribute(Attribute::TARGET_METHOD)]
|
||||||
class OverwriteRules
|
class Overwrite {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
@ -12,6 +12,7 @@ use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Illuminate\Validation\Validator;
|
use Illuminate\Validation\Validator;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class DataObjectFactory
|
class DataObjectFactory
|
||||||
{
|
{
|
||||||
|
|
@ -26,41 +27,13 @@ class DataObjectFactory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param class-string $class
|
* @param class-string $class
|
||||||
* @param array<string,mixed> $input
|
* @param array<string,mixed> $rawInput
|
||||||
* @param array<string,mixed> $routeParameters
|
* @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');
|
$logger = Log::channel('dto');
|
||||||
$parameters = ReflectionHelper::getParametersMeta($class);
|
$input = self::mapInput($class, $rawInput, $routeParameters, $logger);
|
||||||
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);
|
|
||||||
|
|
||||||
$rules = (new RuleFactory($logger))->make($class);
|
$rules = (new RuleFactory($logger))->make($class);
|
||||||
|
|
||||||
|
|
@ -78,4 +51,45 @@ class DataObjectFactory
|
||||||
|
|
||||||
return ValueFactory::make($class, $validator->validated());
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Icefox\DTO;
|
namespace Icefox\DTO;
|
||||||
|
|
||||||
use Icefox\DTO\Attributes\Flat;
|
use Icefox\DTO\Attributes\Flat;
|
||||||
use Icefox\DTO\Attributes\OverwriteRules;
|
use Icefox\DTO\Attributes\Overwrite;
|
||||||
use Icefox\DTO\Config;
|
use Icefox\DTO\Config;
|
||||||
use Icefox\DTO\ParameterMeta;
|
use Icefox\DTO\ParameterMeta;
|
||||||
use Icefox\DTO\ReflectionHelper;
|
use Icefox\DTO\ReflectionHelper;
|
||||||
|
|
@ -30,7 +30,7 @@ use phpDocumentor\Reflection\Types\Object_;
|
||||||
final class RuleFactory
|
final class RuleFactory
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<string|Rule>>
|
* @return array<string, array<int, mixed>>
|
||||||
*/
|
*/
|
||||||
public function getRulesFromDocBlock(
|
public function getRulesFromDocBlock(
|
||||||
Type $type,
|
Type $type,
|
||||||
|
|
@ -63,7 +63,7 @@ final class RuleFactory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<ParameterMeta> $parameters
|
* @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
|
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
|
public function buildParameterRule(ParameterMeta $parameter, string $prefix): array
|
||||||
{
|
{
|
||||||
|
|
@ -150,7 +150,7 @@ final class RuleFactory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param class-string $class
|
* @param class-string $class
|
||||||
* @return array<string,array<int, string>>
|
* @return array<string,array<int, mixed>>
|
||||||
*/
|
*/
|
||||||
public function make(string $class): array
|
public function make(string $class): array
|
||||||
{
|
{
|
||||||
|
|
@ -162,7 +162,7 @@ final class RuleFactory
|
||||||
$customRules = $hasRulesMethod ? App::call("$class::rules", []) : [];
|
$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;
|
$rules = $customRules;
|
||||||
} else {
|
} else {
|
||||||
$inferredRules = RuleFactory::infer($parameters, '');
|
$inferredRules = RuleFactory::infer($parameters, '');
|
||||||
|
|
@ -173,9 +173,9 @@ final class RuleFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,array<int, string>> $first
|
* @param array<string,array<int, mixed>> $first
|
||||||
* @param array<string,array<int, string>> $second
|
* @param array<string,array<int, mixed>> $second
|
||||||
* @return array<string,array<int, string>>
|
* @return array<string,array<int, mixed>>
|
||||||
*/
|
*/
|
||||||
public function mergeRules(array $first, array $second): array
|
public function mergeRules(array $first, array $second): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,211 +2,43 @@
|
||||||
|
|
||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Icefox\DTO\Log;
|
use Icefox\DTO\Attributes\FromInput;
|
||||||
use Icefox\DTO\RuleFactory;
|
use Icefox\DTO\DataObjectFactory;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Support\Collection;
|
||||||
use Tests\Classes\ArrayDataObject;
|
use Psr\Log\NullLogger;
|
||||||
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;
|
|
||||||
|
|
||||||
describe('primitive data test', function () {
|
readonly class MappedCollectionItem
|
||||||
it('creates required rules', function () {
|
{
|
||||||
$rules = (new RuleFactory(new Log()))->make(PrimitiveData::class);
|
public function __construct(#[FromInput('id_item')] public int $idItem) {}
|
||||||
expect($rules)->toMatchArray([
|
}
|
||||||
'string' => ['required'],
|
|
||||||
'int' => ['required', 'numeric'],
|
|
||||||
'float' => ['required', 'numeric'],
|
|
||||||
'bool' => ['required', 'boolean'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates object with all required properties', function () {
|
readonly class MappedCollectionRoot
|
||||||
$object = PrimitiveData::fromArray([
|
{
|
||||||
'string' => 'abc',
|
/**
|
||||||
'int' => 0,
|
* @param Collection<int, MappedCollectionItem> $items
|
||||||
'float' => 3.14,
|
*/
|
||||||
'bool' => true,
|
public function __construct(public string $text, #[FromInput('data')] public Collection $items) {}
|
||||||
]);
|
}
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('optional data', function () {
|
test('using from input', function () {
|
||||||
it('creates optional rules', function () {
|
$mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [
|
||||||
$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',
|
|
||||||
'text' => 'abc',
|
'text' => 'abc',
|
||||||
'standard' => 1,
|
'data' => [
|
||||||
]);
|
[ 'id_item' => 1 ],
|
||||||
expect($object->text)->toBe('xyz');
|
[ 'id_item' => 2 ],
|
||||||
expect($object->standard)->toBe(1);
|
[ 'id_item' => 4 ],
|
||||||
});
|
[ 'id_item' => 8 ],
|
||||||
});
|
|
||||||
|
|
||||||
describe('with mapper object', function () {
|
|
||||||
it('uses mapper', function () {
|
|
||||||
$object = WithMapperObject::fromArray([
|
|
||||||
'period' => [
|
|
||||||
'start' => '1980-01-01',
|
|
||||||
'end' => '1990-01-01',
|
|
||||||
],
|
],
|
||||||
'standard' => 1,
|
], [], new NullLogger());
|
||||||
]);
|
var_dump($mapped);
|
||||||
expect($object->period->startsAt('1980-01-01'))->toBeTrue();
|
|
||||||
expect($object->period->endsAt('1990-01-01'))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses mapper as validator', function () {
|
expect($mapped)->toBe([
|
||||||
$object = WithMapperObject::fromArray([
|
'text' => 'abc',
|
||||||
'period' => [
|
'items' => [
|
||||||
'end' => '1990-01-01',
|
[ 'idItem' => 1 ],
|
||||||
],
|
[ 'idItem' => 2 ],
|
||||||
'standard' => 1,
|
[ 'idItem' => 4 ],
|
||||||
]);
|
[ 'idItem' => 8 ],
|
||||||
})->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($object->values->count())->toBe(2);
|
|
||||||
expect($object->values[0]->string)->toBe('x');
|
|
||||||
expect($object->values[1]->int)->toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Icefox\DTO\Attributes\Flat;
|
use Icefox\DTO\Attributes\Flat;
|
||||||
|
use Icefox\DTO\Attributes\Overwrite;
|
||||||
use Icefox\DTO\RuleFactory;
|
use Icefox\DTO\RuleFactory;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
|
@ -157,3 +158,54 @@ test('annotated collection', function () {
|
||||||
'group.*.value' => ['required', 'numeric'],
|
'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']);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,14 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
|
use Icefox\DTO\Attributes\CastWith;
|
||||||
use Icefox\DTO\Attributes\Flat;
|
use Icefox\DTO\Attributes\Flat;
|
||||||
|
use Icefox\DTO\Attributes\FromInput;
|
||||||
|
use Icefox\DTO\Attributes\Overwrite;
|
||||||
use Icefox\DTO\RuleFactory;
|
use Icefox\DTO\RuleFactory;
|
||||||
use Icefox\DTO\ValueFactory;
|
use Icefox\DTO\ValueFactory;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
readonly class BasicPrimitives
|
readonly class BasicPrimitives
|
||||||
|
|
@ -16,150 +21,202 @@ readonly class BasicPrimitives
|
||||||
public int $number,
|
public int $number,
|
||||||
public bool $flag,
|
public bool $flag,
|
||||||
public ?array $items,
|
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, [
|
$object = ValueFactory::make(BasicPrimitives::class, [
|
||||||
'text' => 'abc',
|
'text' => 'abc',
|
||||||
'number' => 42,
|
'number' => 42,
|
||||||
'flag' => false,
|
'flag' => true,
|
||||||
'items' => ['a', 2, true],
|
'items' => ['a', 2, false],
|
||||||
'floating' => 4.2,
|
'floating' => 32.6,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($object->text)->toBe('abc');
|
expect($object->text)->toBe('abc');
|
||||||
expect($object->number)->toBe(42);
|
expect($object->number)->toBe(42);
|
||||||
expect($object->flag)->toBe(false);
|
expect($object->flag)->toBe(true);
|
||||||
expect($object->items)->toBe(['a', 2, true]);
|
expect($object->items)->toBe(['a', 2, false]);
|
||||||
expect($object->floating)->toEqualWithDelta(4.2, 0.000001);
|
expect($object->floating)->toBe(32.6);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly class AnnotatedArray
|
test('uses default values', function () {
|
||||||
{
|
$object = ValueFactory::make(BasicPrimitives::class, [
|
||||||
/**
|
'text' => 'abc',
|
||||||
* @param array<int,float> $items
|
'number' => 42,
|
||||||
*/
|
'flag' => true,
|
||||||
public function __construct(public array $items) {}
|
'items' => ['a', 2, false],
|
||||||
}
|
|
||||||
test('annotated array', function () {
|
|
||||||
expect(RuleFactory::instance()->make(AnnotatedArray::class))->toBe([
|
|
||||||
'items' => ['required', 'array'],
|
|
||||||
'items.*' => ['required', 'numeric'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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
|
test('uses default when null and not nullable', function () {
|
||||||
{
|
$object = ValueFactory::make(BasicPrimitives::class, [
|
||||||
/**
|
'text' => 'abc',
|
||||||
* @param array<?float> $items
|
'number' => 42,
|
||||||
*/
|
'flag' => true,
|
||||||
public function __construct(public array $items) {}
|
'items' => ['a', 2, false],
|
||||||
}
|
'floating' => null,
|
||||||
test('annotated array with nullable items', function () {
|
|
||||||
expect(RuleFactory::instance()->make(AnnotatedArrayNullableValue::class))->toBe([
|
|
||||||
'items' => ['required', 'array'],
|
|
||||||
'items.*' => ['nullable', 'numeric'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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
|
test('accepts null when nullable', function () {
|
||||||
{
|
$object = ValueFactory::make(BasicPrimitives::class, [
|
||||||
public function __construct(public string $name) {}
|
'text' => 'abc',
|
||||||
}
|
'number' => 42,
|
||||||
readonly class PlainRoot
|
'flag' => true,
|
||||||
{
|
'items' => null,
|
||||||
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'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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) {}
|
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 () {
|
test('creates collection object', function () {
|
||||||
expect(RuleFactory::instance()->make(AnnotatedArrayObject::class))->toBe([
|
$root = ValueFactory::make(CollectionRoot::class, [
|
||||||
'items' => ['nullable', 'array'],
|
'text' => 'abc',
|
||||||
'items.*' => ['required'],
|
'items' => [
|
||||||
'items.*.value' => ['required', 'numeric'],
|
[ '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
|
readonly class WithExplicitCast
|
||||||
{
|
|
||||||
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 function __construct(
|
||||||
|
#[CastWith(DoubleCast::class)]
|
||||||
public int $value,
|
public int $value,
|
||||||
#[Flat]
|
|
||||||
public FlattenedNode $node,
|
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('flattened basic', function () {
|
readonly class WithNestedCast
|
||||||
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<AnnotatedCollectionItem> $group
|
* @param array<int,WithExplicitCast> $items
|
||||||
*/
|
*/
|
||||||
public function __construct(public Collection $group) {}
|
public function __construct(public array $items) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('annotated collection', function () {
|
test('with explicit cast', function () {
|
||||||
expect(RuleFactory::instance()->make(AnnotatedCollection::class))->toBe([
|
$object = ValueFactory::make(WithExplicitCast::class, ['value' => 32]);
|
||||||
'group' => ['required', 'array'],
|
expect($object->value)->toBe(64);
|
||||||
'group.*' => ['required'],
|
});
|
||||||
'group.*.value' => ['required', 'numeric'],
|
|
||||||
]);
|
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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue