Skip to content

Commit 017455e

Browse files
authored
Merge pull request #1 from bentleyo/feature-property-morphable-validation
Feature property morphable validation
2 parents 635d813 + 4911378 commit 017455e

12 files changed

+180
-47
lines changed

src/Attributes/PropertyForMorph.php

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Spatie\LaravelData\Attributes;
4+
5+
use Attribute;
6+
7+
#[Attribute(Attribute::TARGET_PROPERTY)]
8+
class PropertyForMorph
9+
{
10+
}

src/Resolvers/DataFromSomethingResolver.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,12 @@ protected function dataFromArray(
127127
$dataClass = $this->dataConfig->getDataClass($class);
128128

129129
if ($dataClass->isAbstract && $dataClass->propertyMorphable) {
130+
$morphableProperties = $dataClass->propertiesForMorph($properties);
131+
130132
/**
131133
* @var class-string<PropertyMorphableData> $class
132134
*/
133-
if ($morph = $class::morph($properties)) {
135+
if ($morphableProperties && $morph = $class::morph($morphableProperties)) {
134136
return $this->execute($morph, $creationContext, ...$payloads);
135137
}
136138
}

src/Resolvers/DataValidationRulesResolver.php

+52-11
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88
use Spatie\LaravelData\Attributes\MergeValidationRules;
99
use Spatie\LaravelData\Attributes\Validation\ArrayType;
1010
use Spatie\LaravelData\Attributes\Validation\Present;
11+
use Spatie\LaravelData\Contracts\BaseData;
1112
use Spatie\LaravelData\Contracts\PropertyMorphableData;
13+
use Spatie\LaravelData\Support\Creation\CreationContextFactory;
1214
use Spatie\LaravelData\Support\DataClass;
1315
use Spatie\LaravelData\Support\DataConfig;
1416
use Spatie\LaravelData\Support\DataProperty;
1517
use Spatie\LaravelData\Support\Validation\DataRules;
18+
use Spatie\LaravelData\Support\Validation\EnsurePropertyMorphable;
1619
use Spatie\LaravelData\Support\Validation\PropertyRules;
1720
use Spatie\LaravelData\Support\Validation\RuleDenormalizer;
1821
use Spatie\LaravelData\Support\Validation\RuleNormalizer;
@@ -35,17 +38,11 @@ public function execute(
3538
DataRules $dataRules
3639
): array {
3740
$dataClass = $this->dataConfig->getDataClass($class);
38-
39-
if ($this->isPropertyMorphable($dataClass)) {
40-
/**
41-
* @var class-string<PropertyMorphableData> $class
42-
*/
43-
$morphedClass = $class::morph(
44-
$path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), [])
45-
);
46-
47-
$dataClass = $this->dataConfig->getDataClass($morphedClass ?? $class);
48-
}
41+
$dataClass = $this->propertyMorphableDataClass(
42+
$dataClass,
43+
$fullPayload,
44+
$path
45+
) ?? $dataClass;
4946

5047
$withoutValidationProperties = [];
5148

@@ -77,6 +74,10 @@ public function execute(
7774
$path,
7875
);
7976

77+
if ($dataProperty->isForMorph) {
78+
$rules[] = new EnsurePropertyMorphable($dataClass);
79+
}
80+
8081
$dataRules->add($propertyPath, $rules);
8182
}
8283

@@ -91,6 +92,46 @@ public function execute(
9192
return $dataRules->rules;
9293
}
9394

