Skip to content

Commit ad5b827

Browse files
committed
Allow stacking inheritance for rules
1 parent 973ce11 commit ad5b827

File tree

7 files changed

+224
-5
lines changed

7 files changed

+224
-5
lines changed

docs/docs/usage/attributes.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ By default the attribute also applies the inherited rules when the Data class va
9191
public ?string $postal_code,
9292
```
9393

94+
### Runtime Validation
95+
96+
When runtime enforcement is enabled (default), the attribute hooks into Spatie Data’s validator so any request payload must satisfy the inherited rules. This keeps schema generation and runtime validation perfectly in sync.
97+
98+
### Combining with Local Rules
99+
100+
Inherited rules are merged with rules defined on the consuming class. If you add stricter requirements locally—such as making a field required—they stack on top of the inherited rules:
101+
102+
```php
103+
#[ValidationSchema]
104+
class FranchiseUpdateData extends Data
105+
{
106+
public function __construct(
107+
#[InheritValidationFrom(PostalCodeValidator::class, 'postal_code')]
108+
public ?string $postal_code,
109+
) {}
110+
111+
public static function rules(?ValidationContext $context = null): array
112+
{
113+
return [
114+
'postal_code' => ['required'],
115+
];
116+
}
117+
}
118+
```
119+
120+
The generated schema now reflects both sets of rules and the runtime validator enforces them together.
121+
94122
### Field Mapping
95123

96124
You can inherit validation from a differently named field:

src/Resolvers/InheritingDataValidationRulesResolver.php

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
use Spatie\LaravelData\Support\Validation\RuleNormalizer;
1313
use Spatie\LaravelData\Support\Validation\ValidationPath;
1414

15+
use function in_array;
16+
use function is_string;
1517
use function str_starts_with;
1618

1719
class InheritingDataValidationRulesResolver extends BaseResolver
@@ -118,7 +120,8 @@ protected function copyRulesToTargetProperty(
118120

119121
foreach ($sourceRules as $key => $rules) {
120122
if ($key === $sourceProperty) {
121-
$dataRules->add($basePath->property($targetProperty), $rules);
123+
$targetPath = $basePath->property($targetProperty);
124+
$this->mergeIntoDataRules($dataRules, $targetPath, $rules);
122125

123126
continue;
124127
}
@@ -132,10 +135,45 @@ protected function copyRulesToTargetProperty(
132135
$targetKey = $targetProperty.$suffix;
133136
$fullKey = $baseKey ? "{$baseKey}.{$targetKey}" : $targetKey;
134137

135-
$dataRules->add(
138+
$this->mergeIntoDataRules(
139+
$dataRules,
136140
ValidationPath::create($fullKey),
137141
$rules
138142
);
139143
}
140144
}
145+
146+
protected function mergeIntoDataRules(
147+
DataRules $dataRules,
148+
ValidationPath $path,
149+
array $rules
150+
): void {
151+
$key = $path->get();
152+
153+
if ($key === null) {
154+
return;
155+
}
156+
157+
$existing = $dataRules->rules[$key] ?? [];
158+
$merged = $this->mergeRuleSet($existing, $rules);
159+
160+
$dataRules->add($path, $merged);
161+
}
162+
163+
protected function mergeRuleSet(array $existing, array $incoming): array
164+
{
165+
foreach ($incoming as $rule) {
166+
if (is_string($rule)) {
167+
if (! in_array($rule, $existing, true)) {
168+
$existing[] = $rule;
169+
}
170+
171+
continue;
172+
}
173+
174+
$existing[] = $rule;
175+
}
176+
177+
return $existing;
178+
}
141179
}

src/Services/DataClassRuleProcessor.php

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use Spatie\LaravelData\Support\Validation\RuleDenormalizer;
2020
use Spatie\LaravelData\Support\Validation\ValidationContext;
2121
use Spatie\LaravelData\Support\Validation\ValidationPath;
22+
use function in_array;
23+
use function is_string;
2224

2325
/**
2426
* Service for processing Data class validation rules
@@ -296,9 +298,11 @@ protected function processInheritedValidation(
296298

297299
// Find the source property rules
298300
if (isset($sourceRules[$sourceProperty])) {
299-
// Override the current property's rules with inherited ones
300-
$dataRules->add(
301-
$path->property($currentPropertyName),
301+
$propertyPath = $path->property($currentPropertyName);
302+
303+
$this->mergeRulesIntoDataRules(
304+
$dataRules,
305+
$propertyPath,
302306
$sourceRules[$sourceProperty]
303307
);
304308

@@ -331,6 +335,46 @@ protected function processInheritedValidation(
331335
}
332336
}
333337

338+
/**
339+
* Merge a new set of rules into the DataRules collection for a given path.
340+
*/
341+
protected function mergeRulesIntoDataRules(
342+
DataRules $dataRules,
343+
ValidationPath $path,
344+
array $rules
345+
): void {
346+
$key = $path->get();
347+
348+
if ($key === null) {
349+
return;
350+
}
351+
352+
$existing = $dataRules->rules[$key] ?? [];
353+
$merged = $this->mergeRuleSet($existing, $rules);
354+
355+
$dataRules->add($path, $merged);
356+
}
357+
358+
/**
359+
* Merge two rule arrays, avoiding duplicate string rules.
360+
*/
361+
protected function mergeRuleSet(array $existing, array $incoming): array
362+
{
363+
foreach ($incoming as $rule) {
364+
if (is_string($rule)) {
365+
if (! in_array($rule, $existing, true)) {
366+
$existing[] = $rule;
367+
}
368+
369+
continue;
370+
}
371+
372+
$existing[] = $rule;
373+
}
374+
375+
return $existing;
376+
}
377+
334378
public function getSchemaOverridesForClass(string $className): array
335379
{
336380
return $this->schemaOverridesByClass[$className] ?? [];
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\DataClasses;
6+
7+
use RomegaSoftware\LaravelSchemaGenerator\Attributes\InheritValidationFrom;
8+
use RomegaSoftware\LaravelSchemaGenerator\Attributes\ValidationSchema;
9+
use Spatie\LaravelData\Data;
10+
use Spatie\LaravelData\Support\Validation\ValidationContext;
11+
12+
#[ValidationSchema]
13+
class InheritedPostalCodeWithLocalRulesData extends Data
14+
{
15+
public function __construct(
16+
#[InheritValidationFrom(PostalCodeMethodRulesData::class, 'code')]
17+
public ?string $postal_code,
18+
) {}
19+
20+
/**
21+
* @return array<string, array<int, string>>
22+
*/
23+
public static function rules(?ValidationContext $validationContext = null): array
24+
{
25+
return [
26+
'postal_code' => ['required'],
27+
];
28+
}
29+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\DataClasses;
6+
7+
use RomegaSoftware\LaravelSchemaGenerator\Attributes\ValidationSchema;
8+
use Spatie\LaravelData\Data;
9+
use Spatie\LaravelData\Support\Validation\ValidationContext;
10+
11+
#[ValidationSchema]
12+
class PostalCodeMethodRulesData extends Data
13+
{
14+
public function __construct(
15+
public ?string $code,
16+
) {}
17+
18+
/**
19+
* @return array<string, array<int, string>>
20+
*/
21+
public static function rules(?ValidationContext $validationContext = null): array
22+
{
23+
return [
24+
'code' => ['string', 'regex:/^\\d{5}(-\\d{4})?$/'],
25+
];
26+
}
27+
}

tests/Integration/RuntimeInheritedValidationTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PHPUnit\Framework\Attributes\Test;
88
use RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\DataClasses\InheritedPostalCodeData;
99
use RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\DataClasses\InheritedPostalCodeRuntimeDisabledData;
10+
use RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\DataClasses\InheritedPostalCodeWithLocalRulesData;
1011
use RomegaSoftware\LaravelSchemaGenerator\Tests\TestCase;
1112
use Spatie\LaravelData\Resolvers\DataValidatorResolver;
1213

@@ -47,4 +48,27 @@ public function it_can_disable_runtime_inheritance_per_attribute(): void
4748
);
4849
$this->assertFalse($invalid->fails());
4950
}
51+
52+
#[Test]
53+
public function it_merges_local_rules_with_inherited_rules_at_runtime(): void
54+
{
55+
$resolver = $this->app->make(DataValidatorResolver::class);
56+
57+
$missing = $resolver->execute(InheritedPostalCodeWithLocalRulesData::class, []);
58+
$this->assertTrue($missing->fails());
59+
$this->assertArrayHasKey('Required', $missing->failed()['postal_code']);
60+
61+
$invalid = $resolver->execute(
62+
InheritedPostalCodeWithLocalRulesData::class,
63+
['postal_code' => '123']
64+
);
65+
$this->assertTrue($invalid->fails());
66+
$this->assertArrayHasKey('Regex', $invalid->failed()['postal_code']);
67+
68+
$valid = $resolver->execute(
69+
InheritedPostalCodeWithLocalRulesData::class,
70+
['postal_code' => '12345']
71+
);
72+
$this->assertFalse($valid->fails());
73+
}
5074
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace RomegaSoftware\LaravelSchemaGenerator\Tests\Integration;
6+
7+
use PHPUnit\Framework\Attributes\Test;
8+
use ReflectionClass;
9+
use RomegaSoftware\LaravelSchemaGenerator\Tests\Fixtures\DataClasses\InheritedPostalCodeWithLocalRulesData;
10+
use RomegaSoftware\LaravelSchemaGenerator\Tests\TestCase;
11+
use RomegaSoftware\LaravelSchemaGenerator\Tests\Traits\InteractsWithExtractors;
12+
13+
class SchemaInheritanceMergeTest extends TestCase
14+
{
15+
use InteractsWithExtractors;
16+
17+
#[Test]
18+
public function it_merges_local_rules_with_inherited_rules_for_schema_generation(): void
19+
{
20+
$extractor = $this->getDataExtractor();
21+
$extracted = $extractor->extract(new ReflectionClass(InheritedPostalCodeWithLocalRulesData::class));
22+
23+
$postalProperty = $extracted->properties->firstWhere('name', 'postal_code');
24+
25+
$this->assertNotNull($postalProperty);
26+
$this->assertFalse($postalProperty->isOptional);
27+
$this->assertTrue($postalProperty->validations?->isFieldRequired());
28+
}
29+
}

0 commit comments

Comments
 (0)