diff --git a/src/Attributes/OverwriteRules.php b/src/Attributes/OverwriteRules.php new file mode 100644 index 0000000..e1cee40 --- /dev/null +++ b/src/Attributes/OverwriteRules.php @@ -0,0 +1,12 @@ +fails()) { @@ -101,6 +103,42 @@ trait DataObject return []; } + /** + * @return array> + */ + 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> $inferredRules + * @param array> $customRules + * @return array> + */ + 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 $data * @param array> $rules diff --git a/src/Support/RuleFactory.php b/src/Support/RuleFactory.php index a3572b6..db07382 100644 --- a/src/Support/RuleFactory.php +++ b/src/Support/RuleFactory.php @@ -60,7 +60,7 @@ class RuleFactory $paramsSub = self::getParametersMeta($type->getFqsen()->__toString()); $rules = array_merge( $rules, - self::buildRules($paramsSub, $prefix . '.'), + self::infer($paramsSub, $prefix . '.'), ); } return $rules; @@ -103,7 +103,7 @@ class RuleFactory * @param array $parameters * @return array> */ - public static function buildRules(array $parameters, string $prefix): array + public static function infer(array $parameters, string $prefix): array { $rules = []; foreach ($parameters as $parameter) { @@ -163,7 +163,7 @@ class RuleFactory $paramsSub = self::getParametersMeta($type->getName()); $rules = array_merge( $rules, - self::buildRules($paramsSub, $root . '.'), + self::infer($paramsSub, $root . '.'), ); } } diff --git a/tests/DataObjectTest.php b/tests/DataObjectTest.php index 1ed2ce4..4177c8b 100644 --- a/tests/DataObjectTest.php +++ b/tests/DataObjectTest.php @@ -2,12 +2,10 @@ namespace Tests; -use Icefox\DTO\Support\RuleFactory; use Illuminate\Validation\ValidationException; use Tests\Classes\ArrayDataObject; use Tests\Classes\CollectionDataObject; use Tests\Classes\FromInputObject; -use Tests\Classes\ObjectWithoutMapper; use Tests\Classes\OptionalData; use Tests\Classes\OptionalNullableData; use Tests\Classes\PrimitiveData; @@ -16,7 +14,7 @@ use Tests\Classes\WithMapperObject; describe('primitive data test', function () { it('creates required rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(PrimitiveData::class), ''); + $rules = PrimitiveData::getRules(); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['required', 'numeric'], @@ -42,7 +40,7 @@ describe('primitive data test', function () { describe('optional data', function () { it('creates optional rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalData::class), ''); + $rules = OptionalData::getRules(); expect($rules)->toMatchArray([ 'string' => ['sometimes'], 'int' => ['sometimes', 'numeric'], @@ -63,7 +61,7 @@ describe('optional data', function () { describe('nullable data', function () { it('creates nullable rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(OptionalNullableData::class), ''); + $rules = OptionalNullableData::getRules(); expect($rules)->toMatchArray([ 'string' => ['required'], 'int' => ['nullable', 'numeric'], @@ -93,10 +91,7 @@ describe('nullable data', function () { describe('reference other DataObject', function () { it('creates recursive rules', function () { - $rules = RuleFactory::buildRules( - RuleFactory::getParametersMeta(RecursiveDataObject::class), - '', - ); + $rules = RecursiveDataObject::getRules(); expect($rules)->toMatchArray([ 'string' => ['required'], 'extra.string' => ['required'], @@ -109,7 +104,7 @@ describe('reference other DataObject', function () { describe('primitive array', function () { it('creates array rules', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(ArrayDataObject::class), ''); + $rules = ArrayDataObject::getRules(); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required', 'numeric'], @@ -120,10 +115,7 @@ describe('primitive array', function () { describe('object array', function () { it('creates array rules', function () { - $rules = RuleFactory::buildRules( - RuleFactory::getParametersMeta(CollectionDataObject::class), - '', - ); + $rules = CollectionDataObject::getRules(); expect($rules)->toMatchArray([ 'values' => ['required', 'array'], 'values.*' => ['required'], @@ -139,7 +131,7 @@ describe('can map input names', function () { it('creates rules with property names', function () { - $rules = RuleFactory::buildRules(RuleFactory::getParametersMeta(FromInputObject::class), ''); + $rules = FromInputObject::getRules(); expect($rules)->toMatchArray([ 'text' => ['required' ], 'standard' => ['required', 'numeric'], diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php new file mode 100644 index 0000000..19ad404 --- /dev/null +++ b/tests/Rules/RulesTest.php @@ -0,0 +1,120 @@ +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); + }); +}); diff --git a/tests/Rules/WithEmptyOverwriteRules.php b/tests/Rules/WithEmptyOverwriteRules.php new file mode 100644 index 0000000..e743bed --- /dev/null +++ b/tests/Rules/WithEmptyOverwriteRules.php @@ -0,0 +1,23 @@ + ['max:20'], + ]; + } +} diff --git a/tests/Rules/WithOverwriteRules.php b/tests/Rules/WithOverwriteRules.php new file mode 100644 index 0000000..21b2145 --- /dev/null +++ b/tests/Rules/WithOverwriteRules.php @@ -0,0 +1,25 @@ + ['numeric', 'max:20'], + ]; + } +}