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

Feature: property-morphable abstract data #921

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
8 changes: 8 additions & 0 deletions src/Contracts/PropertyMorphableData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Spatie\LaravelData\Contracts;

interface PropertyMorphableData
{
public static function morph(array $properties): ?string;
}
30 changes: 25 additions & 5 deletions src/Resolvers/DataFromSomethingResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Http\Request;
use Spatie\LaravelData\Contracts\BaseData;
use Spatie\LaravelData\Contracts\PropertyMorphableData;
use Spatie\LaravelData\Enums\CustomCreationMethodType;
use Spatie\LaravelData\Optional;
use Spatie\LaravelData\Support\Creation\CreationContext;
Expand Down Expand Up @@ -39,10 +40,9 @@ public function execute(
$payloadCount = count($payloads);

if ($payloadCount === 0 || $payloadCount === 1) {
return $this->dataFromArrayResolver->execute(
$class,
$pipeline->execute($payloads[0] ?? [], $creationContext)
);
$properties = $pipeline->execute($payloads[0] ?? [], $creationContext);

return $this->dataFromArray($class, $creationContext, $payloads, $properties);
Comment on lines -42 to +45
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not completely sure why we first run the pipeline here with the abstract class and then run it again with the inherited class? That first run seems to be completely unnecessary right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rubenvanassche this might just be from me misunderstanding how or why there are multiple payloads 😄

In order to know which concrete class to create we need to collect the properties from the payloads and pass them into the morph method. I've made the assumption these properties could come from $payloads[0], $payloads[1] etc. or a combination thereof. It also made sense to me to have the set of properties run through the pipeline so that they're cast / have default values etc.

Does that make sense or do you think I should change the approach there?

}

$properties = [];
Expand All @@ -57,7 +57,7 @@ public function execute(
}
}

return $this->dataFromArrayResolver->execute($class, $properties);
return $this->dataFromArray($class, $creationContext, $payloads, $properties);
}

protected function createFromCustomCreationMethod(
Expand Down Expand Up @@ -117,4 +117,24 @@ protected function createFromCustomCreationMethod(

return $class::$methodName(...$payloads);
}

protected function dataFromArray(
string $class,
CreationContext $creationContext,
array $payloads,
array $properties,
): BaseData {
$dataClass = $this->dataConfig->getDataClass($class);

if ($dataClass->isAbstract && $dataClass->propertyMorphable) {
/**
* @var class-string<PropertyMorphableData> $class
*/
if ($morph = $class::morph($properties)) {
return $this->execute($morph, $creationContext, ...$payloads);
}
}

return $this->dataFromArrayResolver->execute($class, $properties);
}
}
17 changes: 17 additions & 0 deletions src/Resolvers/DataValidationRulesResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Spatie\LaravelData\Attributes\MergeValidationRules;
use Spatie\LaravelData\Attributes\Validation\ArrayType;
use Spatie\LaravelData\Attributes\Validation\Present;
use Spatie\LaravelData\Contracts\PropertyMorphableData;
use Spatie\LaravelData\Support\DataClass;
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\DataProperty;
Expand Down Expand Up @@ -35,6 +36,17 @@ public function execute(
): array {
$dataClass = $this->dataConfig->getDataClass($class);

if ($this->isPropertyMorphable($dataClass)) {
/**
* @var class-string<PropertyMorphableData> $class
*/
$morphedClass = $class::morph(
$path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), [])
);

$dataClass = $this->dataConfig->getDataClass($morphedClass ?? $class);
}

$withoutValidationProperties = [];

foreach ($dataClass->properties as $dataProperty) {
Expand Down Expand Up @@ -283,4 +295,9 @@ protected function inferRulesForDataProperty(
$path
);
}

