Skip to content

Commit

Permalink
Merge pull request #526 from spatie/abstract-eloquent-casts
Browse files Browse the repository at this point in the history
Add abstract eloquent casts
  • Loading branch information
rubenvanassche authored Aug 9, 2023
2 parents 65e5394 + fe60b9c commit 0de2ecf
Show file tree
Hide file tree
Showing 12 changed files with 282 additions and 4 deletions.
88 changes: 88 additions & 0 deletions docs/advanced-usage/eloquent-casting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 VinylRecordConfig 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 VinylRecordConfig(tracks: 12, rpm: 33),
]);

$cdRecord->config; // CdRecordConfig 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.

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,
]);
```

## Casting data collections

It is also possible to store data collections in an Eloquent model:
Expand Down
8 changes: 8 additions & 0 deletions src/Concerns/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/Contracts/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 2 additions & 0 deletions src/Support/DataClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
55 changes: 55 additions & 0 deletions src/Support/DataClassMorphMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Spatie\LaravelData\Support;

use Spatie\LaravelData\Contracts\BaseData;

class DataClassMorphMap
{
/** @var array<string, class-string<BaseData>> */
protected array $map = [];

/** @var array< class-string<BaseData>, string> */
protected array $reversedMap = [];


/**
* @param string $alias
* @param class-string<BaseData> $class
*/
public function add(
string $alias,
string $class
): self {
$this->map[$alias] = $class;
$this->reversedMap[$class] = $alias;

return $this;
}

/**
* @param array<string, class-string<BaseData>> $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<BaseData> $class
*/
public function getDataClassAlias(string $class): ?string
{
return $this->reversedMap[$class] ?? null;
}
}
15 changes: 14 additions & 1 deletion src/Support/DataConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use ReflectionClass;
use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Contracts\BaseData;
use Spatie\LaravelData\Transformers\Transformer;

class DataConfig
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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];
}

Expand Down Expand Up @@ -94,6 +99,14 @@ public function getRuleInferrers(): array
return $this->ruleInferrers;
}

/**
* @param array<string, class-string<BaseData>> $map
*/
public function enforceMorphMap(array $map): void
{
$this->morphMap->merge($map);
}

public function reset(): self
{
$this->dataClasses = [];
Expand Down
27 changes: 26 additions & 1 deletion src/Support/EloquentCasts/DataEloquentCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@
use Spatie\LaravelData\Contracts\BaseData;
use Spatie\LaravelData\Contracts\TransformableData;
use Spatie\LaravelData\Exceptions\CannotCastData;
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
Expand All @@ -29,6 +33,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<BaseData> $dataClass */
$dataClass = $this->dataConfig->morphMap->getMorphedDataClass($payload['type']) ?? $payload['type'];

return $dataClass::from($payload['data']);
}

return ($this->dataClass)::from($payload);
}

Expand All @@ -38,7 +49,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);
}

Expand All @@ -50,6 +63,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;
}
}
9 changes: 9 additions & 0 deletions tests/Fakes/AbstractData/AbstractData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Spatie\LaravelData\Tests\Fakes\AbstractData;

use Spatie\LaravelData\Data;

abstract class AbstractData extends Data
{
}
11 changes: 11 additions & 0 deletions tests/Fakes/AbstractData/AbstractDataA.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Spatie\LaravelData\Tests\Fakes\AbstractData;

class AbstractDataA extends AbstractData
{
public function __construct(
public string $a,
) {
}
}
11 changes: 11 additions & 0 deletions tests/Fakes/AbstractData/AbstractDataB.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Spatie\LaravelData\Tests\Fakes\AbstractData;

class AbstractDataB extends AbstractData
{
public function __construct(
public string $b,
) {
}
}
3 changes: 3 additions & 0 deletions tests/Fakes/Models/DummyModelWithCasts.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractData;
use Spatie\LaravelData\Tests\Fakes\SimpleData;

class DummyModelWithCasts extends Model
{
protected $casts = [
'data' => SimpleData::class,
'data_collection' => DataCollection::class.':'.SimpleData::class,
'abstract_data' => AbstractData::class,
];

public $timestamps = false;
Expand All @@ -24,6 +26,7 @@ public static function migrate()

$blueprint->text('data')->nullable();
$blueprint->text('data_collection')->nullable();
$blueprint->text('abstract_data')->nullable();
});
}
}
Loading

0 comments on commit 0de2ecf

Please sign in to comment.