95+
protected function propertyMorphableDataClass(
96+
DataClass $dataClass,
97+
array $fullPayload,
98+
ValidationPath $path
99+
): ?DataClass {
100+
if (! $dataClass->propertyMorphable) {
101+
return null;
102+
}
103+
104+
/**
105+
* @var class-string<PropertyMorphableData&BaseData> $class
106+
*/
107+
$class = $dataClass->name;
108+
$creationContext = CreationContextFactory::createFromConfig($class)->get();
109+
$pipeline = $this->dataConfig->getResolvedDataPipeline($class);
110+
111+
try {
112+
// Attempt to cast properties
113+
$properties = $pipeline->execute(
114+
$path->isRoot() ? $fullPayload : Arr::get($fullPayload, $path->get(), []),
115+
$creationContext
116+
);
117+
} catch (\Throwable $exception) {
118+
return null;
119+
}
120+
121+
// Restrict to only morphable properties
122+
$properties = $dataClass->propertiesForMorph($properties);
123+
if ($properties === null) {
124+
return null;
125+
}
126+
127+
$morphedClass = $class::morph($properties);
128+
if ($morphedClass === null) {
129+
return null;
130+
}
131+
132+
return $this->dataConfig->getDataClass($morphedClass);
133+
}
134+
94135
protected function shouldSkipPropertyValidation(
95136
DataProperty $dataProperty,
96137
array $fullPayload,

src/Support/DataClass.php

+14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Spatie\LaravelData\Support;
44

5+
use Illuminate\Support\Arr;
56
use Illuminate\Support\Collection;
67

78
/**
@@ -58,4 +59,17 @@ public function prepareForCache(): void
5859
}
5960
}
6061
}
62+
63+
public function propertiesForMorph(array $properties): ?array
64+
{
65+
$requiredProperties = $this->properties->filter(fn (DataProperty $property) => $property->isForMorph)->pluck('name');
66+
$forMorph = Arr::only($properties, $requiredProperties->all());
67+
68+
// If all required properties are not present, return null
69+
if (count($forMorph) !== $requiredProperties->count()) {
70+
return null;
71+
}
72+
73+
return $forMorph;
74+
}
6175
}

src/Support/DataProperty.php

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public function __construct(
2929
public readonly ?string $inputMappedName,
3030
public readonly ?string $outputMappedName,
3131
public readonly Collection $attributes,
32+
public readonly bool $isForMorph,
3233
) {
3334
}
3435
}

src/Support/Factories/DataClassFactory.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public function build(ReflectionClass $reflectionClass): DataClass
103103
allowedRequestOnly: new LazyDataStructureProperty(fn (): ?array => $responsable ? $name::allowedRequestOnly() : null),
104104
allowedRequestExcept: new LazyDataStructureProperty(fn (): ?array => $responsable ? $name::allowedRequestExcept() : null),
105105
outputMappedProperties: $outputMappedProperties,
106-
transformationFields: static::resolveTransformationFields($properties),
106+
transformationFields: static::resolveTransformationFields($properties)
107107
);
108108
}
109109

src/Support/Factories/DataPropertyFactory.php

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Spatie\LaravelData\Attributes\Computed;
1010
use Spatie\LaravelData\Attributes\GetsCast;
1111
use Spatie\LaravelData\Attributes\Hidden;
12+
use Spatie\LaravelData\Attributes\PropertyForMorph;
1213
use Spatie\LaravelData\Attributes\WithCastAndTransformer;
1314
use Spatie\LaravelData\Attributes\WithoutValidation;
1415
use Spatie\LaravelData\Attributes\WithTransformer;
@@ -108,6 +109,7 @@ className: $reflectionProperty->class,
108109
inputMappedName: $inputMappedName,
109110
outputMappedName: $outputMappedName,
110111
attributes: $attributes,
112+
isForMorph: ! empty($reflectionProperty->getAttributes(PropertyForMorph::class)),
111113
);
112114
}
113115
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Spatie\LaravelData\Support\Validation;
4+
5+
use Closure;
6+
use Exception;
7+
use Illuminate\Contracts\Validation\ValidationRule;
8+
use Spatie\LaravelData\Attributes\Validation\ObjectValidationAttribute;
9+
use Spatie\LaravelData\Support\DataClass;
10+
11+
class EnsurePropertyMorphable extends ObjectValidationAttribute implements ValidationRule
12+
{
13+
public function __construct(protected DataClass $dataClass)
14+
{
15+
}
16+
17+
public function getRule(ValidationPath $path): object|string
18+
{
19+
return $this;
20+
}
21+
22+
public static function keyword(): string
23+
{
24+
return 'requires_property_morphable_class';
25+
}
26+
27+
public static function create(string ...$parameters): static
28+
{
29+
throw new Exception('Cannot create a requires property morphable class rule');
30+
}
31+
32+
public function validate(string $attribute, mixed $value, Closure $fail): void
33+
{
34+
if (! $this->dataClass->propertyMorphable || $this->dataClass->isAbstract) {
35+
$fail('The selected :attribute is invalid for morph.');
36+
}
37+
}
38+
}

tests/CreationTest.php

+21-13
Original file line numberDiff line numberDiff line change
@@ -1455,17 +1455,25 @@ class TestAutoLazyClassAttributeData extends Data
14551455
});
14561456

14571457
describe('property-morphable creation tests', function () {
1458+
enum TestPropertyMorphableEnum: string
1459+
{
1460+
case A = 'a';
1461+
case B = 'b';
1462+
};
1463+
14581464
abstract class TestAbstractPropertyMorphableData extends Data implements PropertyMorphableData
14591465
{
1460-
public function __construct(public string $variant)
1461-
{
1466+
public function __construct(
1467+
#[\Spatie\LaravelData\Attributes\PropertyForMorph]
1468+
public TestPropertyMorphableEnum $variant
1469+
) {
14621470
}
14631471

14641472
public static function morph(array $properties): ?string
14651473
{
14661474
return match ($properties['variant'] ?? null) {
1467-
'a' => TestPropertyMorphableDataA::class,
1468-
'b' => TestPropertyMorphableDataB::class,
1475+
TestPropertyMorphableEnum::A => TestPropertyMorphableDataA::class,
1476+
TestPropertyMorphableEnum::B => TestPropertyMorphableDataB::class,
14691477
default => null,
14701478
};
14711479
}
@@ -1475,15 +1483,15 @@ class TestPropertyMorphableDataA extends TestAbstractPropertyMorphableData
14751483
{
14761484
public function __construct(public string $a, public DummyBackedEnum $enum)
14771485
{
1478-
parent::__construct('a');
1486+
parent::__construct(TestPropertyMorphableEnum::A);
14791487
}
14801488
}
14811489

14821490
class TestPropertyMorphableDataB extends TestAbstractPropertyMorphableData
14831491
{
14841492
public function __construct(public string $b)
14851493
{
1486-
parent::__construct('b');
1494+
parent::__construct(TestPropertyMorphableEnum::B);
14871495
}
14881496
}
14891497

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

14971505
expect($dataA)
14981506
->toBeInstanceOf(TestPropertyMorphableDataA::class)
1499-
->variant->toEqual('a')
1507+
->variant->toEqual(TestPropertyMorphableEnum::A)
15001508
->a->toEqual('foo')
15011509
->enum->toEqual(DummyBackedEnum::FOO);
15021510

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

15081516
expect($dataB)
15091517
->toBeInstanceOf(TestPropertyMorphableDataB::class)
1510-
->variant->toEqual('b')
1518+
->variant->toEqual(TestPropertyMorphableEnum::B)
15111519
->b->toEqual('bar');
15121520
});
15131521

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

15201528
expect($dataA)
15211529
->toBeInstanceOf(TestPropertyMorphableDataA::class)
1522-
->variant->toEqual('a')
1530+
->variant->toEqual(TestPropertyMorphableEnum::A)
15231531
->a->toEqual('foo')
15241532
->enum->toEqual(DummyBackedEnum::FOO);
15251533
});
@@ -1543,13 +1551,13 @@ public function __construct(
15431551

15441552
expect($data->nestedCollection[0])
15451553
->toBeInstanceOf(TestPropertyMorphableDataA::class)
1546-
->variant->toEqual('a')
1554+
->variant->toEqual(TestPropertyMorphableEnum::A)
15471555
->a->toEqual('foo')
15481556
->enum->toEqual(DummyBackedEnum::FOO);
15491557

15501558
expect($data->nestedCollection[1])
15511559
->toBeInstanceOf(TestPropertyMorphableDataB::class)
1552-
->variant->toEqual('b')
1560+
->variant->toEqual(TestPropertyMorphableEnum::B)
15531561
->b->toEqual('bar');
15541562
});
15551563

@@ -1562,13 +1570,13 @@ public function __construct(
15621570

15631571
expect($collection[0])
15641572
->toBeInstanceOf(TestPropertyMorphableDataA::class)
1565-
->variant->toEqual('a')
1573+
->variant->toEqual(TestPropertyMorphableEnum::A)
15661574
->a->toEqual('foo')
15671575
->enum->toEqual(DummyBackedEnum::FOO);
15681576

15691577
expect($collection[1])
15701578
->toBeInstanceOf(TestPropertyMorphableDataB::class)
1571-
->variant->toEqual('b')
1579+
->variant->toEqual(TestPropertyMorphableEnum::B)
15721580
->b->toEqual('bar');
15731581
});
15741582
});

tests/Support/EloquentCasts/DataCollectionEloquentCastTest.php

+4-2
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,10 @@
178178
it('can load and save an abstract property-morphable data collection', function () {
179179
abstract class TestCollectionCastAbstractPropertyMorphableData extends Data implements PropertyMorphableData
180180
{
181-
public function __construct(public string $variant)
182-
{
181+
public function __construct(
182+
#[\Spatie\LaravelData\Attributes\PropertyForMorph]
183+
public string $variant
184+
) {
183185
}
184186

185187
public static function morph(array $properties): ?string

tests/Support/EloquentCasts/DataEloquentCastTest.php

+12-10
Original file line numberDiff line numberDiff line change
@@ -191,24 +191,26 @@
191191
it('can load and save an abstract property-morphable data object', function () {
192192
abstract class TestCastAbstractPropertyMorphableData extends Data implements PropertyMorphableData
193193
{
194-
public function __construct(public string $variant)
195-
{
194+
public function __construct(
195+
#[\Spatie\LaravelData\Attributes\PropertyForMorph]
196+
public DummyBackedEnum $variant
197+
) {
196198
}
197199

198200
public static function morph(array $properties): ?string
199201
{
200202
return match ($properties['variant'] ?? null) {
201-
'a' => TestCastPropertyMorphableDataA::class,
203+
DummyBackedEnum::FOO => TestCastPropertyMorphableDataFoo::class,
202204
default => null,
203205
};
204206
}
205207
}
206208

207-
class TestCastPropertyMorphableDataA extends TestCastAbstractPropertyMorphableData
209+
class TestCastPropertyMorphableDataFoo extends TestCastAbstractPropertyMorphableData
208210
{
209-
public function __construct(public string $a, public DummyBackedEnum $enum)
211+
public function __construct(public string $a)
210212
{
211-
parent::__construct('a');
213+
parent::__construct(DummyBackedEnum::FOO);
212214
}
213215
}
214216

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

225-
$abstractA = new TestCastPropertyMorphableDataA('foo', DummyBackedEnum::FOO);
227+
$abstractA = new TestCastPropertyMorphableDataFoo('foo');
226228

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

231233
assertDatabaseHas($modelClass::class, [
232-
'data' => json_encode(['a' => 'foo', 'enum' => 'foo', 'variant' => 'a']),
234+
'data' => json_encode(['a' => 'foo', 'variant' => 'foo']),
233235
]);
234236

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

237239
expect($model->data)
238-
->toBeInstanceOf(TestCastPropertyMorphableDataA::class)
240+
->toBeInstanceOf(TestCastPropertyMorphableDataFoo::class)
239241
->a->toBe('foo')
240-
->enum->toBe(DummyBackedEnum::FOO);
242+
->variant->toBe(DummyBackedEnum::FOO);
241243
});

0 commit comments

Comments
 (0)