refactor rules out of DataObject

This commit is contained in:
icefox 2026-02-19 15:34:33 -03:00
parent 77d1aebc0a
commit 74f151df07
No known key found for this signature in database
5 changed files with 115 additions and 93 deletions

View file

@ -46,7 +46,7 @@ trait DataObject
public static function fromArray(array $input): ?static public static function fromArray(array $input): ?static
{ {
$logger = new Log(); $logger = new Log();
$parameters = RuleFactory::getParametersMeta(static::class); $parameters = ReflectionHelper::getParametersMeta(static::class);
foreach ($parameters as $parameter) { foreach ($parameters as $parameter) {
$parameterName = $parameter->reflection->getName(); $parameterName = $parameter->reflection->getName();
@ -69,8 +69,7 @@ trait DataObject
} }
$logger->inputRaw($input); $logger->inputRaw($input);
$rules = static::getRules(); $rules = (new RuleFactory($logger))->make(static::class);
$logger->rules($rules);
$validator = static::withValidator($input, $rules); $validator = static::withValidator($input, $rules);
@ -102,52 +101,11 @@ trait DataObject
return App::make(static::class, $mappedInput); return App::make(static::class, $mappedInput);
} }
public static function rules(): array
{
return [];
}
public static function fails(Validator $validator): ?static public static function fails(Validator $validator): ?static
{ {
throw new ValidationException($validator); throw new ValidationException($validator);
} }
/**
* @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

48
src/ReflectionHelper.php Normal file
View file

@ -0,0 +1,48 @@
<?php
namespace Icefox\DTO;
use ReflectionParameter;
use phpDocumentor\Reflection\DocBlock\Tag;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\Types\ContextFactory;
use phpDocumentor\Reflection\DocBlockFactory;
use ReflectionClass;
class ReflectionHelper
{
protected static array $cache = [];
/**
* @param class-string $class
* @return array<ParameterMeta>
*/
public static function getParametersMeta(string $class): array
{
if (array_key_exists($class, self::$cache)) {
return self::$cache[$class];
}
$reflection = new ReflectionClass($class);
$constructor = $reflection->getConstructor();
try {
$docblockParams = (DocBlockFactory::createInstance())->create(
$constructor->getDocComment(),
(new ContextFactory())->createFromReflector($constructor),
)->getTagsByName('param');
} catch (\Exception) {
$docblockParams = [];
}
self::$cache[$class] = array_map(
fn(ReflectionParameter $p) => new ParameterMeta(
$p,
array_find(
$docblockParams,
fn(Tag $tag) => $tag instanceof Param ? $tag->getVariableName() == $p->getName() : false,
),
),
$constructor->getParameters(),
);
return self::$cache[$class];
}
}

View file

