From c26f036330ba2703550b660d62f9af0621a1eb2d Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 12 Oct 2023 20:14:53 +0200 Subject: [PATCH] feat: support multiple transformers, support excluding transformers from Eloquent casting --- src/Attributes/WithTransformer.php | 2 +- src/Concerns/BaseData.php | 3 +- src/Concerns/BaseDataCollectable.php | 2 + src/Contracts/ExcludeFromEloquentCasts.php | 9 +++ src/Contracts/TransformableData.php | 1 + src/Support/DataConfig.php | 27 +++++--- src/Support/DataProperty.php | 5 +- .../EloquentCasts/DataEloquentCast.php | 4 +- .../DataCollectableTransformer.php | 2 + src/Transformers/DataTransformer.php | 43 ++++++++---- .../Enums/DummyBackedEnumWithOptions.php | 31 +++++++++ tests/Fakes/Interfaces/HasOptions.php | 8 +++ .../DummyModelWithEloquentExcludedCasts.php | 29 ++++++++ ...ataWithEloquentExcludedTransformerData.php | 17 +++++ .../Fakes/Transformers/OptionsTransformer.php | 21 ++++++ tests/Support/DataPropertyTest.php | 4 +- .../EloquentCasts/DataEloquentCastTest.php | 68 +++++++++++++++++++ 17 files changed, 244 insertions(+), 32 deletions(-) create mode 100644 src/Contracts/ExcludeFromEloquentCasts.php create mode 100644 tests/Fakes/Enums/DummyBackedEnumWithOptions.php create mode 100644 tests/Fakes/Interfaces/HasOptions.php create mode 100644 tests/Fakes/Models/DummyModelWithEloquentExcludedCasts.php create mode 100644 tests/Fakes/SimpleDataWithEloquentExcludedTransformerData.php create mode 100644 tests/Fakes/Transformers/OptionsTransformer.php diff --git a/src/Attributes/WithTransformer.php b/src/Attributes/WithTransformer.php index 62786d7a..177fda98 100644 --- a/src/Attributes/WithTransformer.php +++ b/src/Attributes/WithTransformer.php @@ -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; diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 5892096d..cb81a3e6 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -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 diff --git a/src/Concerns/BaseDataCollectable.php b/src/Concerns/BaseDataCollectable.php index 39b66ea9..de9b66be 100644 --- a/src/Concerns/BaseDataCollectable.php +++ b/src/Concerns/BaseDataCollectable.php @@ -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(), diff --git a/src/Contracts/ExcludeFromEloquentCasts.php b/src/Contracts/ExcludeFromEloquentCasts.php new file mode 100644 index 00000000..c086efdd --- /dev/null +++ b/src/Contracts/ExcludeFromEloquentCasts.php @@ -0,0 +1,9 @@ + $transformer) { - $this->transformers[ltrim($transformable, ' \\')] = app($transformer); - } + $this->setTransformers($config['transformers'] ?? []); foreach ($config['casts'] ?? [] as $castable => $cast) { $this->casts[ltrim($castable, ' \\')] = app($cast); @@ -44,6 +42,15 @@ public function __construct(array $config) $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)) { @@ -75,23 +82,23 @@ public function findGlobalCastForProperty(DataProperty $property): ?Cast 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; } if (is_a($value::class, $transformable, true)) { - return $transformer; + return $transformers; } } - return null; + return []; } public function getRuleInferrers(): array diff --git a/src/Support/DataProperty.php b/src/Support/DataProperty.php index a56f9f31..318b455e 100644 --- a/src/Support/DataProperty.php +++ b/src/Support/DataProperty.php @@ -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 $attributes @@ -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, @@ -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, diff --git a/src/Support/EloquentCasts/DataEloquentCast.php b/src/Support/EloquentCasts/DataEloquentCast.php index bc14fb5f..811d868b 100644 --- a/src/Support/EloquentCasts/DataEloquentCast.php +++ b/src/Support/EloquentCasts/DataEloquentCast.php @@ -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 diff --git a/src/Transformers/DataCollectableTransformer.php b/src/Transformers/DataCollectableTransformer.php index 5c3bdbb4..61bc4170 100644 --- a/src/Transformers/DataCollectableTransformer.php +++ b/src/Transformers/DataCollectableTransformer.php @@ -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, @@ -60,6 +61,7 @@ protected function transformCollection(Enumerable $items): array ? WrapExecutionType::TemporarilyDisabled : $this->wrapExecutionType, $this->mapPropertyNames, + $this->castingForEloquent, ); } diff --git a/src/Transformers/DataTransformer.php b/src/Transformers/DataTransformer.php index 8a54fef2..93f70f18 100644 --- a/src/Transformers/DataTransformer.php +++ b/src/Transformers/DataTransformer.php @@ -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; @@ -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); } @@ -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) { @@ -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; + }); } } diff --git a/tests/Fakes/Enums/DummyBackedEnumWithOptions.php b/tests/Fakes/Enums/DummyBackedEnumWithOptions.php new file mode 100644 index 00000000..4bcc354e --- /dev/null +++ b/tests/Fakes/Enums/DummyBackedEnumWithOptions.php @@ -0,0 +1,31 @@ + '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() + ); + + } +} diff --git a/tests/Fakes/Interfaces/HasOptions.php b/tests/Fakes/Interfaces/HasOptions.php new file mode 100644 index 00000000..200bdc9d --- /dev/null +++ b/tests/Fakes/Interfaces/HasOptions.php @@ -0,0 +1,8 @@ + 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'); + }); + } +} diff --git a/tests/Fakes/SimpleDataWithEloquentExcludedTransformerData.php b/tests/Fakes/SimpleDataWithEloquentExcludedTransformerData.php new file mode 100644 index 00000000..e934451d --- /dev/null +++ b/tests/Fakes/SimpleDataWithEloquentExcludedTransformerData.php @@ -0,0 +1,17 @@ +toOptions(); + } +}; diff --git a/tests/Support/DataPropertyTest.php b/tests/Support/DataPropertyTest.php index 6e354a5a..3fc2fc3f 100644 --- a/tests/Support/DataPropertyTest.php +++ b/tests/Support/DataPropertyTest.php @@ -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 () { @@ -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 () { diff --git a/tests/Support/EloquentCasts/DataEloquentCastTest.php b/tests/Support/EloquentCasts/DataEloquentCastTest.php index c28d4e2d..385d67f0 100644 --- a/tests/Support/EloquentCasts/DataEloquentCastTest.php +++ b/tests/Support/EloquentCasts/DataEloquentCastTest.php @@ -7,13 +7,20 @@ use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractDataA; use Spatie\LaravelData\Tests\Fakes\AbstractData\AbstractDataB; +use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; +use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnumWithOptions; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithCasts; use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithDefaultCasts; +use Spatie\LaravelData\Tests\Fakes\Models\DummyModelWithEloquentExcludedCasts; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Tests\Fakes\SimpleDataWithDefaultValue; +use Spatie\LaravelData\Tests\Fakes\SimpleDataWithEloquentExcludedTransformerData; +use Spatie\LaravelData\Tests\Fakes\Transformers\OptionsTransformer; +use Spatie\LaravelData\Transformers\EnumTransformer; beforeEach(function () { DummyModelWithCasts::migrate(); + DummyModelWithEloquentExcludedCasts::migrate(); }); it('can save a data object', function () { @@ -130,3 +137,64 @@ ->toBeInstanceOf(AbstractDataA::class) ->a->toBe('A\A'); }); + +it('skips eloquent-excluded transformers during casting', function () { + resolve(DataConfig::class)->setTransformers([ + BackedEnum::class => [OptionsTransformer::class, EnumTransformer::class], + ]); + + $model = DummyModelWithEloquentExcludedCasts::create([ + 'data' => [ + 'string' => 'Test', + 'enum' => DummyBackedEnumWithOptions::CAPTAIN, + 'normal_enum' => DummyBackedEnum::FOO, + ], + 'eloquent_excluded_enum' => DummyBackedEnumWithOptions::CAPTAIN, + ]); + + // Ensures the value is saved properly to the database + assertDatabaseHas($model::class, [ + 'data' => json_encode(['string' => 'Test', 'enum' => 'captain', 'normal_enum' => 'foo']), + 'eloquent_excluded_enum' => 'captain', + ]); + + // Ensures the value is serialized properly otherwise + expect($model->jsonSerialize())->toMatchArray([ + 'data' => [ + 'string' => 'Test', + 'enum' => [ + ['value' => 'captain', 'label' => 'Captain'], + ['value' => 'first_officer', 'label' => 'First Officer'], + ], + 'normal_enum' => 'foo', + ], + 'eloquent_excluded_enum' => 'captain', + ]); +}); + +test('transforming data takes `ExcludeFromEloquentCasts` into account', function () { + resolve(DataConfig::class)->setTransformers([ + BackedEnum::class => [OptionsTransformer::class, EnumTransformer::class], + ]); + + $data = SimpleDataWithEloquentExcludedTransformerData::from([ + 'string' => 'Test', + 'enum' => DummyBackedEnumWithOptions::CAPTAIN, + 'normal_enum' => DummyBackedEnum::FOO, + ]); + + expect($data->transform(castingForEloquent: true))->toBe([ + 'string' => 'Test', + 'enum' => 'captain', + 'normal_enum' => 'foo', + ]); + + expect($data->transform(castingForEloquent: false))->toBe([ + 'string' => 'Test', + 'enum' => [ + ['value' => 'captain', 'label' => 'Captain'], + ['value' => 'first_officer', 'label' => 'First Officer'], + ], + 'normal_enum' => 'foo', + ]); +});