Skip to content

Commit

Permalink
Require attributes for property morphable and cast before passing to …
Browse files Browse the repository at this point in the history
…morph
  • Loading branch information
bentleyo committed Jan 29, 2025
1 parent 3129237 commit 2f70f17
Show file tree
Hide file tree
Showing 9 changed files with 112 additions and 47 deletions.
3 changes: 2 additions & 1 deletion src/Resolvers/DataFromSomethingResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Spatie\LaravelData\Resolvers;

use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Spatie\LaravelData\Contracts\BaseData;
use Spatie\LaravelData\Contracts\PropertyMorphableData;
use Spatie\LaravelData\Enums\CustomCreationMethodType;
Expand Down Expand Up @@ -130,7 +131,7 @@ protected function dataFromArray(
/**
* @var class-string<PropertyMorphableData> $class
*/
if ($morph = $class::morph($properties)) {
if ($morph = $class::morph(Arr::only($properties, $dataClass->propertyMorphablePropertyNames))) {
return $this->execute($morph, $creationContext, ...$payloads);
}
}
Expand Down
56 changes: 45 additions & 11 deletions src/Resolvers/DataValidationRulesResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
use Spatie\LaravelData\Attributes\MergeValidationRules;
use Spatie\LaravelData\Attributes\Validation\ArrayType;
use Spatie\LaravelData\Attributes\Validation\Present;
use Spatie\LaravelData\Contracts\BaseData;
use Spatie\LaravelData\Contracts\PropertyMorphableData;
use Spatie\LaravelData\Support\Creation\CreationContextFactory;
use Spatie\LaravelData\Support\DataClass;
use Spatie\LaravelData\Support\DataConfig;
use Spatie\LaravelData\Support\DataProperty;
Expand Down Expand Up @@ -36,17 +38,11 @@ public function execute(
DataRules $dataRules
): 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);
}
$dataClass = $this->propertyMorphableDataClass(
$dataClass,
$fullPayload,
$path
) ?? $dataClass;

$withoutValidationProperties = [];

Expand Down Expand Up @@ -96,6 +92,44 @@ public function execute(
return $dataRules->rules;
}

protected function propertyMorphableDataClass(
DataClass $dataClass,
array $fullPayload,
ValidationPath $path
): ?DataClass {
if (! $dataClass->propertyMorphable) {
return null;
}

/**
* @var class-string<PropertyMorphableData&BaseData> $class
*/
$class = $dataClass->name;
$creationContext = CreationContextFactory::createFromConfig($class)->get();
$pipeline = $this->dataConfig->getResolvedDataPipeline($class);

try {
$properties = Arr::only($pipeline->execute(
$path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), []),
$creationContext
), $dataClass->propertyMorphablePropertyNames);
} catch (\Throwable $exception) {
return null;
}

// Only morph if all properties are present
if (count($properties) !== count($dataClass->propertyMorphablePropertyNames)) {
return null;
}

$morphedClass = $class::morph($properties);
if ($morphedClass === null) {
return null;
}

return $this->dataConfig->getDataClass($morphedClass);
}

protected function shouldSkipPropertyValidation(
DataProperty $dataProperty,
array $fullPayload,
Expand Down
3 changes: 2 additions & 1 deletion src/Support/DataClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public function __construct(
public DataStructureProperty $allowedRequestOnly,
public DataStructureProperty $allowedRequestExcept,
public DataStructureProperty $outputMappedProperties,
public DataStructureProperty $transformationFields
public DataStructureProperty $transformationFields,
public readonly array $propertyMorphablePropertyNames
) {
}

Expand Down
4 changes: 4 additions & 0 deletions src/Support/Factories/DataClassFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ public function build(ReflectionClass $reflectionClass): DataClass
allowedRequestExcept: new LazyDataStructureProperty(fn (): ?array => $responsable ? $name::allowedRequestExcept() : null),
outputMappedProperties: $outputMappedProperties,
transformationFields: static::resolveTransformationFields($properties),
propertyMorphablePropertyNames: $properties->filter(fn (DataProperty $property) => $property->isForMorph)
->map(fn (DataProperty $property) => $property->name)
->values()
->all(),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Spatie\LaravelData\Support\Validation;

use Closure;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule;
use Spatie\LaravelData\Attributes\Validation\ObjectValidationAttribute;
use Spatie\LaravelData\Support\DataClass;
Expand All @@ -25,7 +26,7 @@ public static function keyword(): string

public static function create(string ...$parameters): static
{
return new static(...$parameters);
throw new Exception('Cannot create a requires property morphable class rule');
}

public function validate(string $attribute, mixed $value, Closure $fail): void
Expand Down
34 changes: 21 additions & 13 deletions tests/CreationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1455,17 +1455,25 @@ class TestAutoLazyClassAttributeData extends Data
});

