diff --git a/src/Contracts/PropertyMorphableData.php b/src/Contracts/PropertyMorphableData.php new file mode 100644 index 000000000..7a71523a0 --- /dev/null +++ b/src/Contracts/PropertyMorphableData.php @@ -0,0 +1,8 @@ +dataFromArrayResolver->execute( - $class, - $pipeline->execute($payloads[0] ?? [], $creationContext) - ); + $properties = $pipeline->execute($payloads[0] ?? [], $creationContext); + + return $this->dataFromArray($class, $creationContext, $payloads, $properties); } $properties = []; @@ -57,7 +57,7 @@ public function execute( } } - return $this->dataFromArrayResolver->execute($class, $properties); + return $this->dataFromArray($class, $creationContext, $payloads, $properties); } protected function createFromCustomCreationMethod( @@ -117,4 +117,24 @@ protected function createFromCustomCreationMethod( return $class::$methodName(...$payloads); } + + protected function dataFromArray( + 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 $this->dataFromArrayResolver->execute($class, $properties); + } } diff --git a/src/Resolvers/DataValidationRulesResolver.php b/src/Resolvers/DataValidationRulesResolver.php index 30a64c13c..4cb37defe 100644 --- a/src/Resolvers/DataValidationRulesResolver.php +++ b/src/Resolvers/DataValidationRulesResolver.php @@ -8,6 +8,7 @@ use Spatie\LaravelData\Attributes\MergeValidationRules; 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; @@ -35,6 +36,17 @@ public function execute( ): array { $dataClass = $this->dataConfig->getDataClass($class); + if ($this->isPropertyMorphable($dataClass)) { + /** + * @var class-string $class + */ + $morphedClass = $class::morph( + $path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), []) + ); + + $dataClass = $this->dataConfig->getDataClass($morphedClass ?? $class); + } + $withoutValidationProperties = []; foreach ($dataClass->properties as $dataProperty) { @@ -283,4 +295,9 @@ protected function inferRulesForDataProperty( $path ); } + + protected function isPropertyMorphable(DataClass $dataClass): bool + { + return $dataClass->isAbstract && $dataClass->propertyMorphable; + } } 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..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) { + $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; } } diff --git a/src/Support/EloquentCasts/DataEloquentCast.php b/src/Support/EloquentCasts/DataEloquentCast.php index 551587d70..d21b4bdc4 100644 --- a/src/Support/EloquentCasts/DataEloquentCast.php +++ b/src/Support/EloquentCasts/DataEloquentCast.php @@ -84,6 +84,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 4aa89f984..0c145faa3 100644 --- a/src/Support/Factories/DataClassFactory.php +++ b/src/Support/Factories/DataClassFactory.php @@ -12,6 +12,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; @@ -87,6 +88,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, diff --git a/tests/CreationTest.php b/tests/CreationTest.php index 2890268f1..df616f907 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -27,6 +27,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; @@ -1452,3 +1453,122 @@ class TestAutoLazyClassAttributeData extends Data ->toHaveCount(2) ->each()->toBeInstanceOf(FakeNestedModelData::class); }); + +describe('property-morphable creation tests', function () { + abstract class TestAbstractPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct(public string $variant) + { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + 'a' => TestPropertyMorphableDataA::class, + 'b' => TestPropertyMorphableDataB::class, + default => null, + }; + } + } + + class TestPropertyMorphableDataA extends TestAbstractPropertyMorphableData + { + public function __construct(public string $a, public DummyBackedEnum $enum) + { + parent::__construct('a'); + } + } + + class TestPropertyMorphableDataB extends TestAbstractPropertyMorphableData + { + public function __construct(public string $b) + { + parent::__construct('b'); + } + } + + it('will allow property-morphable data to be created', function () { + $dataA = TestAbstractPropertyMorphableData::from([ + 'variant' => 'a', + 'a' => 'foo', + 'enum' => 'foo', + ]); + + expect($dataA) + ->toBeInstanceOf(TestPropertyMorphableDataA::class) + ->variant->toEqual('a') + ->a->toEqual('foo') + ->enum->toEqual(DummyBackedEnum::FOO); + + $dataB = TestAbstractPropertyMorphableData::from([ + 'variant' => 'b', + 'b' => 'bar', + ]); + + expect($dataB) + ->toBeInstanceOf(TestPropertyMorphableDataB::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 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($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[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/Support/EloquentCasts/DataCollectionEloquentCastTest.php b/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php index 97efc5d10..2ae13bd0c 100644 --- a/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php @@ -1,13 +1,17 @@ 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 () { + abstract class TestCollectionCastAbstractPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct(public string $variant) + { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + 'a' => 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($modelClass::class, [ + 'data_collection' => json_encode([ + ['a' => 'foo', 'enum' => 'foo', 'variant' => 'a'], + ['b' => 'bar', 'variant' => 'b'], + ], JSON_PRETTY_PRINT), + ]); + + $model = $modelClass::find($modelId); + + expect($model->data_collection[0]) + ->toBeInstanceOf(TestCollectionCastPropertyMorphableDataA::class) + ->a->toBe('foo') + ->enum->toBe(DummyBackedEnum::FOO); + + expect($model->data_collection[1]) + ->toBeInstanceOf(TestCollectionCastPropertyMorphableDataB::class) + ->b->toBe('bar'); +}); diff --git a/tests/Support/EloquentCasts/DataEloquentCastTest.php b/tests/Support/EloquentCasts/DataEloquentCastTest.php index 4b2915120..5a091d000 100644 --- a/tests/Support/EloquentCasts/DataEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataEloquentCastTest.php @@ -1,14 +1,18 @@ toBeTrue(); }); + +it('can load and save an abstract property-morphable data object', function () { + abstract class TestCastAbstractPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct(public string $variant) + { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + 'a' => 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 = $modelClass::create([ + 'data' => $abstractA, + ])->id; + + assertDatabaseHas($modelClass::class, [ + 'data' => json_encode(['a' => 'foo', 'enum' => 'foo', 'variant' => 'a']), + ]); + + $model = $modelClass::find($modelId); + + expect($model->data) + ->toBeInstanceOf(TestCastPropertyMorphableDataA::class) + ->a->toBe('foo') + ->enum->toBe(DummyBackedEnum::FOO); +}); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 08dcd0b3c..84741c068 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -39,6 +39,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; @@ -2605,3 +2606,116 @@ public static function rules(): array 'nested.string' => [__('validation.required', ['attribute' => 'nested.string'])], ]); }); + +describe('property-morphable validation tests', function () { + abstract class TestValidationAbstractPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct( + #[In('a', 'b')] + public string $variant, + ) { + } + + 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']], + ]); + }); +});