@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Icefox\DTO\Support; namespace Icefox\DTO\Support;
use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\Attributes\OverwriteRules;
use Icefox\DTO\Config; use Icefox\DTO\Config;
use Icefox\DTO\Attributes\FromMapper; use Icefox\DTO\Attributes\FromMapper;
use Icefox\DTO\Log;
use Icefox\DTO\ParameterMeta; use Icefox\DTO\ParameterMeta;
use Icefox\DTO\ReflectionHelper;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use ReflectionClass; use ReflectionClass;
@ -29,8 +32,6 @@ use phpDocumentor\Reflection\Types\Object_;
class RuleFactory class RuleFactory
{ {
protected static array $cache = [];
/** /**
* @return array<string, array<string|Rule>> * @return array<string, array<string|Rule>>
*/ */
@ -57,7 +58,7 @@ class RuleFactory
} elseif ($type instanceof Float_ || $type instanceof Integer) { } elseif ($type instanceof Float_ || $type instanceof Integer) {
$rules[$prefix][] = 'numeric'; $rules[$prefix][] = 'numeric';
} elseif ($type instanceof Object_) { } elseif ($type instanceof Object_) {
$paramsSub = self::getParametersMeta($type->getFqsen()->__toString()); $paramsSub = ReflectionHelper::getParametersMeta($type->getFqsen()->__toString());
$rules = array_merge( $rules = array_merge(
$rules, $rules,
self::infer($paramsSub, $prefix . '.'), self::infer($paramsSub, $prefix . '.'),
@ -66,39 +67,6 @@ class RuleFactory
return $rules; return $rules;
} }
/**
* @param class-string $class
* @return array<ParameterMeta>
*/
public static function getParametersMeta(string $class): array
{
if (array_key_exists($class, self::$cache)) {
return self::$cache[$class];
}
$reflection = new ReflectionClass($class);
$constructor = $reflection->getConstructor();
try {
$docblockParams = (DocBlockFactory::createInstance())->create(
$constructor->getDocComment(),
(new ContextFactory())->createFromReflector($constructor),
)->getTagsByName('param');
} catch (\Exception) {
$docblockParams = [];
}
self::$cache[$class] = array_map(
fn(ReflectionParameter $p) => new ParameterMeta(
$p,
array_find(
$docblockParams,
fn(Tag $tag) => $tag instanceof Param ? $tag->getVariableName() == $p->getName() : false,
),
),
$constructor->getParameters(),
);
return self::$cache[$class];
}
/** /**
* @param array<ParameterMeta> $parameters * @param array<ParameterMeta> $parameters
* @return array<string,array<int,string|Rule>> * @return array<string,array<int,string|Rule>>
@ -160,7 +128,7 @@ class RuleFactory
$rules[$root][] = Rule::enum($name); $rules[$root][] = Rule::enum($name);
} }
} else { } else {
$paramsSub = self::getParametersMeta($type->getName()); $paramsSub = ReflectionHelper::getParametersMeta($type->getName());
$rules = array_merge( $rules = array_merge(
$rules, $rules,
self::infer($paramsSub, $root . '.'), self::infer($paramsSub, $root . '.'),
@ -190,4 +158,48 @@ class RuleFactory
return $rules; return $rules;
} }
public function __construct(public Log $log) {}
/**
* @param class-string $class
* @return array<string,array<int, string>>
*/
public function make(string $class): array
{
$parameters = ReflectionHelper::getParametersMeta($class);
$classReflection = new ReflectionClass($class);
$hasRulesMethod = $classReflection->hasMethod('rules');
$customRules = $hasRulesMethod ? App::call("$class::rules", []) : [];
if ($hasRulesMethod && !empty($classReflection->getMethod('rules')->getAttributes(OverwriteRules::class))) {
$rules = $customRules;
} else {
$inferredRules = RuleFactory::infer($parameters, '');
$rules = self::mergeRules($inferredRules, $customRules);
}
$this->log->rules($rules);
return $rules;
}
/**
* @param array<string,array<int, string>> $inferredRules
* @param array<string,array<int, string>> $customRules
* @return array<string,array<int, string>>
*/
protected function mergeRules(array $inferredRules, array $customRules): array
{
$merged = $inferredRules;
foreach ($customRules as $key => $rules) {
if (isset($merged[$key])) {
$merged[$key] = array_values(array_unique(array_merge($merged[$key], $rules)));
} else {
$merged[$key] = $rules;
}
}
return $merged;
}
} }

View file