describe('property-morphable creation tests', function () {
enum TestPropertyMorphableEnum: string
{
case A = 'a';
case B = 'b';
};

abstract class TestAbstractPropertyMorphableData extends Data implements PropertyMorphableData
{
public function __construct(public string $variant)
{
public function __construct(
#[\Spatie\LaravelData\Attributes\PropertyForMorph]
public TestPropertyMorphableEnum $variant
) {
}

public static function morph(array $properties): ?string
{
return match ($properties['variant'] ?? null) {
'a' => TestPropertyMorphableDataA::class,
'b' => TestPropertyMorphableDataB::class,
TestPropertyMorphableEnum::A => TestPropertyMorphableDataA::class,
TestPropertyMorphableEnum::B => TestPropertyMorphableDataB::class,
default => null,
};
}
Expand All @@ -1475,15 +1483,15 @@ class TestPropertyMorphableDataA extends TestAbstractPropertyMorphableData
{
public function __construct(public string $a, public DummyBackedEnum $enum)
{
parent::__construct('a');
parent::__construct(TestPropertyMorphableEnum::A);
}
}

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

Expand All @@ -1496,7 +1504,7 @@ public function __construct(public string $b)

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

Expand All @@ -1507,7 +1515,7 @@ public function __construct(public string $b)

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

Expand All @@ -1519,7 +1527,7 @@ public function __construct(public string $b)

expect($dataA)
->toBeInstanceOf(TestPropertyMorphableDataA::class)
->variant->toEqual('a')
->variant->toEqual(TestPropertyMorphableEnum::A)
->a->toEqual('foo')
->enum->toEqual(DummyBackedEnum::FOO);
});
Expand All @@ -1543,13 +1551,13 @@ public function __construct(

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

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

Expand All @@ -1562,13 +1570,13 @@ public function __construct(

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

expect($collection[1])
->toBeInstanceOf(TestPropertyMorphableDataB::class)
->variant->toEqual('b')
->variant->toEqual(TestPropertyMorphableEnum::B)
->b->toEqual('bar');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,10 @@
it('can load and save an abstract property-morphable data collection', function () {
abstract class TestCollectionCastAbstractPropertyMorphableData extends Data implements PropertyMorphableData
{
public function __construct(public string $variant)
{
public function __construct(
#[\Spatie\LaravelData\Attributes\PropertyForMorph]
public string $variant
) {
}

public static function morph(array $properties): ?string
Expand Down
22 changes: 12 additions & 10 deletions tests/Support/EloquentCasts/DataEloquentCastTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,24 +191,26 @@
it('can load and save an abstract property-morphable data object', function () {
abstract class TestCastAbstractPropertyMorphableData extends Data implements PropertyMorphableData
{
public function __construct(public string $variant)
{
public function __construct(
#[\Spatie\LaravelData\Attributes\PropertyForMorph]
public DummyBackedEnum $variant
) {
}

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

class TestCastPropertyMorphableDataA extends TestCastAbstractPropertyMorphableData
class TestCastPropertyMorphableDataFoo extends TestCastAbstractPropertyMorphableData
{
public function __construct(public string $a, public DummyBackedEnum $enum)
public function __construct(public string $a)
{
parent::__construct('a');
parent::__construct(DummyBackedEnum::FOO);
}
}

Expand All @@ -222,20 +224,20 @@ public function __construct(public string $a, public DummyBackedEnum $enum)
public $timestamps = false;
};

$abstractA = new TestCastPropertyMorphableDataA('foo', DummyBackedEnum::FOO);
$abstractA = new TestCastPropertyMorphableDataFoo('foo');

$modelId = $modelClass::create([
'data' => $abstractA,
])->id;

assertDatabaseHas($modelClass::class, [
'data' => json_encode(['a' => 'foo', 'enum' => 'foo', 'variant' => 'a']),
'data' => json_encode(['a' => 'foo', 'variant' => 'foo']),
]);

$model = $modelClass::find($modelId);

expect($model->data)
->toBeInstanceOf(TestCastPropertyMorphableDataA::class)
->toBeInstanceOf(TestCastPropertyMorphableDataFoo::class)
->a->toBe('foo')
->enum->toBe(DummyBackedEnum::FOO);
->variant->toBe(DummyBackedEnum::FOO);
});
28 changes: 20 additions & 8 deletions tests/ValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2609,19 +2609,25 @@ public static function rules(): array
});

describe('property-morphable validation tests', function () {
enum TestValidationPropertyMorphableEnum: string
{
case A = 'a';
case B = 'b';
};

abstract class TestValidationAbstractPropertyMorphableData extends Data implements PropertyMorphableData
{
public function __construct(
#[PropertyForMorph]
public string $variant,
public TestValidationPropertyMorphableEnum $variant,
) {
}

public static function morph(array $properties): ?string
{
return match ($properties['variant'] ?? null) {
'a' => TestValidationPropertyMorphableDataA::class,
'b' => TestValidationPropertyMorphableDataB::class,
return match ($properties['variant']) {
TestValidationPropertyMorphableEnum::A => TestValidationPropertyMorphableDataA::class,
TestValidationPropertyMorphableEnum::B => TestValidationPropertyMorphableDataB::class,
default => null,
};
}
Expand All @@ -2631,15 +2637,15 @@ class TestValidationPropertyMorphableDataA extends TestValidationAbstractPropert
{
public function __construct(public string $a, public DummyBackedEnum $enum)
{
parent::__construct('a');
parent::__construct(TestValidationPropertyMorphableEnum::A);
}
}

class TestValidationPropertyMorphableDataB extends TestValidationAbstractPropertyMorphableData
{
public function __construct(public string $b)
{
parent::__construct('b');
parent::__construct(TestValidationPropertyMorphableEnum::B);
}
}

Expand All @@ -2651,7 +2657,10 @@ public function __construct(public string $b)
->assertErrors([
'variant' => 'c',
], [
'variant' => ['The selected variant is invalid for morph.'],
'variant' => [
'The selected variant is invalid.',
'The selected variant is invalid for morph.',
],
])
->assertErrors([
'variant' => 'a',
Expand Down Expand Up @@ -2701,7 +2710,10 @@ public function __construct(
->assertErrors([
'nestedCollection' => [['variant' => 'c']],
], [
'nestedCollection.0.variant' => ['The selected nested collection.0.variant is invalid for morph.'],
'nestedCollection.0.variant' => [
'The selected nested collection.0.variant is invalid.',
'The selected nested collection.0.variant is invalid for morph.',
],
])
->assertErrors([
'nestedCollection' => [['variant' => 'a'], ['variant' => 'b']],
Expand Down

0 comments on commit 2f70f17

Please sign in to comment.