Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add abstract eloquent casts #526

Merged
merged 4 commits into from
Aug 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading