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

feat: support multiple transformers and excluding transformers from Eloquent casting #588

Closed
Closed
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
2 changes: 1 addition & 1 deletion src/Attributes/WithTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use Spatie\LaravelData\Exceptions\CannotCreateTransformerAttribute;
use Spatie\LaravelData\Transformers\Transformer;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
class WithTransformer
{
public array $arguments;
Expand Down
3 changes: 2 additions & 1 deletion src/Concerns/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ public function transform(
bool $transformValues = true,
WrapExecutionType $wrapExecutionType = WrapExecutionType::Disabled,
bool $mapPropertyNames = true,
bool $castingForEloquent = false,
): array {
return DataTransformer::create($transformValues, $wrapExecutionType, $mapPropertyNames)->transform($this);
return DataTransformer::create($transformValues, $wrapExecutionType, $mapPropertyNames, $castingForEloquent)->transform($this);
}

public function getMorphClass(): string
Expand Down
2 changes: 2 additions & 0 deletions src/Concerns/BaseDataCollectable.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ public function transform(
bool $transformValues = true,
WrapExecutionType $wrapExecutionType = WrapExecutionType::Disabled,
bool $mapPropertyNames = true,
bool $castingForEloquent = false,
): array {
$transformer = new DataCollectableTransformer(
$this->dataClass,
$transformValues,
$wrapExecutionType,
$mapPropertyNames,
$castingForEloquent,
$this->getPartialTrees(),
$this->items,
$this->getWrap(),
Expand Down
9 changes: 9 additions & 0 deletions src/Contracts/ExcludeFromEloquentCasts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Spatie\LaravelData\Contracts;

/** Marker interface for transformers that should not be applied to Eloquent casts. */
interface ExcludeFromEloquentCasts
{
//
}
1 change: 1 addition & 0 deletions src/Contracts/TransformableData.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public function transform(
bool $transformValues = true,
WrapExecutionType $wrapExecutionType = WrapExecutionType::Disabled,
bool $mapPropertyNames = true,
bool $castingForEloquent = false,
): array;

public static function castUsing(array $arguments);
Expand Down
27 changes: 17 additions & 10 deletions src/Support/DataConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

namespace Spatie\LaravelData\Support;

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

class DataConfig
{
Expand Down Expand Up @@ -33,9 +33,7 @@
$config['rule_inferrers'] ?? []
);

foreach ($config['transformers'] ?? [] as $transformable => $transformer) {
$this->transformers[ltrim($transformable, ' \\')] = app($transformer);
}
$this->setTransformers($config['transformers'] ?? []);

foreach ($config['casts'] ?? [] as $castable => $cast) {
$this->casts[ltrim($castable, ' \\')] = app($cast);
Expand All @@ -44,6 +42,15 @@
$this->morphMap = new DataClassMorphMap();
}

public function setTransformers(array $transformers): self
{
foreach ($transformers as $transformable => $transformers) {
$this->transformers[ltrim($transformable, ' \\')] = array_map(resolve(...), Arr::wrap($transformers));
}

return $this;
}

public function getDataClass(string $class): DataClass
{
if (array_key_exists($class, $this->dataClasses)) {
Expand Down Expand Up @@ -75,23 +82,23 @@
return null;
}

public function findGlobalTransformerForValue(mixed $value): ?Transformer
public function findGlobalTransformersForValue(mixed $value): array
{
if (gettype($value) !== 'object') {
return null;
return [];
}

foreach ($this->transformers as $transformable => $transformer) {
foreach ($this->transformers as $transformable => $transformers) {
if ($value::class === $transformable) {
return $transformer;
return $transformers;

Check failure on line 93 in src/Support/DataConfig.php

View workflow job for this annotation

GitHub Actions / phpstan

Method Spatie\LaravelData\Support\DataConfig::findGlobalTransformersForValue() should return array but returns Spatie\LaravelData\Transformers\Transformer.
}

if (is_a($value::class, $transformable, true)) {
return $transformer;
return $transformers;

Check failure on line 97 in src/Support/DataConfig.php

View workflow job for this annotation

GitHub Actions / phpstan

Method Spatie\LaravelData\Support\DataConfig::findGlobalTransformersForValue() should return array but returns Spatie\LaravelData\Transformers\Transformer.
}
}

return null;
return [];
}

public function getRuleInferrers(): array
Expand Down
5 changes: 2 additions & 3 deletions src/Support/DataProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Mappers\NameMapper;
use Spatie\LaravelData\Resolvers\NameMappersResolver;
use Spatie\LaravelData\Transformers\Transformer;

/**
* @property Collection<string, object> $attributes
Expand All @@ -32,7 +31,7 @@ public function __construct(
public readonly bool $hasDefaultValue,
public readonly mixed $defaultValue,
public readonly ?Cast $cast,
public readonly ?Transformer $transformer,
public readonly Collection $transformers,
public readonly ?string $inputMappedName,
public readonly ?string $outputMappedName,
public readonly Collection $attributes,
Expand Down Expand Up @@ -86,7 +85,7 @@ className: $property->class,
hasDefaultValue: $property->isPromoted() ? $hasDefaultValue : $property->hasDefaultValue(),
defaultValue: $property->isPromoted() ? $defaultValue : $property->getDefaultValue(),
cast: $attributes->first(fn (object $attribute) => $attribute instanceof GetsCast)?->get(),
transformer: $attributes->first(fn (object $attribute) => $attribute instanceof WithTransformer)?->get(),
transformers: $attributes->filter(fn (object $attribute) => $attribute instanceof WithTransformer)->map->get(),
inputMappedName: $inputMappedName,
outputMappedName: $outputMappedName,
attributes: $attributes,
Expand Down
4 changes: 2 additions & 2 deletions src/Support/EloquentCasts/DataEloquentCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ public function set($model, string $key, $value, array $attributes): ?string
if ($isAbstractClassCast) {
return json_encode([
'type' => $this->dataConfig->morphMap->getDataClassAlias($value::class) ?? $value::class,
'data' => json_decode($value->toJson(), associative: true, flags: JSON_THROW_ON_ERROR),
'data' => json_decode(json_encode($value->transform(castingForEloquent: true)), associative: true, flags: JSON_THROW_ON_ERROR),
]);
}

return $value->toJson();
return json_encode($value->transform(castingForEloquent: true));
}

protected function isAbstractClassCast(): bool
Expand Down
2 changes: 2 additions & 0 deletions src/Transformers/DataCollectableTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public function __construct(
protected bool $transformValues,
protected WrapExecutionType $wrapExecutionType,
protected bool $mapPropertyNames,
protected bool $castingForEloquent,
protected PartialTrees $trees,
protected Enumerable|CursorPaginator|Paginator $items,
protected Wrap $wrap,
Expand Down Expand Up @@ -60,6 +61,7 @@ protected function transformCollection(Enumerable $items): array
? WrapExecutionType::TemporarilyDisabled
: $this->wrapExecutionType,
$this->mapPropertyNames,
$this->castingForEloquent,
);
}

Expand Down
43 changes: 30 additions & 13 deletions src/Transformers/DataTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
namespace Spatie\LaravelData\Transformers;

use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Contracts\AppendableData;
use Spatie\LaravelData\Contracts\BaseData;
use Spatie\LaravelData\Contracts\BaseDataCollectable;
use Spatie\LaravelData\Contracts\ExcludeFromEloquentCasts;
use Spatie\LaravelData\Contracts\IncludeableData;
use Spatie\LaravelData\Contracts\TransformableData;
use Spatie\LaravelData\Contracts\WrappableData;
Expand All @@ -30,14 +32,16 @@ public static function create(
bool $transformValues,
WrapExecutionType $wrapExecutionType,
bool $mapPropertyNames,
bool $castingForEloquent,
): self {
return new self($transformValues, $wrapExecutionType, $mapPropertyNames);
return new self($transformValues, $wrapExecutionType, $mapPropertyNames, $castingForEloquent);
}

public function __construct(
protected bool $transformValues,
protected WrapExecutionType $wrapExecutionType,
protected bool $mapPropertyNames,
protected bool $castingForEloquent,
) {
$this->config = app(DataConfig::class);
}
Expand Down Expand Up @@ -215,8 +219,12 @@ protected function resolvePropertyValue(
$value = Arr::except($value, $trees->except->getFields());
}

if ($transformer = $this->resolveTransformerForValue($property, $value)) {
return $transformer->transform($property, $value);
if (count($transformers = $this->resolveTransformersForValue($property, $value))) {
foreach ($transformers as $transformer) {
if ($result = $transformer->transform($property, $value)) {
return $result;
}
}
}

if (! $value instanceof BaseData && ! $value instanceof BaseDataCollectable) {
Expand Down Expand Up @@ -244,23 +252,32 @@ protected function resolvePropertyValue(
return $value;
}

protected function resolveTransformerForValue(
protected function resolveTransformersForValue(
DataProperty $property,
mixed $value,
): ?Transformer {
): Collection {
if (! $this->transformValues) {
return null;
return collect();
}

$transformer = $property->transformer ?? $this->config->findGlobalTransformerForValue($value);
// NOTE: maybe these should be merged instead, but that would be a breaking change.
$transformers = $property->transformers->isNotEmpty()
? $property->transformers
: collect($this->config->findGlobalTransformersForValue($value));

$shouldUseDefaultDataTransformer = $transformer instanceof ArrayableTransformer
&& ($property->type->isDataObject || $property->type->isDataCollectable);
return $transformers->filter(function (Transformer $transformer) use ($property) {
if ($this->castingForEloquent && $transformer instanceof ExcludeFromEloquentCasts) {
return false;
}

if ($shouldUseDefaultDataTransformer) {
return null;
}
$shouldUseDefaultDataTransformer = $transformer instanceof ArrayableTransformer
&& ($property->type->isDataObject || $property->type->isDataCollectable);

if ($shouldUseDefaultDataTransformer) {
return false;
}

return $transformer;
return true;
});
}
}
31 changes: 31 additions & 0 deletions tests/Fakes/Enums/DummyBackedEnumWithOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Spatie\LaravelData\Tests\Fakes\Enums;

use Spatie\LaravelData\Tests\Fakes\Interfaces\HasOptions;

enum DummyBackedEnumWithOptions: string implements HasOptions
{
case CAPTAIN = 'captain';
case FIRST_OFFICER = 'first_officer';

public function label(): string
{
return match ($this) {
self::CAPTAIN => 'Captain',
self::FIRST_OFFICER => 'First Officer',
};
}

public function toOptions(): array
{
return array_map(
callback: fn (self $enum) => [
'value' => $enum->value,
'label' => $enum->label(),
],
array: self::cases()
);

}
}
8 changes: 8 additions & 0 deletions tests/Fakes/Interfaces/HasOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Spatie\LaravelData\Tests\Fakes\Interfaces;

interface HasOptions
{
public function toOptions(): array;
}
29 changes: 29 additions & 0 deletions tests/Fakes/Models/DummyModelWithEloquentExcludedCasts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Spatie\LaravelData\Tests\Fakes\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnumWithOptions;
use Spatie\LaravelData\Tests\Fakes\SimpleDataWithEloquentExcludedTransformerData;

class DummyModelWithEloquentExcludedCasts extends Model
{
protected $casts = [
'data' => SimpleDataWithEloquentExcludedTransformerData::class,
'eloquent_excluded_enum' => DummyBackedEnumWithOptions::class,
];

public $timestamps = false;

public static function migrate()
{
Schema::create('dummy_model_with_eloquent_excluded_casts', function (Blueprint $blueprint) {
$blueprint->increments('id');

$blueprint->text('data')->nullable();
$blueprint->string('eloquent_excluded_enum');
});
}
}
17 changes: 17 additions & 0 deletions tests/Fakes/SimpleDataWithEloquentExcludedTransformerData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Spatie\LaravelData\Tests\Fakes;

use Spatie\LaravelData\Data;
use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum;
use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnumWithOptions;

class SimpleDataWithEloquentExcludedTransformerData extends Data
{
public function __construct(
public readonly string $string,
public readonly DummyBackedEnumWithOptions $enum,
public readonly DummyBackedEnum $normal_enum,
) {
}
}
21 changes: 21 additions & 0 deletions tests/Fakes/Transformers/OptionsTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Spatie\LaravelData\Tests\Fakes\Transformers;

use BackedEnum;
use Spatie\LaravelData\Contracts\ExcludeFromEloquentCasts;
use Spatie\LaravelData\Support\DataProperty;
use Spatie\LaravelData\Tests\Fakes\Interfaces\HasOptions;
use Spatie\LaravelData\Transformers\Transformer;

class OptionsTransformer implements Transformer, ExcludeFromEloquentCasts
{
public function transform(DataProperty $property, mixed $value): ?array
{
if (! $value instanceof BackedEnum || ! $value instanceof HasOptions) {
return null;
}

return $value->toOptions();
}
};
4 changes: 2 additions & 2 deletions tests/Support/DataPropertyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function resolveHelper(
public SimpleData $property;
});

expect($helper->transformer)->toEqual(new DateTimeInterfaceTransformer());
expect($helper->transformers)->first()->toEqual(new DateTimeInterfaceTransformer());
});

it('can get the transformer attribute with arguments', function () {
Expand All @@ -50,7 +50,7 @@ function resolveHelper(
public SimpleData $property;
});

expect($helper->transformer)->toEqual(new DateTimeInterfaceTransformer('d-m-y'));
expect($helper->transformers)->first()->toEqual(new DateTimeInterfaceTransformer('d-m-y'));
});

it('can get the mapped input name', function () {
Expand Down
Loading
Loading