protected function isPropertyMorphable(DataClass $dataClass): bool
{
return $dataClass->isAbstract && $dataClass->propertyMorphable;
}
}
1 change: 1 addition & 0 deletions src/Support/DataClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public function __construct(
public readonly ?DataMethod $constructorMethod,
public readonly bool $isReadonly,
public readonly bool $isAbstract,
public readonly bool $propertyMorphable,
public readonly bool $appendable,
public readonly bool $includeable,
public readonly bool $responsable,
Expand Down
18 changes: 10 additions & 8 deletions src/Support/EloquentCasts/DataCollectionEloquentCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ public function get($model, string $key, $value, array $attributes): ?DataCollec

$data = json_decode($value, true, flags: JSON_THROW_ON_ERROR);

$dataClass = $this->dataConfig->getDataClass($this->dataClass);
$isAbstractClassCast = $this->isAbstractClassCast();

$data = array_map(function (array $item) use ($dataClass) {
if ($dataClass->isAbstract && $dataClass->transformable) {
$data = array_map(function (array $item) use ($isAbstractClassCast) {
if ($isAbstractClassCast) {
$morphedClass = $this->dataConfig->morphMap->getMorphedDataClass($item['type']) ?? $item['type'];

return $morphedClass::from($item['data']);
Expand Down Expand Up @@ -73,10 +73,10 @@ public function set($model, string $key, $value, array $attributes): ?string
throw CannotCastData::shouldBeArray($model::class, $key);
}

$dataClass = $this->dataConfig->getDataClass($this->dataClass);
$isAbstractClassCast = $this->isAbstractClassCast();

$data = array_map(function (array|BaseData $item) use ($dataClass) {
if ($dataClass->isAbstract && $item instanceof TransformableData) {
$data = array_map(function (array|BaseData $item) use ($isAbstractClassCast) {
if ($isAbstractClassCast && $item instanceof TransformableData) {
$class = get_class($item);

return [
Expand All @@ -90,7 +90,7 @@ public function set($model, string $key, $value, array $attributes): ?string
: $item;
}, $value);

if ($dataClass->isAbstract) {
if ($isAbstractClassCast) {
return json_encode($data);
}

Expand All @@ -107,6 +107,8 @@ public function set($model, string $key, $value, array $attributes): ?string

protected function isAbstractClassCast(): bool
{
return $this->dataConfig->getDataClass($this->dataClass)->isAbstract;
$dataClass = $this->dataConfig->getDataClass($this->dataClass);

return $dataClass->isAbstract && $dataClass->transformable && ! $dataClass->propertyMorphable;
}
}
4 changes: 3 additions & 1 deletion src/Support/EloquentCasts/DataEloquentCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ public function set($model, string $key, $value, array $attributes): ?string

protected function isAbstractClassCast(): bool
{
return $this->dataConfig->getDataClass($this->dataClass)->isAbstract;
$dataClass = $this->dataConfig->getDataClass($this->dataClass);

return $dataClass->isAbstract && ! $dataClass->propertyMorphable;
}
}
2 changes: 2 additions & 0 deletions src/Support/Factories/DataClassFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Spatie\LaravelData\Contracts\AppendableData;
use Spatie\LaravelData\Contracts\EmptyData;
use Spatie\LaravelData\Contracts\IncludeableData;
use Spatie\LaravelData\Contracts\PropertyMorphableData;
use Spatie\LaravelData\Contracts\ResponsableData;
use Spatie\LaravelData\Contracts\TransformableData;
use Spatie\LaravelData\Contracts\ValidateableData;
Expand Down Expand Up @@ -87,6 +88,7 @@ public function build(ReflectionClass $reflectionClass): DataClass
constructorMethod: $constructor,
isReadonly: method_exists($reflectionClass, 'isReadOnly') && $reflectionClass->isReadOnly(),
isAbstract: $reflectionClass->isAbstract(),
propertyMorphable: $reflectionClass->implementsInterface(PropertyMorphableData::class),
appendable: $reflectionClass->implementsInterface(AppendableData::class),
includeable: $reflectionClass->implementsInterface(IncludeableData::class),
responsable: $responsable,
Expand Down
120 changes: 120 additions & 0 deletions tests/CreationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Spatie\LaravelData\Casts\Uncastable;
use Spatie\LaravelData\Concerns\WithDeprecatedCollectionMethod;
use Spatie\LaravelData\Contracts\DeprecatedData as DeprecatedDataContract;
use Spatie\LaravelData\Contracts\PropertyMorphableData;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\DataPipeline;
Expand Down Expand Up @@ -1452,3 +1453,122 @@ class TestAutoLazyClassAttributeData extends Data
->toHaveCount(2)
->each()->toBeInstanceOf(FakeNestedModelData::class);
});

describe('property-morphable creation tests', function () {
abstract class TestAbstractPropertyMorphableData extends Data implements PropertyMorphableData
{
public function __construct(public string $variant)
{
}

public static function morph(array $properties): ?string
{
return match ($properties['variant'] ?? null) {
'a' => TestPropertyMorphableDataA::class,
'b' => TestPropertyMorphableDataB::class,
default => null,
};
}
}

class TestPropertyMorphableDataA extends TestAbstractPropertyMorphableData
{
public function __construct(public string $a, public DummyBackedEnum $enum)
{
parent::__construct('a');
}
}

class TestPropertyMorphableDataB extends TestAbstractPropertyMorphableData
{
public function __construct(public string $b)
{
parent::__construct('b');
}
}

it('will allow property-morphable data to be created', function () {
$dataA = TestAbstractPropertyMorphableData::from([
'variant' => 'a',
'a' => 'foo',
'enum' => 'foo',
]);

expect($dataA)
->toBeInstanceOf(TestPropertyMorphableDataA::class)
->variant->toEqual('a')
->a->toEqual('foo')
->enum->toEqual(DummyBackedEnum::FOO);

$dataB = TestAbstractPropertyMorphableData::from([
'variant' => 'b',
'b' => 'bar',
]);

expect($dataB)
->toBeInstanceOf(TestPropertyMorphableDataB::class)
->variant->toEqual('b')
->b->toEqual('bar');
});

it('will allow property-morphable data to be created from concrete', function () {
$dataA = TestPropertyMorphableDataA::from([
'a' => 'foo',
'enum' => 'foo',
]);

expect($dataA)
->toBeInstanceOf(TestPropertyMorphableDataA::class)
->variant->toEqual('a')
->a->toEqual('foo')
->enum->toEqual(DummyBackedEnum::FOO);
});

it('will allow property-morphable data to be created from a nested collection', function () {
class NestedPropertyMorphableData extends Data
{
public function __construct(
/** @var TestAbstractPropertyMorphableData[] */
public ?DataCollection $nestedCollection,
) {
}
}

$data = NestedPropertyMorphableData::from([
'nestedCollection' => [
['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'],
['variant' => 'b', 'b' => 'bar'],
],
]);

expect($data->nestedCollection[0])
->toBeInstanceOf(TestPropertyMorphableDataA::class)
->variant->toEqual('a')
->a->toEqual('foo')
->enum->toEqual(DummyBackedEnum::FOO);

expect($data->nestedCollection[1])
->toBeInstanceOf(TestPropertyMorphableDataB::class)
->variant->toEqual('b')
->b->toEqual('bar');
});


it('will allow property-morphable data to be created as a collection', function () {
$collection = TestAbstractPropertyMorphableData::collect([
['variant' => 'a', 'a' => 'foo', 'enum' => DummyBackedEnum::FOO->value],
['variant' => 'b', 'b' => 'bar'],
]);

expect($collection[0])
->toBeInstanceOf(TestPropertyMorphableDataA::class)
->variant->toEqual('a')
->a->toEqual('foo')
->enum->toEqual(DummyBackedEnum::FOO);

expect($collection[1])
->toBeInstanceOf(TestPropertyMorphableDataB::class)
->variant->toEqual('b')
->b->toEqual('bar');
});
});
Loading
Loading