From 8169294fbdbefa5fb29530f15525f2ff5ae84782 Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Tue, 8 Aug 2023 13:59:04 +0200 Subject: [PATCH 1/4] Add abstract eloquent casts --- docs/advanced-usage/eloquent-casting.md | 88 +++++++++++++++++++ src/Concerns/BaseData.php | 8 ++ src/Contracts/BaseData.php | 2 + src/Support/DataClass.php | 2 + src/Support/DataClassMorphMap.php | 56 ++++++++++++ src/Support/DataConfig.php | 15 +++- .../EloquentCasts/DataEloquentCast.php | 28 +++++- tests/Fakes/AbstractData/AbstractData.php | 10 +++ tests/Fakes/AbstractData/AbstractDataA.php | 11 +++ tests/Fakes/AbstractData/AbstractDataB.php | 11 +++ tests/Fakes/Models/DummyModelWithCasts.php | 3 + .../EloquentCasts/DataEloquentCastTest.php | 59 +++++++++++-- 12 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 src/Support/DataClassMorphMap.php create mode 100644 tests/Fakes/AbstractData/AbstractData.php create mode 100644 tests/Fakes/AbstractData/AbstractDataA.php create mode 100644 tests/Fakes/AbstractData/AbstractDataB.php diff --git a/docs/advanced-usage/eloquent-casting.md b/docs/advanced-usage/eloquent-casting.md index 09f89f61..55d8d824 100644 --- a/docs/advanced-usage/eloquent-casting.md +++ b/docs/advanced-usage/eloquent-casting.md @@ -40,6 +40,94 @@ This will internally be converted to a data object which you can later retrieve Song::findOrFail($id)->artist; // ArtistData object ``` +### Abstract data objects + +Sometimes you have an abstract parent data object with multiple child data objects, for example: + +```php +abstract class RecordConfig extends Data +{ + public function __construct( + public int $tracks, + ) {} +} + +class CdRecordConfig extends RecordConfig +{ + public function __construct( + int $tracks + public int $bytes, + ) { + parent::__construct($tracks); + } +} + +class VinylRecord extends RecordConfig +{ + public function __construct( + int $tracks + public int $rpm, + ) { + parent::__construct($tracks); + } +} +``` + +A model can have a JSON field which is either one of these data objects: + +```php +class Record extends Model +{ + protected $casts = [ + 'config' => RecordConfig::class, + ]; +} +``` + +You can then store either a `CdRecordConfig` or a `VinylRecord` in the `config` field: + +```php +$cdRecord = Record::create([ + 'config' => new CdRecordConfig(tracks: 12, bytes: 1000), +]); + +$vinylRecord = Record::create([ + 'config' => new VinylRecord(tracks: 12, rpm: 33), +]); + +$cdRecord->config; // CdRecordConfig object +$vinylRecord->config; // VinylRecord object +``` + +When a data object class is abstract and used as an Eloquent cast then this feature will work out of the box. + +The child data object value of the model will be stored in the database as a JSON string with the class name as the key: + +```json +{ + "type": "\\App\\Data\\CdRecordConfig", + "value": { + "tracks": 12, + "bytes": 1000 + } +} +``` + +When retrieving the model, the data object will be instantiated based on the `type` key in the JSON string. + +#### Abstract data class morphs + +By default, the `type` key in the JSON string will be the fully qualified class name of the child data object. This can break your application quite easily when you refactor your code. To prevent this, you can add a morph map like with [Eloquent models](https://laravel.com/docs/10.x/eloquent-relationships#polymorphic-relationships). Within your `AppServiceProvivder` you can add the following mapping: + +```php +use Spatie\LaravelData\Support\DataConfig; + +app(DataConfig::class)->enforceMorphMap([ + 'cd_record_config' => CdRecordConfig::class, + 'vinyl_record_config' => VinylRecordConfig::class, +]); +```php + ## Casting data collections It is also possible to store data collections in an Eloquent model: diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 7fdefb0e..5892096d 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -106,6 +106,14 @@ public function transform( return DataTransformer::create($transformValues, $wrapExecutionType, $mapPropertyNames)->transform($this); } + public function getMorphClass(): string + { + /** @var class-string<\Spatie\LaravelData\Contracts\BaseData> $class */ + $class = static::class; + + return app(DataConfig::class)->morphMap->getDataClassAlias($class) ?? $class; + } + public function __sleep(): array { return app(DataConfig::class)->getDataClass(static::class) diff --git a/src/Contracts/BaseData.php b/src/Contracts/BaseData.php index ad01fd33..cfff47d8 100644 --- a/src/Contracts/BaseData.php +++ b/src/Contracts/BaseData.php @@ -35,4 +35,6 @@ public static function normalizers(): array; public static function pipeline(): DataPipeline; public static function empty(array $extra = []): array; + + public function getMorphClass(): string; } diff --git a/src/Support/DataClass.php b/src/Support/DataClass.php index d0f11915..7d81f71f 100644 --- a/src/Support/DataClass.php +++ b/src/Support/DataClass.php @@ -35,6 +35,7 @@ public function __construct( public readonly Collection $methods, public readonly ?DataMethod $constructorMethod, public readonly bool $isReadonly, + public readonly bool $isAbstract, public readonly bool $appendable, public readonly bool $includeable, public readonly bool $responsable, @@ -68,6 +69,7 @@ public static function create(ReflectionClass $class): self methods: self::resolveMethods($class), constructorMethod: DataMethod::createConstructor($constructor, $properties), isReadonly: method_exists($class, 'isReadOnly') && $class->isReadOnly(), + isAbstract: $class->isAbstract(), appendable: $class->implementsInterface(AppendableData::class), includeable: $class->implementsInterface(IncludeableData::class), responsable: $class->implementsInterface(ResponsableData::class), diff --git a/src/Support/DataClassMorphMap.php b/src/Support/DataClassMorphMap.php new file mode 100644 index 00000000..5f5e9b3f --- /dev/null +++ b/src/Support/DataClassMorphMap.php @@ -0,0 +1,56 @@ +> */ + protected array $map = []; + + /** @var array< class-string, string> */ + protected array $reversedMap = []; + + + /** + * @param string $alias + * @param class-string $class + */ + public function add( + string $alias, + string $class + ): self { + $this->map[$alias] = $class; + $this->reversedMap[$class] = $alias; + + return $this; + } + + /** + * @param array> $map + */ + public function merge(array $map): self + { + foreach ($map as $alias => $class) { + $this->add($alias, $class); + } + + return $this; + } + + public function getMorphedDataClass(string $alias): ?string + { + return $this->map[$alias] ?? null; + } + + + /** + * @param class-string $class + */ + public function getDataClassAlias(string $class): ?string + { + return $this->reversedMap[$class] ?? null; + } +} diff --git a/src/Support/DataConfig.php b/src/Support/DataConfig.php index c9591b5e..6c73545b 100644 --- a/src/Support/DataConfig.php +++ b/src/Support/DataConfig.php @@ -4,6 +4,7 @@ use ReflectionClass; use Spatie\LaravelData\Casts\Cast; +use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Transformers\Transformer; class DataConfig @@ -23,6 +24,8 @@ class DataConfig /** @var \Spatie\LaravelData\RuleInferrers\RuleInferrer[] */ protected array $ruleInferrers; + public readonly DataClassMorphMap $morphMap; + public function __construct(array $config) { $this->ruleInferrers = array_map( @@ -37,6 +40,8 @@ public function __construct(array $config) foreach ($config['casts'] ?? [] as $castable => $cast) { $this->casts[ltrim($castable, ' \\')] = app($cast); } + + $this->morphMap = new DataClassMorphMap(); } public function getDataClass(string $class): DataClass @@ -50,7 +55,7 @@ public function getDataClass(string $class): DataClass public function getResolvedDataPipeline(string $class): ResolvedDataPipeline { - if (array_key_exists($class, $this->resolvedDataPipelines)) { + if (array_key_exists($class, $this->resolvedDataPipelines)) { return $this->resolvedDataPipelines[$class]; } @@ -94,6 +99,14 @@ public function getRuleInferrers(): array return $this->ruleInferrers; } + /** + * @param array> $map + */ + public function enforceMorphMap(array $map): void + { + $this->morphMap->merge($map); + } + public function reset(): self { $this->dataClasses = []; diff --git a/src/Support/EloquentCasts/DataEloquentCast.php b/src/Support/EloquentCasts/DataEloquentCast.php index 7d156833..62d11452 100644 --- a/src/Support/EloquentCasts/DataEloquentCast.php +++ b/src/Support/EloquentCasts/DataEloquentCast.php @@ -6,15 +6,20 @@ use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Contracts\TransformableData; use Spatie\LaravelData\Exceptions\CannotCastData; +use Spatie\LaravelData\Support\DataClass; +use Spatie\LaravelData\Support\DataConfig; class DataEloquentCast implements CastsAttributes { + protected DataConfig $dataConfig; + public function __construct( /** @var class-string<\Spatie\LaravelData\Contracts\BaseData> $dataClass */ protected string $dataClass, /** @var string[] $arguments */ protected array $arguments = [] ) { + $this->dataConfig = app(DataConfig::class); } public function get($model, string $key, $value, array $attributes): ?BaseData @@ -29,6 +34,13 @@ public function get($model, string $key, $value, array $attributes): ?BaseData $payload = json_decode($value, true, flags: JSON_THROW_ON_ERROR); + if ($this->isAbstractClassCast()) { + /** @var class-string $dataClass */ + $dataClass = $this->dataConfig->morphMap->getMorphedDataClass($payload['type']) ?? $payload['type']; + + return $dataClass::from($payload['data']); + } + return ($this->dataClass)::from($payload); } @@ -38,7 +50,9 @@ public function set($model, string $key, $value, array $attributes): ?string return null; } - if (is_array($value)) { + $isAbstractClassCast = $this->isAbstractClassCast(); + + if (is_array($value) && ! $isAbstractClassCast) { $value = ($this->dataClass)::from($value); } @@ -50,6 +64,18 @@ public function set($model, string $key, $value, array $attributes): ?string throw CannotCastData::shouldBeTransformableData($model::class, $key); } + if ($isAbstractClassCast) { + return json_encode([ + 'type' => $this->dataConfig->morphMap->getDataClassAlias($value::class) ?? $value::class, + 'data' => $value->toJson(), + ]); + } + return $value->toJson(); } + + protected function isAbstractClassCast(): bool + { + return $this->dataConfig->getDataClass($this->dataClass)->isAbstract; + } } diff --git a/tests/Fakes/AbstractData/AbstractData.php b/tests/Fakes/AbstractData/AbstractData.php new file mode 100644 index 00000000..2d8a0f06 --- /dev/null +++ b/tests/Fakes/AbstractData/AbstractData.php @@ -0,0 +1,10 @@ + SimpleData::class, 'data_collection' => DataCollection::class.':'.SimpleData::class, + 'abstract_data' => AbstractData::class, ]; public $timestamps = false; @@ -24,6 +26,7 @@ public static function migrate() $blueprint->text('data')->nullable(); $blueprint->text('data_collection')->nullable(); + $blueprint->text('abstract_data')->nullable(); }); } } diff --git a/tests/Support/EloquentCasts/DataEloquentCastTest.php b/tests/Support/EloquentCasts/DataEloquentCastTest.php index d9ebd0ce..49f65e48 100644 --- a/tests/Support/EloquentCasts/DataEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataEloquentCastTest.php @@ -1,15 +1,14 @@ toBeInstanceOf(SimpleDataWithDefaultValue::class) ->string->toEqual('default'); }); + +it('can use an abstract data class with multiple children', function () { + $abstractA = new AbstractDataA('A\A'); + $abstractB = new AbstractDataB('B\B'); + + $modelId = DummyModelWithCasts::create([ + 'abstract_data' => $abstractA, + ])->id; + + $model = DummyModelWithCasts::find($modelId); + + expect($model->abstract_data) + ->toBeInstanceOf(AbstractDataA::class) + ->a->toBe('A\A'); + + $model->abstract_data = $abstractB; + $model->save(); + + $model = DummyModelWithCasts::find($modelId); + + expect($model->abstract_data) + ->toBeInstanceOf(AbstractDataB::class) + ->b->toBe('B\B'); +}); + +it('can use an abstract data class with morph map', function (){ + app(DataConfig::class)->enforceMorphMap([ + 'a' => AbstractDataA::class, + ]); + + $abstractA = new AbstractDataA('A\A'); + $abstractB = new AbstractDataB('B\B'); + + $modelA = DummyModelWithCasts::create([ + 'abstract_data' => $abstractA, + ]); + + $modelB = DummyModelWithCasts::create([ + 'abstract_data' => $abstractB, + ]); + + expect(json_decode($modelA->getRawOriginal('abstract_data'))->type)->toBe('a'); + expect(json_decode($modelB->getRawOriginal('abstract_data'))->type)->toBe(AbstractDataB::class); + + $loadedMorphedModel = DummyModelWithCasts::find($modelA->id); + + expect($loadedMorphedModel->abstract_data) + ->toBeInstanceOf(AbstractDataA::class) + ->a->toBe('A\A'); +}); From 6346504dded8c93aa28ed5c499ae9902e00b7970 Mon Sep 17 00:00:00 2001 From: rubenvanassche Date: Tue, 8 Aug 2023 11:59:28 +0000 Subject: [PATCH 2/4] Fix styling --- src/Support/DataClassMorphMap.php | 1 - src/Support/EloquentCasts/DataEloquentCast.php | 1 - tests/Fakes/AbstractData/AbstractData.php | 1 - tests/Support/EloquentCasts/DataEloquentCastTest.php | 6 ++++-- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Support/DataClassMorphMap.php b/src/Support/DataClassMorphMap.php index 5f5e9b3f..d1e604a7 100644 --- a/src/Support/DataClassMorphMap.php +++ b/src/Support/DataClassMorphMap.php @@ -2,7 +2,6 @@ namespace Spatie\LaravelData\Support; - use Spatie\LaravelData\Contracts\BaseData; class DataClassMorphMap diff --git a/src/Support/EloquentCasts/DataEloquentCast.php b/src/Support/EloquentCasts/DataEloquentCast.php index 62d11452..4dc5bf17 100644 --- a/src/Support/EloquentCasts/DataEloquentCast.php +++ b/src/Support/EloquentCasts/DataEloquentCast.php @@ -6,7 +6,6 @@ use Spatie\LaravelData\Contracts\BaseData; use Spatie\LaravelData\Contracts\TransformableData; use Spatie\LaravelData\Exceptions\CannotCastData; -use Spatie\LaravelData\Support\DataClass; use Spatie\LaravelData\Support\DataConfig; class DataEloquentCast implements CastsAttributes diff --git a/tests/Fakes/AbstractData/AbstractData.php b/tests/Fakes/AbstractData/AbstractData.php index 2d8a0f06..292784bd 100644 --- a/tests/Fakes/AbstractData/AbstractData.php +++ b/tests/Fakes/AbstractData/AbstractData.php @@ -6,5 +6,4 @@ abstract class AbstractData extends Data { - } diff --git a/tests/Support/EloquentCasts/DataEloquentCastTest.php b/tests/Support/EloquentCasts/DataEloquentCastTest.php index 49f65e48..c28d4e2d 100644 --- a/tests/Support/EloquentCasts/DataEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataEloquentCastTest.php @@ -1,6 +1,9 @@ b->toBe('B\B'); }); -it('can use an abstract data class with morph map', function (){ +it('can use an abstract data class with morph map', function () { app(DataConfig::class)->enforceMorphMap([ 'a' => AbstractDataA::class, ]); From e896877a5893e5878ef2a19e15be736ab66c5c47 Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Tue, 8 Aug 2023 13:59:42 +0200 Subject: [PATCH 3/4] Fix docs --- docs/advanced-usage/eloquent-casting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced-usage/eloquent-casting.md b/docs/advanced-usage/eloquent-casting.md index 55d8d824..7073f428 100644 --- a/docs/advanced-usage/eloquent-casting.md +++ b/docs/advanced-usage/eloquent-casting.md @@ -126,7 +126,7 @@ app(DataConfig::class)->enforceMorphMap([ 'cd_record_config' => CdRecordConfig::class, 'vinyl_record_config' => VinylRecordConfig::class, ]); -```php +``` ## Casting data collections From fe60b9c95c24bd549aab0e4cf9a796e652c533c2 Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Tue, 8 Aug 2023 14:03:33 +0200 Subject: [PATCH 4/4] Update docs --- docs/advanced-usage/eloquent-casting.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/advanced-usage/eloquent-casting.md b/docs/advanced-usage/eloquent-casting.md index 7073f428..b779645e 100644 --- a/docs/advanced-usage/eloquent-casting.md +++ b/docs/advanced-usage/eloquent-casting.md @@ -62,7 +62,7 @@ class CdRecordConfig extends RecordConfig } } -class VinylRecord extends RecordConfig +class VinylRecordConfig extends RecordConfig { public function __construct( int $tracks @@ -92,11 +92,11 @@ $cdRecord = Record::create([ ]); $vinylRecord = Record::create([ - 'config' => new VinylRecord(tracks: 12, rpm: 33), + 'config' => new VinylRecordConfig(tracks: 12, rpm: 33), ]); $cdRecord->config; // CdRecordConfig object -$vinylRecord->config; // VinylRecord object +$vinylRecord->config; // VinylRecordConfig object ``` When a data object class is abstract and used as an Eloquent cast then this feature will work out of the box.