@ -2,6 +2,8 @@
namespace Tests; namespace Tests;
use Icefox\DTO\Log;
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;
@ -14,7 +16,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 = PrimitiveData::getRules(); $rules = (new RuleFactory(new Log()))->make(PrimitiveData::class);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'string' => ['required'], 'string' => ['required'],
'int' => ['required', 'numeric'], 'int' => ['required', 'numeric'],
@ -40,7 +42,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 = OptionalData::getRules(); $rules = (new RuleFactory(new Log()))->make(OptionalData::class);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'string' => ['sometimes'], 'string' => ['sometimes'],
'int' => ['sometimes', 'numeric'], 'int' => ['sometimes', 'numeric'],
@ -61,7 +63,7 @@ describe('optional data', function () {
describe('nullable data', function () { describe('nullable data', function () {
it('creates nullable rules', function () { it('creates nullable rules', function () {
$rules = OptionalNullableData::getRules(); $rules = (new RuleFactory(new Log()))->make(OptionalNullableData::class);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'string' => ['required'], 'string' => ['required'],
'int' => ['nullable', 'numeric'], 'int' => ['nullable', 'numeric'],
@ -91,7 +93,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 = RecursiveDataObject::getRules(); $rules = (new RuleFactory(new Log()))->make(RecursiveDataObject::class);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'string' => ['required'], 'string' => ['required'],
'extra.string' => ['required'], 'extra.string' => ['required'],
@ -104,7 +106,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 = ArrayDataObject::getRules(); $rules = (new RuleFactory(new Log()))->make(ArrayDataObject::class);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'values' => ['required', 'array'], 'values' => ['required', 'array'],
'values.*' => ['required', 'numeric'], 'values.*' => ['required', 'numeric'],
@ -115,7 +117,7 @@ describe('primitive array', function () {
describe('object array', function () { describe('object array', function () {
it('creates array rules', function () { it('creates array rules', function () {
$rules = CollectionDataObject::getRules(); $rules = (new RuleFactory(new Log()))->make(CollectionDataObject::class);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'values' => ['required', 'array'], 'values' => ['required', 'array'],
'values.*' => ['required'], 'values.*' => ['required'],
@ -131,7 +133,7 @@ describe('can map input names', function () {
it('creates rules with property names', function () { it('creates rules with property names', function () {
$rules = FromInputObject::getRules(); $rules = (new RuleFactory(new Log()))->make(FromInputObject::class);
expect($rules)->toMatchArray([ expect($rules)->toMatchArray([
'text' => ['required' ], 'text' => ['required' ],
'standard' => ['required', 'numeric'], 'standard' => ['required', 'numeric'],

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Tests\Rules; namespace Tests\Rules;
use Icefox\DTO\Log;
use Icefox\DTO\ReflectionHelper;
use Icefox\DTO\Support\RuleFactory; use Icefox\DTO\Support\RuleFactory;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Tests\Rules\WithEmptyOverwriteRules; use Tests\Rules\WithEmptyOverwriteRules;
@ -12,7 +14,7 @@ use Tests\Rules\WithOverwriteRules;
describe('rules array shape', function () { describe('rules array shape', function () {
it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () { it('returns inferred rules shape from RuleFactory::infer (inferred only)', function () {
$parameters = RuleFactory::getParametersMeta(WithMergedRules::class); $parameters = ReflectionHelper::getParametersMeta(WithMergedRules::class);
$rules = RuleFactory::infer($parameters, ''); $rules = RuleFactory::infer($parameters, '');
expect($rules)->toBe([ expect($rules)->toBe([
@ -21,7 +23,7 @@ describe('rules array shape', function () {
}); });
it('returns inferred rules shape regardless of OverwriteRules attribute', function () { it('returns inferred rules shape regardless of OverwriteRules attribute', function () {
$parameters = RuleFactory::getParametersMeta(WithOverwriteRules::class); $parameters = ReflectionHelper::getParametersMeta(WithOverwriteRules::class);
$rules = RuleFactory::infer($parameters, ''); $rules = RuleFactory::infer($parameters, '');
expect($rules)->toBe([ expect($rules)->toBe([
@ -32,7 +34,7 @@ describe('rules array shape', function () {
describe('getRules method', function () { describe('getRules method', function () {
it('returns merged rules from DataObject::getRules()', function () { it('returns merged rules from DataObject::getRules()', function () {
$rules = WithMergedRules::getRules(); $rules = (new RuleFactory(new Log()))->make(WithMergedRules::class);
expect($rules)->toBe([ expect($rules)->toBe([
'value' => ['required', 'numeric', 'max:20'], 'value' => ['required', 'numeric', 'max:20'],
@ -40,7 +42,7 @@ describe('getRules method', function () {
}); });
it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () { it('returns only custom rules from DataObject::getRules() with OverwriteRules', function () {
$rules = WithOverwriteRules::getRules(); $rules = (new RuleFactory(new Log()))->make(WithOverwriteRules::class);
expect($rules)->toBe([ expect($rules)->toBe([
'value' => ['numeric', 'max:20'], 'value' => ['numeric', 'max:20'],
@ -48,7 +50,7 @@ describe('getRules method', function () {
}); });
it('returns empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () { it('returns empty rules from DataObject::getRules() with OverwriteRules and no custom rules', function () {
$rules = WithEmptyOverwriteRules::getRules(); $rules = (new RuleFactory(new Log()))->make(WithEmptyOverwriteRules::class);
expect($rules)->toBe([]); expect($rules)->toBe([]);
}); });