From c88b8fbb98068308e0ed9c5fa75c22d5240fe871 Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Thu, 2 Jan 2025 11:28:41 +1000 Subject: [PATCH 01/10] Add support for property-morphable data --- src/Contracts/PropertyMorphableData.php | 8 ++++++++ src/Resolvers/DataFromSomethingResolver.php | 12 ++++++++++++ src/Resolvers/DataValidationRulesResolver.php | 6 ++++++ src/Support/DataClass.php | 1 + .../EloquentCasts/DataCollectionEloquentCast.php | 2 +- src/Support/EloquentCasts/DataEloquentCast.php | 4 +++- src/Support/Factories/DataClassFactory.php | 2 ++ 7 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 src/Contracts/PropertyMorphableData.php diff --git a/src/Contracts/PropertyMorphableData.php b/src/Contracts/PropertyMorphableData.php new file mode 100644 index 000000000..ac2002c04 --- /dev/null +++ b/src/Contracts/PropertyMorphableData.php @@ -0,0 +1,8 @@ +isPropertyMorphable($class)) { + $class = $class::morph(...$payloads) ?? $class; + } + if ($data = $this->createFromCustomCreationMethod($class, $creationContext, $payloads)) { return $data; } @@ -60,6 +64,14 @@ public function execute( return $this->dataFromArrayResolver->execute($class, $properties); } + protected function isPropertyMorphable(string $class): bool + { + $dataClass = $this->dataConfig + ->getDataClass($class); + + return $dataClass->isAbstract && $dataClass->propertyMorphable; + } + protected function createFromCustomCreationMethod( string $class, CreationContext $creationContext, diff --git a/src/Resolvers/DataValidationRulesResolver.php b/src/Resolvers/DataValidationRulesResolver.php index f1a469f87..7bc1c03eb 100644 --- a/src/Resolvers/DataValidationRulesResolver.php +++ b/src/Resolvers/DataValidationRulesResolver.php @@ -34,6 +34,12 @@ public function execute( ): array { $dataClass = $this->dataConfig->getDataClass($class); + if ($dataClass->isAbstract && $dataClass->propertyMorphable) { + $payload = $path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), []); + $class = $class::morph($payload) ?? $class; + $dataClass = $this->dataConfig->getDataClass($class); + } + $withoutValidationProperties = []; foreach ($dataClass->properties as $dataProperty) { diff --git a/src/Support/DataClass.php b/src/Support/DataClass.php index 003ebeba1..5615e9129 100644 --- a/src/Support/DataClass.php +++ b/src/Support/DataClass.php @@ -20,6 +20,7 @@ public function __construct( public readonly ?DataMethod $constructorMethod, public readonly bool $isReadonly, public readonly bool $isAbstract, + public readonly bool $propertyMorphable, public readonly bool $appendable, public readonly bool $includeable, public readonly bool $responsable, diff --git a/src/Support/EloquentCasts/DataCollectionEloquentCast.php b/src/Support/EloquentCasts/DataCollectionEloquentCast.php index e323aaa64..cda80aa52 100644 --- a/src/Support/EloquentCasts/DataCollectionEloquentCast.php +++ b/src/Support/EloquentCasts/DataCollectionEloquentCast.php @@ -43,7 +43,7 @@ public function get($model, string $key, $value, array $attributes): ?DataCollec $dataClass = $this->dataConfig->getDataClass($this->dataClass); $data = array_map(function (array $item) use ($dataClass) { - if ($dataClass->isAbstract && $dataClass->transformable) { + if ($dataClass->isAbstract && $dataClass->transformable && ! $dataClass->propertyMorphable) { $morphedClass = $this->dataConfig->morphMap->getMorphedDataClass($item['type']) ?? $item['type']; return $morphedClass::from($item['data']); diff --git a/src/Support/EloquentCasts/DataEloquentCast.php b/src/Support/EloquentCasts/DataEloquentCast.php index 6e7095736..c2af766b2 100644 --- a/src/Support/EloquentCasts/DataEloquentCast.php +++ b/src/Support/EloquentCasts/DataEloquentCast.php @@ -85,6 +85,8 @@ public function set($model, string $key, $value, array $attributes): ?string protected function isAbstractClassCast(): bool { - return $this->dataConfig->getDataClass($this->dataClass)->isAbstract; + $dataClass = $this->dataConfig->getDataClass($this->dataClass); + + return $dataClass->isAbstract && ! $dataClass->propertyMorphable; } } diff --git a/src/Support/Factories/DataClassFactory.php b/src/Support/Factories/DataClassFactory.php index 46b09a2ca..6d1659788 100644 --- a/src/Support/Factories/DataClassFactory.php +++ b/src/Support/Factories/DataClassFactory.php @@ -11,6 +11,7 @@ use Spatie\LaravelData\Contracts\AppendableData; use Spatie\LaravelData\Contracts\EmptyData; use Spatie\LaravelData\Contracts\IncludeableData; +use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Contracts\ResponsableData; use Spatie\LaravelData\Contracts\TransformableData; use Spatie\LaravelData\Contracts\ValidateableData; @@ -82,6 +83,7 @@ public function build(ReflectionClass $reflectionClass): DataClass constructorMethod: $constructor, isReadonly: method_exists($reflectionClass, 'isReadOnly') && $reflectionClass->isReadOnly(), isAbstract: $reflectionClass->isAbstract(), + propertyMorphable: $reflectionClass->implementsInterface(PropertyMorphableData::class), appendable: $reflectionClass->implementsInterface(AppendableData::class), includeable: $reflectionClass->implementsInterface(IncludeableData::class), responsable: $responsable, From a17d5f9a8a3bb831e208e65858d89cd040ef6f9f Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Thu, 2 Jan 2025 11:59:57 +1000 Subject: [PATCH 02/10] Add tests for creation --- tests/CreationTest.php | 64 +++++++++++++++++++ .../AbstractPropertyMorphableData.php | 28 ++++++++ .../NestedPropertyMorphableData.php | 15 +++++ .../PropertyMorphableDataA.php | 12 ++++ .../PropertyMorphableDataB.php | 12 ++++ .../PropertyMorphableEnum.php | 9 +++ 6 files changed, 140 insertions(+) create mode 100644 tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php create mode 100644 tests/Fakes/PropertyMorphableData/NestedPropertyMorphableData.php create mode 100644 tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php create mode 100644 tests/Fakes/PropertyMorphableData/PropertyMorphableDataB.php create mode 100644 tests/Fakes/PropertyMorphableData/PropertyMorphableEnum.php diff --git a/tests/CreationTest.php b/tests/CreationTest.php index d8bc3c559..adaff27c8 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -53,6 +53,11 @@ use Spatie\LaravelData\Tests\Fakes\NestedLazyData; use Spatie\LaravelData\Tests\Fakes\NestedModelCollectionData; use Spatie\LaravelData\Tests\Fakes\NestedModelData; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\AbstractPropertyMorphableData; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\NestedPropertyMorphableData; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataA; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataB; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableEnum; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithoutConstructor; @@ -1227,3 +1232,62 @@ public static function pipeline(): DataPipeline [10, SimpleData::from('Hello World')] ); })->todo(); + +it('will allow property-morphable data to be created', function () { + $dataA = AbstractPropertyMorphableData::from([ + 'variant' => 'a', + 'a' => 'foo', + ]); + + expect($dataA) + ->toBeInstanceOf(PropertyMorphableDataA::class) + ->variant->toEqual(PropertyMorphableEnum::A) + ->a->toEqual('foo'); + + $dataB = AbstractPropertyMorphableData::from([ + 'variant' => 'b', + 'b' => 'bar', + ]); + + expect($dataB) + ->toBeInstanceOf(PropertyMorphableDataB::class) + ->variant->toEqual(PropertyMorphableEnum::B) + ->b->toEqual('bar'); +}); + +it('will allow property-morphable data to be created from a nested collection', function () { + $data = NestedPropertyMorphableData::from([ + 'nestedCollection' => [ + ['variant' => 'a', 'a' => 'foo'], + ['variant' => 'b', 'b' => 'bar'], + ], + ]); + + expect($data->nestedCollection[0]) + ->toBeInstanceOf(PropertyMorphableDataA::class) + ->variant->toEqual(PropertyMorphableEnum::A) + ->a->toEqual('foo'); + + expect($data->nestedCollection[1]) + ->toBeInstanceOf(PropertyMorphableDataB::class) + ->variant->toEqual(PropertyMorphableEnum::B) + ->b->toEqual('bar'); +}); + + +it('will allow property-morphable data to be created as a collection', function () { + $collection = AbstractPropertyMorphableData::collect([ + ['variant' => 'a', 'a' => 'foo'], + ['variant' => 'b', 'b' => 'bar'], + ]); + + expect($collection[0]) + ->toBeInstanceOf(PropertyMorphableDataA::class) + ->variant->toEqual(PropertyMorphableEnum::A) + ->a->toEqual('foo'); + + expect($collection[1]) + ->toBeInstanceOf(PropertyMorphableDataB::class) + ->variant->toEqual(PropertyMorphableEnum::B) + ->b->toEqual('bar'); +}); diff --git a/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php b/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php new file mode 100644 index 000000000..ff30720ac --- /dev/null +++ b/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php @@ -0,0 +1,28 @@ + PropertyMorphableDataA::class, + PropertyMorphableEnum::B => PropertyMorphableDataB::class, + default => null, + }; + } +} diff --git a/tests/Fakes/PropertyMorphableData/NestedPropertyMorphableData.php b/tests/Fakes/PropertyMorphableData/NestedPropertyMorphableData.php new file mode 100644 index 000000000..a03d0aac6 --- /dev/null +++ b/tests/Fakes/PropertyMorphableData/NestedPropertyMorphableData.php @@ -0,0 +1,15 @@ + Date: Thu, 2 Jan 2025 12:02:08 +1000 Subject: [PATCH 03/10] Simplify tests by removing enum --- tests/CreationTest.php | 13 ++++++------- .../AbstractPropertyMorphableData.php | 15 ++++++--------- .../PropertyMorphableDataA.php | 2 +- .../PropertyMorphableDataB.php | 2 +- .../PropertyMorphableEnum.php | 9 --------- 5 files changed, 14 insertions(+), 27 deletions(-) delete mode 100644 tests/Fakes/PropertyMorphableData/PropertyMorphableEnum.php diff --git a/tests/CreationTest.php b/tests/CreationTest.php index adaff27c8..963491cb2 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -57,7 +57,6 @@ use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\NestedPropertyMorphableData; use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataA; use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataB; -use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableEnum; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithoutConstructor; @@ -1241,7 +1240,7 @@ public static function pipeline(): DataPipeline expect($dataA) ->toBeInstanceOf(PropertyMorphableDataA::class) - ->variant->toEqual(PropertyMorphableEnum::A) + ->variant->toEqual('a') ->a->toEqual('foo'); $dataB = AbstractPropertyMorphableData::from([ @@ -1251,7 +1250,7 @@ public static function pipeline(): DataPipeline expect($dataB) ->toBeInstanceOf(PropertyMorphableDataB::class) - ->variant->toEqual(PropertyMorphableEnum::B) + ->variant->toEqual('b') ->b->toEqual('bar'); }); @@ -1265,12 +1264,12 @@ public static function pipeline(): DataPipeline expect($data->nestedCollection[0]) ->toBeInstanceOf(PropertyMorphableDataA::class) - ->variant->toEqual(PropertyMorphableEnum::A) + ->variant->toEqual('a') ->a->toEqual('foo'); expect($data->nestedCollection[1]) ->toBeInstanceOf(PropertyMorphableDataB::class) - ->variant->toEqual(PropertyMorphableEnum::B) + ->variant->toEqual('b') ->b->toEqual('bar'); }); @@ -1283,11 +1282,11 @@ public static function pipeline(): DataPipeline expect($collection[0]) ->toBeInstanceOf(PropertyMorphableDataA::class) - ->variant->toEqual(PropertyMorphableEnum::A) + ->variant->toEqual('a') ->a->toEqual('foo'); expect($collection[1]) ->toBeInstanceOf(PropertyMorphableDataB::class) - ->variant->toEqual(PropertyMorphableEnum::B) + ->variant->toEqual('b') ->b->toEqual('bar'); }); diff --git a/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php b/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php index ff30720ac..674cd436b 100644 --- a/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php +++ b/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php @@ -2,26 +2,23 @@ namespace Spatie\LaravelData\Tests\Fakes\PropertyMorphableData; +use Spatie\LaravelData\Attributes\Validation\In; use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Data; abstract class AbstractPropertyMorphableData extends Data implements PropertyMorphableData { public function __construct( - public PropertyMorphableEnum $variant, + #[In('a', 'b')] + public string $variant, ) { } public static function morph(...$payloads): ?string { - $variant = $payloads[0]['variant'] ?? null; - if (! $variant instanceof PropertyMorphableEnum) { - $variant = PropertyMorphableEnum::tryFrom($variant); - } - - return match ($variant) { - PropertyMorphableEnum::A => PropertyMorphableDataA::class, - PropertyMorphableEnum::B => PropertyMorphableDataB::class, + return match ($payloads[0]['variant'] ?? null) { + 'a' => PropertyMorphableDataA::class, + 'b' => PropertyMorphableDataB::class, default => null, }; } diff --git a/tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php b/tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php index fecbbed61..18733a488 100644 --- a/tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php +++ b/tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php @@ -7,6 +7,6 @@ class PropertyMorphableDataA extends AbstractPropertyMorphableData public function __construct( public string $a, ) { - parent::__construct(PropertyMorphableEnum::A); + parent::__construct('a'); } } diff --git a/tests/Fakes/PropertyMorphableData/PropertyMorphableDataB.php b/tests/Fakes/PropertyMorphableData/PropertyMorphableDataB.php index 3e398d7e1..57bdb72f7 100644 --- a/tests/Fakes/PropertyMorphableData/PropertyMorphableDataB.php +++ b/tests/Fakes/PropertyMorphableData/PropertyMorphableDataB.php @@ -7,6 +7,6 @@ class PropertyMorphableDataB extends AbstractPropertyMorphableData public function __construct( public string $b, ) { - parent::__construct(PropertyMorphableEnum::B); + parent::__construct('b'); } } diff --git a/tests/Fakes/PropertyMorphableData/PropertyMorphableEnum.php b/tests/Fakes/PropertyMorphableData/PropertyMorphableEnum.php deleted file mode 100644 index a8f9b5909..000000000 --- a/tests/Fakes/PropertyMorphableData/PropertyMorphableEnum.php +++ /dev/null @@ -1,9 +0,0 @@ - Date: Tue, 24 Dec 2024 16:21:26 +1000 Subject: [PATCH 04/10] Update data collection eloquent cast --- .../DataCollectionEloquentCast.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Support/EloquentCasts/DataCollectionEloquentCast.php b/src/Support/EloquentCasts/DataCollectionEloquentCast.php index cda80aa52..86fa4085d 100644 --- a/src/Support/EloquentCasts/DataCollectionEloquentCast.php +++ b/src/Support/EloquentCasts/DataCollectionEloquentCast.php @@ -40,10 +40,10 @@ public function get($model, string $key, $value, array $attributes): ?DataCollec $data = json_decode($value, true, flags: JSON_THROW_ON_ERROR); - $dataClass = $this->dataConfig->getDataClass($this->dataClass); + $isAbstractClassCast = $this->isAbstractClassCast(); - $data = array_map(function (array $item) use ($dataClass) { - if ($dataClass->isAbstract && $dataClass->transformable && ! $dataClass->propertyMorphable) { + $data = array_map(function (array $item) use ($isAbstractClassCast) { + if ($isAbstractClassCast) { $morphedClass = $this->dataConfig->morphMap->getMorphedDataClass($item['type']) ?? $item['type']; return $morphedClass::from($item['data']); @@ -73,10 +73,10 @@ public function set($model, string $key, $value, array $attributes): ?string throw CannotCastData::shouldBeArray($model::class, $key); } - $dataClass = $this->dataConfig->getDataClass($this->dataClass); + $isAbstractClassCast = $this->isAbstractClassCast(); - $data = array_map(function (array|BaseData $item) use ($dataClass) { - if ($dataClass->isAbstract && $item instanceof TransformableData) { + $data = array_map(function (array|BaseData $item) use ($isAbstractClassCast) { + if ($isAbstractClassCast && $item instanceof TransformableData) { $class = get_class($item); return [ @@ -90,7 +90,7 @@ public function set($model, string $key, $value, array $attributes): ?string : $item; }, $value); - if ($dataClass->isAbstract) { + if ($isAbstractClassCast) { return json_encode($data); } @@ -107,6 +107,8 @@ public function set($model, string $key, $value, array $attributes): ?string protected function isAbstractClassCast(): bool { - return $this->dataConfig->getDataClass($this->dataClass)->isAbstract; + $dataClass = $this->dataConfig->getDataClass($this->dataClass); + + return $dataClass->isAbstract && $dataClass->transformable && ! $dataClass->propertyMorphable; } } From 1f630b148b7297583e152dc8d63829bb462fbf08 Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Thu, 2 Jan 2025 12:31:12 +1000 Subject: [PATCH 05/10] Add tests that cover validation and eloquent casting --- .../DummyModelWithPropertyMorphableCast.php | 19 +++++++ .../DataCollectionEloquentCastTest.php | 29 ++++++++++ .../EloquentCasts/DataEloquentCastTest.php | 20 +++++++ tests/ValidationTest.php | 55 +++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 tests/Fakes/Models/DummyModelWithPropertyMorphableCast.php diff --git a/tests/Fakes/Models/DummyModelWithPropertyMorphableCast.php b/tests/Fakes/Models/DummyModelWithPropertyMorphableCast.php new file mode 100644 index 000000000..ce1bfe6fe --- /dev/null +++ b/tests/Fakes/Models/DummyModelWithPropertyMorphableCast.php @@ -0,0 +1,19 @@ + AbstractPropertyMorphableData::class, + 'data_collection' => SimpleDataCollection::class.':'.AbstractPropertyMorphableData::class, + ]; + + protected $table = 'dummy_model_with_casts'; + + public $timestamps = false; +} diff --git a/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php b/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php index 97efc5d10..676e6d427 100644 --- a/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php @@ -11,6 +11,9 @@ use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCustomCollectionCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithDefaultCasts; +use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithPropertyMorphableCast; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataA; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataB; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataCollection; @@ -170,3 +173,29 @@ expect($model->abstract_collection[0])->toBeInstanceOf(AbstractDataA::class); expect($model->abstract_collection[1])->toBeInstanceOf(AbstractDataB::class); }); + +it('can load and save an abstract property-morphable data collection', function () { + $abstractA = new PropertyMorphableDataA('foo'); + $abstractB = new PropertyMorphableDataB('bar'); + + $modelId = DummyModelWithPropertyMorphableCast::create([ + 'data_collection' => [$abstractA, $abstractB], + ])->id; + + assertDatabaseHas(DummyModelWithPropertyMorphableCast::class, [ + 'data_collection' => json_encode([ + ['a' => 'foo', 'variant' => 'a'], + ['b' => 'bar', 'variant' => 'b'], + ], JSON_PRETTY_PRINT), + ]); + + $model = DummyModelWithPropertyMorphableCast::find($modelId); + + expect($model->data_collection[0]) + ->toBeInstanceOf(PropertyMorphableDataA::class) + ->a->toBe('foo'); + + expect($model->data_collection[1]) + ->toBeInstanceOf(PropertyMorphableDataB::class) + ->b->toBe('bar'); +}); diff --git a/tests/Support/EloquentCasts/DataEloquentCastTest.php b/tests/Support/EloquentCasts/DataEloquentCastTest.php index 4b2915120..ba8757286 100644 --- a/tests/Support/EloquentCasts/DataEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataEloquentCastTest.php @@ -12,6 +12,8 @@ use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithDefaultCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithEncryptedCasts; +use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithPropertyMorphableCast; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataA; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithDefaultValue; @@ -183,3 +185,21 @@ expect($isEncrypted)->toBeTrue(); }); + +it('can load and save an abstract property-morphable data object', function () { + $abstractA = new PropertyMorphableDataA('foo'); + + $modelId = DummyModelWithPropertyMorphableCast::create([ + 'data' => $abstractA, + ])->id; + + assertDatabaseHas(DummyModelWithPropertyMorphableCast::class, [ + 'data' => json_encode(['a' => 'foo', 'variant' => 'a']), + ]); + + $model = DummyModelWithPropertyMorphableCast::find($modelId); + + expect($model->data) + ->toBeInstanceOf(PropertyMorphableDataA::class) + ->a->toBe('foo'); +}); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index d6e1dff90..e08c2f8c5 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -55,6 +55,8 @@ use Spatie\LaravelData\Tests\Fakes\MultiData; use Spatie\LaravelData\Tests\Fakes\NestedData; use Spatie\LaravelData\Tests\Fakes\NestedNullableData; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\AbstractPropertyMorphableData; +use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\NestedPropertyMorphableData; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithExplicitValidationRuleAttributeData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithOverwrittenRules; @@ -2528,3 +2530,56 @@ public function __construct( 'otherProperty' => 'Hello World', ]); }); + +it('can validate property-morphable data', function () { + DataValidationAsserter::for(AbstractPropertyMorphableData::class) + ->assertErrors([], [ + 'variant' => ['The variant field is required.'], + ]) + ->assertErrors([ + 'variant' => 'c', + ], [ + 'variant' => ['The selected variant is invalid.'], + ]) + ->assertErrors([ + 'variant' => 'a', + ], [ + 'a' => ['The a field is required.'], + ]) + ->assertErrors([ + 'variant' => 'b', + ], [ + 'b' => ['The b field is required.'], + ]) + ->assertOk([ + 'variant' => 'a', + 'a' => 'foo', + ]) + ->assertOk([ + 'variant' => 'b', + 'b' => 'foo', + ]); +}); + +it('can validate nested property-morphable data', function () { + DataValidationAsserter::for(NestedPropertyMorphableData::class) + ->assertErrors([ + 'nestedCollection' => [[]], + ], [ + 'nestedCollection.0.variant' => ['The nested collection.0.variant field is required.'], + ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'c']], + ], [ + 'nestedCollection.0.variant' => ['The selected nested collection.0.variant is invalid.'], + ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'a'], ['variant' => 'b']], + ], [ + 'nestedCollection.0.a' => ['The nested collection.0.a field is required.'], + 'nestedCollection.1.b' => ['The nested collection.1.b field is required.'], + ]) + ->assertOk([ + 'nestedCollection' => [['variant' => 'a', 'a' => 'foo'], ['variant' => 'b', 'b' => 'bar']], + ]); +}); From 46c95511c41f225f52dd102de8f67666944528a1 Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Thu, 2 Jan 2025 18:19:19 +1000 Subject: [PATCH 06/10] Fix phpstan issues --- src/Resolvers/DataFromSomethingResolver.php | 17 +++++++++++------ src/Resolvers/DataValidationRulesResolver.php | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Resolvers/DataFromSomethingResolver.php b/src/Resolvers/DataFromSomethingResolver.php index 079740854..6861c80cf 100644 --- a/src/Resolvers/DataFromSomethingResolver.php +++ b/src/Resolvers/DataFromSomethingResolver.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Spatie\LaravelData\Contracts\BaseData; +use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Enums\CustomCreationMethodType; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Creation\CreationContext; @@ -30,8 +31,8 @@ public function execute( CreationContext $creationContext, mixed ...$payloads ): BaseData { - if ($this->isPropertyMorphable($class)) { - $class = $class::morph(...$payloads) ?? $class; + if ($propertyMorphableClass = $this->propertyMorphableClass($class)) { + $class = $propertyMorphableClass::morph(...$payloads) ?? $class; } if ($data = $this->createFromCustomCreationMethod($class, $creationContext, $payloads)) { @@ -64,12 +65,16 @@ public function execute( return $this->dataFromArrayResolver->execute($class, $properties); } - protected function isPropertyMorphable(string $class): bool + /** + * @return class-string + */ + protected function propertyMorphableClass(string $class): ?string { - $dataClass = $this->dataConfig - ->getDataClass($class); + $dataClass = $this->dataConfig->getDataClass($class); - return $dataClass->isAbstract && $dataClass->propertyMorphable; + return $dataClass->isAbstract && $dataClass->propertyMorphable + ? $class + : null; } protected function createFromCustomCreationMethod( diff --git a/src/Resolvers/DataValidationRulesResolver.php b/src/Resolvers/DataValidationRulesResolver.php index 7bc1c03eb..ec6348f20 100644 --- a/src/Resolvers/DataValidationRulesResolver.php +++ b/src/Resolvers/DataValidationRulesResolver.php @@ -7,6 +7,7 @@ use Illuminate\Validation\Rule; use Spatie\LaravelData\Attributes\Validation\ArrayType; use Spatie\LaravelData\Attributes\Validation\Present; +use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Support\DataClass; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\DataProperty; @@ -34,9 +35,9 @@ public function execute( ): array { $dataClass = $this->dataConfig->getDataClass($class); - if ($dataClass->isAbstract && $dataClass->propertyMorphable) { + if ($propertyMorphableClass = $this->propertyMorphableClass($dataClass)) { $payload = $path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), []); - $class = $class::morph($payload) ?? $class; + $class = $propertyMorphableClass::morph($payload) ?? $class; $dataClass = $this->dataConfig->getDataClass($class); } @@ -284,4 +285,14 @@ protected function inferRulesForDataProperty( $path ); } + + /** + * @return class-string + */ + protected function propertyMorphableClass(DataClass $dataClass): ?string + { + return $dataClass->isAbstract && $dataClass->propertyMorphable + ? $dataClass->name + : null; + } } From 549889b53a689c58b21bd2f8df8211706fd36ba9 Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Fri, 3 Jan 2025 10:49:38 +1000 Subject: [PATCH 07/10] Use properties instead of payloads --- src/Contracts/PropertyMorphableData.php | 2 +- src/Resolvers/DataFromArrayResolver.php | 16 ++++++++++++++++ src/Resolvers/DataFromSomethingResolver.php | 17 ----------------- .../AbstractPropertyMorphableData.php | 4 ++-- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/Contracts/PropertyMorphableData.php b/src/Contracts/PropertyMorphableData.php index ac2002c04..7a71523a0 100644 --- a/src/Contracts/PropertyMorphableData.php +++ b/src/Contracts/PropertyMorphableData.php @@ -4,5 +4,5 @@ interface PropertyMorphableData { - public static function morph(mixed ...$payloads): ?string; + public static function morph(array $properties): ?string; } diff --git a/src/Resolvers/DataFromArrayResolver.php b/src/Resolvers/DataFromArrayResolver.php index 825ded0c6..8e579f70a 100644 --- a/src/Resolvers/DataFromArrayResolver.php +++ b/src/Resolvers/DataFromArrayResolver.php @@ -4,6 +4,7 @@ use ArgumentCountError; use Spatie\LaravelData\Contracts\BaseData; +use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Exceptions\CannotCreateData; use Spatie\LaravelData\Exceptions\CannotSetComputedValue; use Spatie\LaravelData\Optional; @@ -28,6 +29,11 @@ public function execute(string $class, array $properties): BaseData { $dataClass = $this->dataConfig->getDataClass($class); + if ($propertyMorphableClass = $this->propertyMorphableClass($dataClass)) { + $class = $propertyMorphableClass::morph($properties) ?? $class; + $dataClass = $this->dataConfig->getDataClass($class); + } + $data = $this->createData($dataClass, $properties); foreach ($dataClass->properties as $property) { @@ -101,4 +107,14 @@ protected function createData( ); } } + + /** + * @return class-string + */ + protected function propertyMorphableClass(DataClass $dataClass): ?string + { + return $dataClass->isAbstract && $dataClass->propertyMorphable + ? $dataClass->name + : null; + } } diff --git a/src/Resolvers/DataFromSomethingResolver.php b/src/Resolvers/DataFromSomethingResolver.php index 6861c80cf..e35616eee 100644 --- a/src/Resolvers/DataFromSomethingResolver.php +++ b/src/Resolvers/DataFromSomethingResolver.php @@ -4,7 +4,6 @@ use Illuminate\Http\Request; use Spatie\LaravelData\Contracts\BaseData; -use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Enums\CustomCreationMethodType; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Creation\CreationContext; @@ -31,10 +30,6 @@ public function execute( CreationContext $creationContext, mixed ...$payloads ): BaseData { - if ($propertyMorphableClass = $this->propertyMorphableClass($class)) { - $class = $propertyMorphableClass::morph(...$payloads) ?? $class; - } - if ($data = $this->createFromCustomCreationMethod($class, $creationContext, $payloads)) { return $data; } @@ -65,18 +60,6 @@ public function execute( return $this->dataFromArrayResolver->execute($class, $properties); } - /** - * @return class-string - */ - protected function propertyMorphableClass(string $class): ?string - { - $dataClass = $this->dataConfig->getDataClass($class); - - return $dataClass->isAbstract && $dataClass->propertyMorphable - ? $class - : null; - } - protected function createFromCustomCreationMethod( string $class, CreationContext $creationContext, diff --git a/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php b/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php index 674cd436b..60240c243 100644 --- a/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php +++ b/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php @@ -14,9 +14,9 @@ public function __construct( ) { } - public static function morph(...$payloads): ?string + public static function morph(array $properties): ?string { - return match ($payloads[0]['variant'] ?? null) { + return match ($properties['variant'] ?? null) { 'a' => PropertyMorphableDataA::class, 'b' => PropertyMorphableDataB::class, default => null, From b97829739b5186ababf1f94202333dad121ca83b Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Fri, 3 Jan 2025 18:52:25 +1000 Subject: [PATCH 08/10] Ensure casting happens with the context of the final class --- src/Resolvers/DataFromArrayResolver.php | 16 ------- src/Resolvers/DataFromSomethingResolver.php | 45 +++++++++++++------ tests/CreationTest.php | 27 ++++++++--- .../PropertyMorphableDataA.php | 3 ++ .../DataCollectionEloquentCastTest.php | 8 ++-- .../EloquentCasts/DataEloquentCastTest.php | 8 ++-- tests/ValidationTest.php | 17 ++++++- 7 files changed, 83 insertions(+), 41 deletions(-) diff --git a/src/Resolvers/DataFromArrayResolver.php b/src/Resolvers/DataFromArrayResolver.php index 8e579f70a..825ded0c6 100644 --- a/src/Resolvers/DataFromArrayResolver.php +++ b/src/Resolvers/DataFromArrayResolver.php @@ -4,7 +4,6 @@ use ArgumentCountError; use Spatie\LaravelData\Contracts\BaseData; -use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Exceptions\CannotCreateData; use Spatie\LaravelData\Exceptions\CannotSetComputedValue; use Spatie\LaravelData\Optional; @@ -29,11 +28,6 @@ public function execute(string $class, array $properties): BaseData { $dataClass = $this->dataConfig->getDataClass($class); - if ($propertyMorphableClass = $this->propertyMorphableClass($dataClass)) { - $class = $propertyMorphableClass::morph($properties) ?? $class; - $dataClass = $this->dataConfig->getDataClass($class); - } - $data = $this->createData($dataClass, $properties); foreach ($dataClass->properties as $property) { @@ -107,14 +101,4 @@ protected function createData( ); } } - - /** - * @return class-string - */ - protected function propertyMorphableClass(DataClass $dataClass): ?string - { - return $dataClass->isAbstract && $dataClass->propertyMorphable - ? $dataClass->name - : null; - } } diff --git a/src/Resolvers/DataFromSomethingResolver.php b/src/Resolvers/DataFromSomethingResolver.php index e35616eee..14d930aca 100644 --- a/src/Resolvers/DataFromSomethingResolver.php +++ b/src/Resolvers/DataFromSomethingResolver.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Spatie\LaravelData\Contracts\BaseData; +use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Enums\CustomCreationMethodType; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Creation\CreationContext; @@ -39,25 +40,23 @@ public function execute( $payloadCount = count($payloads); if ($payloadCount === 0 || $payloadCount === 1) { - return $this->dataFromArrayResolver->execute( - $class, - $pipeline->execute($payloads[0] ?? [], $creationContext) - ); - } + $properties = $pipeline->execute($payloads[0] ?? [], $creationContext); + } else { + $properties = []; - $properties = []; + foreach ($payloads as $payload) { + foreach ($pipeline->execute($payload, $creationContext) as $key => $value) { + if (array_key_exists($key, $properties) && ($value === null || $value instanceof Optional)) { + continue; + } - foreach ($payloads as $payload) { - foreach ($pipeline->execute($payload, $creationContext) as $key => $value) { - if (array_key_exists($key, $properties) && ($value === null || $value instanceof Optional)) { - continue; + $properties[$key] = $value; } - - $properties[$key] = $value; } } - return $this->dataFromArrayResolver->execute($class, $properties); + return $this->propertyMorphedData($class, $creationContext, $payloads, $properties) + ?? $this->dataFromArrayResolver->execute($class, $properties); } protected function createFromCustomCreationMethod( @@ -117,4 +116,24 @@ protected function createFromCustomCreationMethod( return $class::$methodName(...$payloads); } + + protected function propertyMorphedData( + string $class, + CreationContext $creationContext, + array $payloads, + array $properties, + ): ?BaseData { + $dataClass = $this->dataConfig->getDataClass($class); + + if ($dataClass->isAbstract && $dataClass->propertyMorphable) { + /** + * @var class-string $class + */ + if ($morph = $class::morph($properties)) { + return $this->execute($morph, $creationContext, ...$payloads); + } + } + + return null; + } } diff --git a/tests/CreationTest.php b/tests/CreationTest.php index 963491cb2..850c93b65 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -1236,12 +1236,14 @@ public static function pipeline(): DataPipeline $dataA = AbstractPropertyMorphableData::from([ 'variant' => 'a', 'a' => 'foo', + 'enum' => 'foo', ]); expect($dataA) ->toBeInstanceOf(PropertyMorphableDataA::class) ->variant->toEqual('a') - ->a->toEqual('foo'); + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); $dataB = AbstractPropertyMorphableData::from([ 'variant' => 'b', @@ -1254,10 +1256,23 @@ public static function pipeline(): DataPipeline ->b->toEqual('bar'); }); +it('will allow property-morphable data to be created from concrete', function () { + $dataA = PropertyMorphableDataA::from([ + 'a' => 'foo', + 'enum' => 'foo', + ]); + + expect($dataA) + ->toBeInstanceOf(PropertyMorphableDataA::class) + ->variant->toEqual('a') + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); +}); + it('will allow property-morphable data to be created from a nested collection', function () { $data = NestedPropertyMorphableData::from([ 'nestedCollection' => [ - ['variant' => 'a', 'a' => 'foo'], + ['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], ['variant' => 'b', 'b' => 'bar'], ], ]); @@ -1265,7 +1280,8 @@ public static function pipeline(): DataPipeline expect($data->nestedCollection[0]) ->toBeInstanceOf(PropertyMorphableDataA::class) ->variant->toEqual('a') - ->a->toEqual('foo'); + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); expect($data->nestedCollection[1]) ->toBeInstanceOf(PropertyMorphableDataB::class) @@ -1276,14 +1292,15 @@ public static function pipeline(): DataPipeline it('will allow property-morphable data to be created as a collection', function () { $collection = AbstractPropertyMorphableData::collect([ - ['variant' => 'a', 'a' => 'foo'], + ['variant' => 'a', 'a' => 'foo', 'enum' => DummyBackedEnum::FOO->value], ['variant' => 'b', 'b' => 'bar'], ]); expect($collection[0]) ->toBeInstanceOf(PropertyMorphableDataA::class) ->variant->toEqual('a') - ->a->toEqual('foo'); + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); expect($collection[1]) ->toBeInstanceOf(PropertyMorphableDataB::class) diff --git a/tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php b/tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php index 18733a488..c3f6d61d1 100644 --- a/tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php +++ b/tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php @@ -2,10 +2,13 @@ namespace Spatie\LaravelData\Tests\Fakes\PropertyMorphableData; +use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; + class PropertyMorphableDataA extends AbstractPropertyMorphableData { public function __construct( public string $a, + public DummyBackedEnum $enum, ) { parent::__construct('a'); } diff --git a/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php b/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php index 676e6d427..0a07b8b4d 100644 --- a/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php @@ -8,6 +8,7 @@ use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractData; use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractDataA; use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractDataB; +use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCustomCollectionCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithDefaultCasts; @@ -175,7 +176,7 @@ }); it('can load and save an abstract property-morphable data collection', function () { - $abstractA = new PropertyMorphableDataA('foo'); + $abstractA = new PropertyMorphableDataA('foo', DummyBackedEnum::FOO); $abstractB = new PropertyMorphableDataB('bar'); $modelId = DummyModelWithPropertyMorphableCast::create([ @@ -184,7 +185,7 @@ assertDatabaseHas(DummyModelWithPropertyMorphableCast::class, [ 'data_collection' => json_encode([ - ['a' => 'foo', 'variant' => 'a'], + ['a' => 'foo', 'enum' => 'foo', 'variant' => 'a'], ['b' => 'bar', 'variant' => 'b'], ], JSON_PRETTY_PRINT), ]); @@ -193,7 +194,8 @@ expect($model->data_collection[0]) ->toBeInstanceOf(PropertyMorphableDataA::class) - ->a->toBe('foo'); + ->a->toBe('foo') + ->enum->toBe(DummyBackedEnum::FOO); expect($model->data_collection[1]) ->toBeInstanceOf(PropertyMorphableDataB::class) diff --git a/tests/Support/EloquentCasts/DataEloquentCastTest.php b/tests/Support/EloquentCasts/DataEloquentCastTest.php index ba8757286..078c77c95 100644 --- a/tests/Support/EloquentCasts/DataEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataEloquentCastTest.php @@ -9,6 +9,7 @@ use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractDataA; use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractDataB; +use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithDefaultCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithEncryptedCasts; @@ -187,19 +188,20 @@ }); it('can load and save an abstract property-morphable data object', function () { - $abstractA = new PropertyMorphableDataA('foo'); + $abstractA = new PropertyMorphableDataA('foo', DummyBackedEnum::FOO); $modelId = DummyModelWithPropertyMorphableCast::create([ 'data' => $abstractA, ])->id; assertDatabaseHas(DummyModelWithPropertyMorphableCast::class, [ - 'data' => json_encode(['a' => 'foo', 'variant' => 'a']), + 'data' => json_encode(['a' => 'foo', 'enum' => 'foo', 'variant' => 'a']), ]); $model = DummyModelWithPropertyMorphableCast::find($modelId); expect($model->data) ->toBeInstanceOf(PropertyMorphableDataA::class) - ->a->toBe('foo'); + ->a->toBe('foo') + ->enum->toBe(DummyBackedEnum::FOO); }); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index e08c2f8c5..e3dbfe541 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -2545,6 +2545,14 @@ public function __construct( 'variant' => 'a', ], [ 'a' => ['The a field is required.'], + 'enum' => ['The enum field is required.'], + ]) + ->assertErrors([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'invalid', + ], [ + 'enum' => ['The selected enum is invalid.'], ]) ->assertErrors([ 'variant' => 'b', @@ -2554,6 +2562,7 @@ public function __construct( ->assertOk([ 'variant' => 'a', 'a' => 'foo', + 'enum' => 'foo', ]) ->assertOk([ 'variant' => 'b', @@ -2577,9 +2586,15 @@ public function __construct( 'nestedCollection' => [['variant' => 'a'], ['variant' => 'b']], ], [ 'nestedCollection.0.a' => ['The nested collection.0.a field is required.'], + 'nestedCollection.0.enum' => ['The nested collection.0.enum field is required.'], 'nestedCollection.1.b' => ['The nested collection.1.b field is required.'], ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'invalid']], + ], [ + 'nestedCollection.0.enum' => ['The selected nested collection.0.enum is invalid.'], + ]) ->assertOk([ - 'nestedCollection' => [['variant' => 'a', 'a' => 'foo'], ['variant' => 'b', 'b' => 'bar']], + 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], ['variant' => 'b', 'b' => 'bar']], ]); }); From 17be5beae45d1b1154c33b1622a89c8eaf494fb5 Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Fri, 3 Jan 2025 19:01:23 +1000 Subject: [PATCH 09/10] Refactoring --- src/Resolvers/DataFromSomethingResolver.php | 27 ++++++++++--------- src/Resolvers/DataValidationRulesResolver.php | 16 +++++------ 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/Resolvers/DataFromSomethingResolver.php b/src/Resolvers/DataFromSomethingResolver.php index 14d930aca..16d11cbac 100644 --- a/src/Resolvers/DataFromSomethingResolver.php +++ b/src/Resolvers/DataFromSomethingResolver.php @@ -41,22 +41,23 @@ public function execute( if ($payloadCount === 0 || $payloadCount === 1) { $properties = $pipeline->execute($payloads[0] ?? [], $creationContext); - } else { - $properties = []; - foreach ($payloads as $payload) { - foreach ($pipeline->execute($payload, $creationContext) as $key => $value) { - if (array_key_exists($key, $properties) && ($value === null || $value instanceof Optional)) { - continue; - } + return $this->dataFromArray($class, $creationContext, $payloads, $properties); + } + + $properties = []; - $properties[$key] = $value; + foreach ($payloads as $payload) { + foreach ($pipeline->execute($payload, $creationContext) as $key => $value) { + if (array_key_exists($key, $properties) && ($value === null || $value instanceof Optional)) { + continue; } + + $properties[$key] = $value; } } - return $this->propertyMorphedData($class, $creationContext, $payloads, $properties) - ?? $this->dataFromArrayResolver->execute($class, $properties); + return $this->dataFromArray($class, $creationContext, $payloads, $properties); } protected function createFromCustomCreationMethod( @@ -117,12 +118,12 @@ protected function createFromCustomCreationMethod( return $class::$methodName(...$payloads); } - protected function propertyMorphedData( + protected function dataFromArray( string $class, CreationContext $creationContext, array $payloads, array $properties, - ): ?BaseData { + ): BaseData { $dataClass = $this->dataConfig->getDataClass($class); if ($dataClass->isAbstract && $dataClass->propertyMorphable) { @@ -134,6 +135,6 @@ protected function propertyMorphedData( } } - return null; + return $this->dataFromArrayResolver->execute($class, $properties); } } diff --git a/src/Resolvers/DataValidationRulesResolver.php b/src/Resolvers/DataValidationRulesResolver.php index ec6348f20..a8fb42e3a 100644 --- a/src/Resolvers/DataValidationRulesResolver.php +++ b/src/Resolvers/DataValidationRulesResolver.php @@ -35,9 +35,12 @@ public function execute( ): array { $dataClass = $this->dataConfig->getDataClass($class); - if ($propertyMorphableClass = $this->propertyMorphableClass($dataClass)) { + if ($this->isPropertyMorphable($dataClass)) { + /** + * @var class-string $class + */ $payload = $path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), []); - $class = $propertyMorphableClass::morph($payload) ?? $class; + $class = $class::morph($payload) ?? $class; $dataClass = $this->dataConfig->getDataClass($class); } @@ -286,13 +289,8 @@ protected function inferRulesForDataProperty( ); } - /** - * @return class-string - */ - protected function propertyMorphableClass(DataClass $dataClass): ?string + protected function isPropertyMorphable(DataClass $dataClass): bool { - return $dataClass->isAbstract && $dataClass->propertyMorphable - ? $dataClass->name - : null; + return $dataClass->isAbstract && $dataClass->propertyMorphable; } } From cc76f2080bde5f236eb4cb16d5c30daed10702cf Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Tue, 28 Jan 2025 19:38:42 +1000 Subject: [PATCH 10/10] Inline fake classes and implement suggested fix for validation rules resolver --- src/Resolvers/DataValidationRulesResolver.php | 8 +- tests/CreationTest.php | 172 ++++++++++------- .../DummyModelWithPropertyMorphableCast.php | 19 -- .../AbstractPropertyMorphableData.php | 25 --- .../NestedPropertyMorphableData.php | 15 -- .../PropertyMorphableDataA.php | 15 -- .../PropertyMorphableDataB.php | 12 -- .../DataCollectionEloquentCastTest.php | 64 +++++-- .../EloquentCasts/DataEloquentCastTest.php | 48 ++++- tests/ValidationTest.php | 178 +++++++++++------- 10 files changed, 316 insertions(+), 240 deletions(-) delete mode 100644 tests/Fakes/Models/DummyModelWithPropertyMorphableCast.php delete mode 100644 tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php delete mode 100644 tests/Fakes/PropertyMorphableData/NestedPropertyMorphableData.php delete mode 100644 tests/Fakes/PropertyMorphableData/PropertyMorphableDataA.php delete mode 100644 tests/Fakes/PropertyMorphableData/PropertyMorphableDataB.php diff --git a/src/Resolvers/DataValidationRulesResolver.php b/src/Resolvers/DataValidationRulesResolver.php index a8fb42e3a..3b5935d08 100644 --- a/src/Resolvers/DataValidationRulesResolver.php +++ b/src/Resolvers/DataValidationRulesResolver.php @@ -39,9 +39,11 @@ public function execute( /** * @var class-string $class */ - $payload = $path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), []); - $class = $class::morph($payload) ?? $class; - $dataClass = $this->dataConfig->getDataClass($class); + $morphedClass = $class::morph( + $path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), []) + ); + + $dataClass = $this->dataConfig->getDataClass($morphedClass ?? $class); } $withoutValidationProperties = []; diff --git a/tests/CreationTest.php b/tests/CreationTest.php index 850c93b65..5e4398434 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -22,6 +22,7 @@ use Spatie\LaravelData\Casts\Uncastable; use Spatie\LaravelData\Concerns\WithDeprecatedCollectionMethod; use Spatie\LaravelData\Contracts\DeprecatedData as DeprecatedDataContract; +use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataPipeline; @@ -53,10 +54,6 @@ use Spatie\LaravelData\Tests\Fakes\NestedLazyData; use Spatie\LaravelData\Tests\Fakes\NestedModelCollectionData; use Spatie\LaravelData\Tests\Fakes\NestedModelData; -use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\AbstractPropertyMorphableData; -use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\NestedPropertyMorphableData; -use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataA; -use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\PropertyMorphableDataB; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithoutConstructor; @@ -1232,78 +1229,121 @@ public static function pipeline(): DataPipeline ); })->todo(); -it('will allow property-morphable data to be created', function () { - $dataA = AbstractPropertyMorphableData::from([ - 'variant' => 'a', - 'a' => 'foo', - 'enum' => 'foo', - ]); +describe('property-morphable creation tests', function () { + abstract class TestAbstractPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct(public string $variant) + { + } - expect($dataA) - ->toBeInstanceOf(PropertyMorphableDataA::class) - ->variant->toEqual('a') - ->a->toEqual('foo') - ->enum->toEqual(DummyBackedEnum::FOO); + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + 'a' => TestPropertyMorphableDataA::class, + 'b' => TestPropertyMorphableDataB::class, + default => null, + }; + } + } - $dataB = AbstractPropertyMorphableData::from([ - 'variant' => 'b', - 'b' => 'bar', - ]); + class TestPropertyMorphableDataA extends TestAbstractPropertyMorphableData + { + public function __construct(public string $a, public DummyBackedEnum $enum) + { + parent::__construct('a'); + } + } - expect($dataB) - ->toBeInstanceOf(PropertyMorphableDataB::class) - ->variant->toEqual('b') - ->b->toEqual('bar'); -}); + class TestPropertyMorphableDataB extends TestAbstractPropertyMorphableData + { + public function __construct(public string $b) + { + parent::__construct('b'); + } + } -it('will allow property-morphable data to be created from concrete', function () { - $dataA = PropertyMorphableDataA::from([ - 'a' => 'foo', - 'enum' => 'foo', - ]); + it('will allow property-morphable data to be created', function () { + $dataA = TestAbstractPropertyMorphableData::from([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'foo', + ]); - expect($dataA) - ->toBeInstanceOf(PropertyMorphableDataA::class) - ->variant->toEqual('a') - ->a->toEqual('foo') - ->enum->toEqual(DummyBackedEnum::FOO); -}); + expect($dataA) + ->toBeInstanceOf(TestPropertyMorphableDataA::class) + ->variant->toEqual('a') + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); -it('will allow property-morphable data to be created from a nested collection', function () { - $data = NestedPropertyMorphableData::from([ - 'nestedCollection' => [ - ['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], - ['variant' => 'b', 'b' => 'bar'], - ], - ]); + $dataB = TestAbstractPropertyMorphableData::from([ + 'variant' => 'b', + 'b' => 'bar', + ]); - expect($data->nestedCollection[0]) - ->toBeInstanceOf(PropertyMorphableDataA::class) - ->variant->toEqual('a') - ->a->toEqual('foo') - ->enum->toEqual(DummyBackedEnum::FOO); + expect($dataB) + ->toBeInstanceOf(TestPropertyMorphableDataB::class) + ->variant->toEqual('b') + ->b->toEqual('bar'); + }); - expect($data->nestedCollection[1]) - ->toBeInstanceOf(PropertyMorphableDataB::class) - ->variant->toEqual('b') - ->b->toEqual('bar'); -}); + it('will allow property-morphable data to be created from concrete', function () { + $dataA = TestPropertyMorphableDataA::from([ + 'a' => 'foo', + 'enum' => 'foo', + ]); + expect($dataA) + ->toBeInstanceOf(TestPropertyMorphableDataA::class) + ->variant->toEqual('a') + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); + }); -it('will allow property-morphable data to be created as a collection', function () { - $collection = AbstractPropertyMorphableData::collect([ - ['variant' => 'a', 'a' => 'foo', 'enum' => DummyBackedEnum::FOO->value], - ['variant' => 'b', 'b' => 'bar'], - ]); + it('will allow property-morphable data to be created from a nested collection', function () { + class NestedPropertyMorphableData extends Data + { + public function __construct( + /** @var TestAbstractPropertyMorphableData[] */ + public ?DataCollection $nestedCollection, + ) { + } + } + + $data = NestedPropertyMorphableData::from([ + 'nestedCollection' => [ + ['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], + ['variant' => 'b', 'b' => 'bar'], + ], + ]); - expect($collection[0]) - ->toBeInstanceOf(PropertyMorphableDataA::class) - ->variant->toEqual('a') - ->a->toEqual('foo') - ->enum->toEqual(DummyBackedEnum::FOO); + expect($data->nestedCollection[0]) + ->toBeInstanceOf(TestPropertyMorphableDataA::class) + ->variant->toEqual('a') + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); + + expect($data->nestedCollection[1]) + ->toBeInstanceOf(TestPropertyMorphableDataB::class) + ->variant->toEqual('b') + ->b->toEqual('bar'); + }); + + + it('will allow property-morphable data to be created as a collection', function () { + $collection = TestAbstractPropertyMorphableData::collect([ + ['variant' => 'a', 'a' => 'foo', 'enum' => DummyBackedEnum::FOO->value], + ['variant' => 'b', 'b' => 'bar'], + ]); - expect($collection[1]) - ->toBeInstanceOf(PropertyMorphableDataB::class) - ->variant->toEqual('b') - ->b->toEqual('bar'); + expect($collection[0]) + ->toBeInstanceOf(TestPropertyMorphableDataA::class) + ->variant->toEqual('a') + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); + + expect($collection[1]) + ->toBeInstanceOf(TestPropertyMorphableDataB::class) + ->variant->toEqual('b') + ->b->toEqual('bar'); + }); }); diff --git a/tests/Fakes/Models/DummyModelWithPropertyMorphableCast.php b/tests/Fakes/Models/DummyModelWithPropertyMorphableCast.php deleted file mode 100644 index ce1bfe6fe..000000000 --- a/tests/Fakes/Models/DummyModelWithPropertyMorphableCast.php +++ /dev/null @@ -1,19 +0,0 @@ - AbstractPropertyMorphableData::class, - 'data_collection' => SimpleDataCollection::class.':'.AbstractPropertyMorphableData::class, - ]; - - protected $table = 'dummy_model_with_casts'; - - public $timestamps = false; -} diff --git a/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php b/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php deleted file mode 100644 index 60240c243..000000000 --- a/tests/Fakes/PropertyMorphableData/AbstractPropertyMorphableData.php +++ /dev/null @@ -1,25 +0,0 @@ - PropertyMorphableDataA::class, - 'b' => PropertyMorphableDataB::class, - default => null, - }; - } -} diff --git a/tests/Fakes/PropertyMorphableData/NestedPropertyMorphableData.php b/tests/Fakes/PropertyMorphableData/NestedPropertyMorphableData.php deleted file mode 100644 index a03d0aac6..000000000 --- a/tests/Fakes/PropertyMorphableData/NestedPropertyMorphableData.php +++ /dev/null @@ -1,15 +0,0 @@ - TestCollectionCastPropertyMorphableDataA::class, + 'b' => TestCollectionCastPropertyMorphableDataB::class, + default => null, + }; + } + } + + class TestCollectionCastPropertyMorphableDataA extends TestCollectionCastAbstractPropertyMorphableData + { + public function __construct(public string $a, public DummyBackedEnum $enum) + { + parent::__construct('a'); + } + } + + class TestCollectionCastPropertyMorphableDataB extends TestCollectionCastAbstractPropertyMorphableData + { + public function __construct(public string $b) + { + parent::__construct('b'); + } + } + + $modelClass = new class () extends Model { + protected $casts = [ + 'data_collection' => SimpleDataCollection::class.':'.TestCollectionCastAbstractPropertyMorphableData::class, + ]; + + protected $table = 'dummy_model_with_casts'; + + public $timestamps = false; + }; + + $abstractA = new TestCollectionCastPropertyMorphableDataA('foo', DummyBackedEnum::FOO); + $abstractB = new TestCollectionCastPropertyMorphableDataB('bar'); + + $modelId = $modelClass::create([ 'data_collection' => [$abstractA, $abstractB], ])->id; - assertDatabaseHas(DummyModelWithPropertyMorphableCast::class, [ + assertDatabaseHas($modelClass::class, [ 'data_collection' => json_encode([ ['a' => 'foo', 'enum' => 'foo', 'variant' => 'a'], ['b' => 'bar', 'variant' => 'b'], ], JSON_PRETTY_PRINT), ]); - $model = DummyModelWithPropertyMorphableCast::find($modelId); + $model = $modelClass::find($modelId); expect($model->data_collection[0]) - ->toBeInstanceOf(PropertyMorphableDataA::class) + ->toBeInstanceOf(TestCollectionCastPropertyMorphableDataA::class) ->a->toBe('foo') ->enum->toBe(DummyBackedEnum::FOO); expect($model->data_collection[1]) - ->toBeInstanceOf(PropertyMorphableDataB::class) + ->toBeInstanceOf(TestCollectionCastPropertyMorphableDataB::class) ->b->toBe('bar'); }); diff --git a/tests/Support/EloquentCasts/DataEloquentCastTest.php b/tests/Support/EloquentCasts/DataEloquentCastTest.php index 078c77c95..5a091d000 100644 --- a/tests/Support/EloquentCasts/DataEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataEloquentCastTest.php @@ -1,11 +1,14 @@ TestCastPropertyMorphableDataA::class, + default => null, + }; + } + } + + class TestCastPropertyMorphableDataA extends TestCastAbstractPropertyMorphableData + { + public function __construct(public string $a, public DummyBackedEnum $enum) + { + parent::__construct('a'); + } + } + + $modelClass = new class () extends Model { + protected $casts = [ + 'data' => TestCastAbstractPropertyMorphableData::class, + ]; + + protected $table = 'dummy_model_with_casts'; + + public $timestamps = false; + }; + + $abstractA = new TestCastPropertyMorphableDataA('foo', DummyBackedEnum::FOO); - $modelId = DummyModelWithPropertyMorphableCast::create([ + $modelId = $modelClass::create([ 'data' => $abstractA, ])->id; - assertDatabaseHas(DummyModelWithPropertyMorphableCast::class, [ + assertDatabaseHas($modelClass::class, [ 'data' => json_encode(['a' => 'foo', 'enum' => 'foo', 'variant' => 'a']), ]); - $model = DummyModelWithPropertyMorphableCast::find($modelId); + $model = $modelClass::find($modelId); expect($model->data) - ->toBeInstanceOf(PropertyMorphableDataA::class) + ->toBeInstanceOf(TestCastPropertyMorphableDataA::class) ->a->toBe('foo') ->enum->toBe(DummyBackedEnum::FOO); }); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index e3dbfe541..e71846a6d 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -38,6 +38,7 @@ use Spatie\LaravelData\Attributes\Validation\StringType; use Spatie\LaravelData\Attributes\Validation\Unique; use Spatie\LaravelData\Attributes\WithoutValidation; +use Spatie\LaravelData\Contracts\PropertyMorphableData; use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\Mappers\SnakeCaseMapper; @@ -55,8 +56,6 @@ use Spatie\LaravelData\Tests\Fakes\MultiData; use Spatie\LaravelData\Tests\Fakes\NestedData; use Spatie\LaravelData\Tests\Fakes\NestedNullableData; -use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\AbstractPropertyMorphableData; -use Spatie\LaravelData\Tests\Fakes\PropertyMorphableData\NestedPropertyMorphableData; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithExplicitValidationRuleAttributeData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithOverwrittenRules; @@ -2531,70 +2530,115 @@ public function __construct( ]); }); -it('can validate property-morphable data', function () { - DataValidationAsserter::for(AbstractPropertyMorphableData::class) - ->assertErrors([], [ - 'variant' => ['The variant field is required.'], - ]) - ->assertErrors([ - 'variant' => 'c', - ], [ - 'variant' => ['The selected variant is invalid.'], - ]) - ->assertErrors([ - 'variant' => 'a', - ], [ - 'a' => ['The a field is required.'], - 'enum' => ['The enum field is required.'], - ]) - ->assertErrors([ - 'variant' => 'a', - 'a' => 'foo', - 'enum' => 'invalid', - ], [ - 'enum' => ['The selected enum is invalid.'], - ]) - ->assertErrors([ - 'variant' => 'b', - ], [ - 'b' => ['The b field is required.'], - ]) - ->assertOk([ - 'variant' => 'a', - 'a' => 'foo', - 'enum' => 'foo', - ]) - ->assertOk([ - 'variant' => 'b', - 'b' => 'foo', - ]); -}); +describe('property-morphable validation tests', function () { + abstract class TestValidationAbstractPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct( + #[In('a', 'b')] + public string $variant, + ) { + } -it('can validate nested property-morphable data', function () { - DataValidationAsserter::for(NestedPropertyMorphableData::class) - ->assertErrors([ - 'nestedCollection' => [[]], - ], [ - 'nestedCollection.0.variant' => ['The nested collection.0.variant field is required.'], - ]) - ->assertErrors([ - 'nestedCollection' => [['variant' => 'c']], - ], [ - 'nestedCollection.0.variant' => ['The selected nested collection.0.variant is invalid.'], - ]) - ->assertErrors([ - 'nestedCollection' => [['variant' => 'a'], ['variant' => 'b']], - ], [ - 'nestedCollection.0.a' => ['The nested collection.0.a field is required.'], - 'nestedCollection.0.enum' => ['The nested collection.0.enum field is required.'], - 'nestedCollection.1.b' => ['The nested collection.1.b field is required.'], - ]) - ->assertErrors([ - 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'invalid']], - ], [ - 'nestedCollection.0.enum' => ['The selected nested collection.0.enum is invalid.'], - ]) - ->assertOk([ - 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], ['variant' => 'b', 'b' => 'bar']], - ]); + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + 'a' => TestValidationPropertyMorphableDataA::class, + 'b' => TestValidationPropertyMorphableDataB::class, + default => null, + }; + } + } + + class TestValidationPropertyMorphableDataA extends TestValidationAbstractPropertyMorphableData + { + public function __construct(public string $a, public DummyBackedEnum $enum) + { + parent::__construct('a'); + } + } + + class TestValidationPropertyMorphableDataB extends TestValidationAbstractPropertyMorphableData + { + public function __construct(public string $b) + { + parent::__construct('b'); + } + } + + it('can validate property-morphable data', function () { + DataValidationAsserter::for(TestValidationAbstractPropertyMorphableData::class) + ->assertErrors([], [ + 'variant' => ['The variant field is required.'], + ]) + ->assertErrors([ + 'variant' => 'c', + ], [ + 'variant' => ['The selected variant is invalid.'], + ]) + ->assertErrors([ + 'variant' => 'a', + ], [ + 'a' => ['The a field is required.'], + 'enum' => ['The enum field is required.'], + ]) + ->assertErrors([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'invalid', + ], [ + 'enum' => ['The selected enum is invalid.'], + ]) + ->assertErrors([ + 'variant' => 'b', + ], [ + 'b' => ['The b field is required.'], + ]) + ->assertOk([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'foo', + ]) + ->assertOk([ + 'variant' => 'b', + 'b' => 'foo', + ]); + }); + + it('can validate nested property-morphable data', function () { + class TestValidationNestedPropertyMorphableData extends Data + { + public function __construct( + /** @var TestValidationAbstractPropertyMorphableData[] */ + public ?DataCollection $nestedCollection, + ) { + } + }; + + DataValidationAsserter::for(TestValidationNestedPropertyMorphableData::class) + ->assertErrors([ + 'nestedCollection' => [[]], + ], [ + 'nestedCollection.0.variant' => ['The nested collection.0.variant field is required.'], + ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'c']], + ], [ + 'nestedCollection.0.variant' => ['The selected nested collection.0.variant is invalid.'], + ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'a'], ['variant' => 'b']], + ], [ + 'nestedCollection.0.a' => ['The nested collection.0.a field is required.'], + 'nestedCollection.0.enum' => ['The nested collection.0.enum field is required.'], + 'nestedCollection.1.b' => ['The nested collection.1.b field is required.'], + ]) + ->assertErrors([ + 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'invalid']], + ], [ + 'nestedCollection.0.enum' => ['The selected nested collection.0.enum is invalid.'], + ]) + ->assertOk([ + 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], ['variant' => 'b', 'b' => 'bar']], + ]); + }); });