diff --git a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php index 69d8919f..00113d44 100644 --- a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php +++ b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php @@ -2,24 +2,41 @@ namespace Spatie\LaravelData\Resolvers; +use Illuminate\Support\Arr; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\Validation\ValidationPath; class DataValidationMessagesAndAttributesResolver { public function __construct( - protected DataConfig $dataConfig, + protected DataConfig $dataConfig, + protected DataMorphClassResolver $dataMorphClassResolver, ) { } public function execute( - string $class, - array $fullPayload, + string $class, + array $fullPayload, ValidationPath $path, - array $nestingChain = [], + array $nestingChain = [], ): array { $dataClass = $this->dataConfig->getDataClass($class); + if ($dataClass->isAbstract && $dataClass->propertyMorphable) { + $payload = $path->isRoot() + ? $fullPayload + : Arr::get($fullPayload, $path->get(), []); + + $morphedClass = $this->dataMorphClassResolver->execute( + $dataClass, + [$payload], + ); + + $dataClass = $morphedClass + ? $this->dataConfig->getDataClass($morphedClass) + : $dataClass; + } + $messages = []; $attributes = []; @@ -57,23 +74,52 @@ public function execute( continue; } - $collected = $this->execute( - $dataProperty->type->dataClass, - $fullPayload, - $propertyPath->property('*'), - [...$nestingChain, $dataProperty->type->dataClass], - ); + $collectedDataClass = $this->dataConfig->getDataClass($dataProperty->type->dataClass); - $messages[] = $collected['messages']; - $attributes[] = $collected['attributes']; + if ($collectedDataClass->isAbstract && $collectedDataClass->propertyMorphable) { + $items = Arr::get($fullPayload, $propertyPath->get(), []); + + foreach ($items as $index => $item) { + $morphedClass = $this->dataMorphClassResolver->execute( + $collectedDataClass, + [$item], + ); + + if (! $morphedClass) { + $morphedClass = $dataProperty->type->dataClass; + } + + $collected = $this->execute( + $morphedClass, + $fullPayload, + $propertyPath->property($index), + [...$nestingChain, $dataProperty->type->dataClass, $morphedClass], + ); + + $messages[] = $collected['messages']; + $attributes[] = $collected['attributes']; + } + + } else { + $collected = $this->execute( + $dataProperty->type->dataClass, + $fullPayload, + $propertyPath->property('*'), + [...$nestingChain, $dataProperty->type->dataClass], + ); + + $messages[] = $collected['messages']; + $attributes[] = $collected['attributes']; + + } } } $messages = array_merge(...$messages); $attributes = array_merge(...$attributes); - if (method_exists($class, 'messages')) { - $messages = collect(app()->call([$class, 'messages'])) + if (method_exists($dataClass->name, 'messages')) { + $messages = collect(app()->call([$dataClass->name, 'messages'])) ->keyBy( fn (mixed $messages, string $key) => ! str_contains($key, '.') && is_string($messages) ? $path->property("*.{$key}")->get() @@ -83,8 +129,8 @@ public function execute( ->all(); } - if (method_exists($class, 'attributes')) { - $attributes = collect(app()->call([$class, 'attributes'])) + if (method_exists($dataClass->name, 'attributes')) { + $attributes = collect(app()->call([$dataClass->name, 'attributes'])) ->keyBy(fn (mixed $messages, string $key) => $path->property($key)->get()) ->merge($attributes) ->all(); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 0237aca6..5d241a7d 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -2549,11 +2549,11 @@ class TestDataWithMergedRuleset extends Data { public function __construct( #[Max(10)] - public string $array_rules, + public string $array_rules, #[Max(10)] - public string $string_rules, + public string $string_rules, #[WithoutValidation] - public string $without_validation, + public string $without_validation, public TestNestedDataWithMergedRules $nested ) { } @@ -2613,7 +2613,7 @@ enum TestValidationPropertyMorphableEnum: string { case A = 'a'; case B = 'b'; - }; + } abstract class TestValidationAbstractPropertyMorphableData extends Data implements PropertyMorphableData { @@ -2699,7 +2699,7 @@ public function __construct( public ?DataCollection $nestedCollection, ) { } - }; + } DataValidationAsserter::for(TestValidationNestedPropertyMorphableData::class) ->assertErrors([ @@ -2731,4 +2731,227 @@ public function __construct( 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], ['variant' => 'b', 'b' => 'bar']], ]); }); + + it('can validate property-morphable data with custom messages and attributes', function () { + abstract class TestValidationCustomMessageAbstractPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct( + #[PropertyForMorph] + public TestValidationPropertyMorphableEnum $variant, + #[Max(1)] + public int $abstract_integer, + public string $abstract_string, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant']) { + TestValidationPropertyMorphableEnum::A => TestValidationCustomMessagePropertyMorphableDataA::class, + default => null, + }; + } + + public static function messages() + { + return [ + 'abstract_integer.max' => 'Abstract class integer test message.', + ]; + } + + public static function attributes() + { + return [ + 'abstract_string' => '[Abstract String]', + ]; + } + } + + class TestValidationCustomMessagePropertyMorphableDataA extends TestValidationCustomMessageAbstractPropertyMorphableData + { + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) { + parent::__construct(TestValidationPropertyMorphableEnum::A, $abstract_integer, $abstract_string); + } + + public static function messages() + { + return [ + ...parent::messages(), + 'concrete_integer.max' => 'Concrete class integer test message.', + ]; + } + + public static function attributes() + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String]', + ]; + } + } + + DataValidationAsserter::for(TestValidationCustomMessagePropertyMorphableDataA::class) + ->assertErrors([ + 'variant' => 'a', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], [ + 'concrete_integer' => ['Concrete class integer test message.'], + 'concrete_string' => ['The [Concrete String] field is required.'], + 'abstract_integer' => ['Abstract class integer test message.'], + 'abstract_string' => ['The [Abstract String] field is required.'], + ]); + }); + + it('can validate collections of property-morphable data with custom messages and attributes', function () { + class TestCustomMessagesInCollectionOfPropertyMorphableData extends Data + { + public function __construct( + + /** + * @var array + */ + public array $items, + ) { + } + } + + abstract class TestCustomMessageOfPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct( + #[PropertyForMorph] + public TestValidationPropertyMorphableEnum $variant, + #[Max(1)] + public int $abstract_integer, + public string $abstract_string, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant']) { + TestValidationPropertyMorphableEnum::A => TestCustomMessagePropertyMorphableDataA::class, + TestValidationPropertyMorphableEnum::B => TestCustomMessagePropertyMorphableDataB::class, + default => null, + }; + } + + public static function messages() + { + return [ + 'abstract_integer.max' => 'Abstract class integer test message.', + ]; + } + + public static function attributes() + { + return [ + 'abstract_string' => '[Abstract String]', + ]; + } + } + + class TestCustomMessagePropertyMorphableDataA extends TestCustomMessageOfPropertyMorphableData + { + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) { + parent::__construct(TestValidationPropertyMorphableEnum::A, $abstract_integer, $abstract_string); + } + + public static function messages() + { + return [ + ...parent::messages(), + 'concrete_integer.max' => 'Concrete class A integer test message.', + ]; + } + + public static function attributes() + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String A]', + ]; + } + } + + class TestCustomMessagePropertyMorphableDataB extends TestCustomMessageOfPropertyMorphableData + { + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) { + parent::__construct(TestValidationPropertyMorphableEnum::B, $abstract_integer, $abstract_string); + } + + public static function messages() + { + return [ + ...parent::messages(), + 'abstract_integer.max' => 'Concrete Class B override of Abstract class integer test message.', + 'concrete_integer.max' => 'Concrete class B integer test message.', + ]; + } + + public static function attributes() + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String B]', + ]; + } + } + + + DataValidationAsserter::for(TestCustomMessagesInCollectionOfPropertyMorphableData::class) + ->assertErrors([ + 'items' => [ + [ + 'variant' => 'a', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], + [ + 'variant' => 'invalid', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], + [ + 'variant' => 'b', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], + ]], [ + 'items.0.concrete_integer' => ['Concrete class A integer test message.'], + 'items.0.concrete_string' => ['The [Concrete String A] field is required.'], + 'items.0.abstract_integer' => ['Abstract class integer test message.'], + 'items.0.abstract_string' => ['The [Abstract String] field is required.'], + + 'items.1.variant' => [ + 'The selected items.1.variant is invalid.', + 'The selected items.1.variant is invalid for morph.', + ], + 'items.1.abstract_integer' => ['Abstract class integer test message.'], + 'items.1.abstract_string' => ['The [Abstract String] field is required.'], + + 'items.2.concrete_integer' => ['Concrete class B integer test message.'], + 'items.2.concrete_string' => ['The [Concrete String B] field is required.'], + 'items.2.abstract_integer' => ['Concrete Class B override of Abstract class integer test message.'], + 'items.2.abstract_string' => ['The [Abstract String] field is required.'], + ]); + }); });