OverwriteRules

This commit is contained in:
icefox 2026-02-18 21:57:12 -03:00
parent 3a26a2e0c2
commit 709201547c
No known key found for this signature in database
8 changed files with 252 additions and 19 deletions

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Icefox\DTO\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
class OverwriteRules
{
}

View file

@ -7,6 +7,7 @@ namespace Icefox\DTO;
use Icefox\DTO\Attributes\FromInput; use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\Attributes\FromRouteParameter; use Icefox\DTO\Attributes\FromRouteParameter;
use Icefox\DTO\Attributes\OverwriteRules;
use Icefox\DTO\Support\RuleFactory; use Icefox\DTO\Support\RuleFactory;
use Icefox\DTO\Support\ValueFactory; use Icefox\DTO\Support\ValueFactory;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -66,7 +67,8 @@ trait DataObject
} }
} }
$rules = RuleFactory::buildRules($parameters, ''); $rules = static::getRules();
$validator = static::withValidator($input, $rules); $validator = static::withValidator($input, $rules);
if ($validator->fails()) { if ($validator->fails()) {
@ -101,6 +103,42 @@ trait DataObject
return []; return [];
} }
/**
* @return array<string,array<int, string|Rule>>
*/
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<string,array<int, string|Rule>> $inferredRules
* @param array<string,array<int, string|Rule>> $customRules
* @return array<string,array<int, string|Rule>>
*/
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<string,mixed> $data * @param array<string,mixed> $data
* @param array<string,array<int, string|Rule>> $rules * @param array<string,array<int, string|Rule>> $rules

View file

@ -60,7 +60,7 @@ class RuleFactory
$paramsSub = self::getParametersMeta($type->getFqsen()->__toString()); $paramsSub = self::getParametersMeta($type->getFqsen()->__toString());
$rules = array_merge( $rules = array_merge(
$rules, $rules,
self::buildRules($paramsSub, $prefix . '.'), self::infer($paramsSub, $prefix . '.'),
); );
} }
return $rules; return $rules;
@ -103,7 +103,7 @@ class RuleFactory
* @param array<ParameterMeta> $parameters * @param array<ParameterMeta> $parameters
* @return array<string,array<int,string|Rule>> * @return array<string,array<int,string|Rule>>
*/ */
public static function buildRules(array $parameters, string $prefix): array public static function infer(array $parameters, string $prefix): array
{ {
$rules = []; $rules = [];
foreach ($parameters as $parameter) { foreach ($parameters as $parameter) {
@ -163,7 +163,7 @@ class RuleFactory
$paramsSub = self::getParametersMeta($type->getName()); $paramsSub = self::getParametersMeta($type->getName());
$rules = array_merge( $rules = array_merge(
$rules, $rules,
self::buildRules($paramsSub, $root . '.'), self::infer($paramsSub, $root . '.'),
); );
} }
} }

View file

@ -2,12 +2,10 @@
namespace Tests; namespace Tests;
use Icefox\DTO\Support\RuleFactory;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Tests\Classes\ArrayDataObject; use Tests\Classes\ArrayDataObject;
use Tests\Classes\CollectionDataObject; use Tests\Classes\CollectionDataObject;
use Tests\Classes\FromInputObject; use Tests\Classes\FromInputObject;
use Tests\Classes\ObjectWithoutMapper;
use Tests\Classes\OptionalData; use Tests\Classes\OptionalData;
use Tests\Classes\OptionalNullableData; use Tests\Classes\OptionalNullableData;
use Tests\Classes\PrimitiveData; use Tests\Classes\PrimitiveData;
@ -16,7 +14,7 @@ use Tests\Classes\WithMapperObject;
describe('primitive data test', function () { describe('primitive data test', function () {
it('creates required rules', function () { it('creates required rules', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(PrimitiveData::class), ''); $rules = PrimitiveData::getRules();
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'string' => ['required'], 'string' => ['required'],
'int' => ['required', 'numeric'], 'int' => ['required', 'numeric'],
@ -42,7 +40,7 @@ describe('primitive data test', function () {
describe('optional data', function () { describe('optional data', function () {
it('creates optional rules', function () { it('creates optional rules', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalData::class), ''); $rules = OptionalData::getRules();
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'string' => ['sometimes'], 'string' => ['sometimes'],
'int' => ['sometimes', 'numeric'], 'int' => ['sometimes', 'numeric'],
@ -63,7 +61,7 @@ describe('optional data', function () {
describe('nullable data', function () { describe('nullable data', function () {
it('creates nullable rules', function () { it('creates nullable rules', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalNullableData::class), ''); $rules = OptionalNullableData::getRules();
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'string' => ['required'], 'string' => ['required'],
'int' => ['nullable', 'numeric'], 'int' => ['nullable', 'numeric'],
@ -93,10 +91,7 @@ describe('nullable data', function () {
describe('reference other DataObject', function () { describe('reference other DataObject', function () {
it('creates recursive rules', function () { it('creates recursive rules', function () {
$rules = RuleFactory::buildRules( $rules = RecursiveDataObject::getRules();
RuleFactory::getParametersMeta(RecursiveDataObject::class),
'',
);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'string' => ['required'], 'string' => ['required'],
'extra.string' => ['required'], 'extra.string' => ['required'],
@ -109,7 +104,7 @@ describe('reference other DataObject', function () {
describe('primitive array', function () { describe('primitive array', function () {
it('creates array rules', function () { it('creates array rules', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(ArrayDataObject::class), ''); $rules = ArrayDataObject::getRules();
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'values' => ['required', 'array'], 'values' => ['required', 'array'],
'values.*' => ['required', 'numeric'], 'values.*' => ['required', 'numeric'],
@ -120,10 +115,7 @@ describe('primitive array', function () {
describe('object array', function () { describe('object array', function () {
it('creates array rules', function () { it('creates array rules', function () {
$rules = RuleFactory::buildRules( $rules = CollectionDataObject::getRules();
RuleFactory::getParametersMeta(CollectionDataObject::class),
'',
);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'values' => ['required', 'array'], 'values' => ['required', 'array'],
'values.*' => ['required'], 'values.*' => ['required'],
@ -139,7 +131,7 @@ describe('can map input names', function () {
it('creates rules with property names', function () { it('creates rules with property names', function () {
$rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(FromInputObject::class), ''); $rules = FromInputObject::getRules();
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'text' => ['required' ], 'text' => ['required' ],
'standard' => ['required', 'numeric'], 'standard' => ['required', 'numeric'],

120
tests/Rules/RulesTest.php Normal file
View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Tests\Rules;
use Icefox\DTO\Support\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 = RuleFactory::getParametersMeta(WithMergedRules::class);
$rules = RuleFactory::infer($parameters, '');
expect($rules)->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);
});
});

View file

@ -0,0 +1,23 @@
<?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

@ -0,0 +1,23 @@
<?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

@ -0,0 +1,25 @@
<?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'],
];
}
}