Skip to content

Commit 23d4c07

Browse files
committed
feat: Add dedicated constraint attributes
- Add modular constraint attributes: Pattern, Length, Range, MultipleOf, Items, Enum - Create AttributeConstraintExtractor for attribute-to-validation-rule conversion - Add comprehensive test
1 parent f1c0502 commit 23d4c07

File tree

16 files changed

+861
-0
lines changed

16 files changed

+861
-0
lines changed

src/Attribute/Constraint/Enum.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Attribute\Constraint;
6+
7+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
8+
readonly class Enum
9+
{
10+
public function __construct(
11+
public array $values,
12+
) {}
13+
}

src/Attribute/Constraint/Items.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Attribute\Constraint;
6+
7+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
8+
readonly class Items
9+
{
10+
public function __construct(
11+
public ?int $min = null,
12+
public ?int $max = null,
13+
public ?bool $unique = null,
14+
) {}
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Attribute\Constraint;
6+
7+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
8+
readonly class Length
9+
{
10+
public function __construct(
11+
public ?int $min = null,
12+
public ?int $max = null,
13+
) {}
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Attribute\Constraint;
6+
7+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
8+
readonly class MultipleOf
9+
{
10+
public function __construct(
11+
public int|float $value,
12+
) {}
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Attribute\Constraint;
6+
7+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
8+
readonly class Pattern
9+
{
10+
public function __construct(
11+
public string $pattern,
12+
) {}
13+
}

src/Attribute/Constraint/Range.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Attribute\Constraint;
6+
7+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
8+
readonly class Range
9+
{
10+
public function __construct(
11+
public int|float|null $min = null,
12+
public int|float|null $max = null,
13+
public ?bool $exclusiveMin = null,
14+
public ?bool $exclusiveMax = null,
15+
) {}
16+
}

src/Generator.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,22 @@
1515
use Spiral\JsonSchemaGenerator\Schema\Property;
1616
use Spiral\JsonSchemaGenerator\Schema\PropertyType;
1717
use Spiral\JsonSchemaGenerator\Validation\ValidationConstraintExtractor;
18+
use Spiral\JsonSchemaGenerator\Validation\AttributeConstraintExtractor;
1819

1920
final class Generator implements GeneratorInterface
2021
{
2122
protected array $cache = [];
2223
private readonly ValidationConstraintExtractor $validationExtractor;
24+
private readonly AttributeConstraintExtractor $attributeExtractor;
2325

2426
public function __construct(
2527
protected readonly ParserInterface $parser = new Parser(),
2628
?ValidationConstraintExtractor $validationExtractor = null,
2729
protected readonly GeneratorConfig $config = new GeneratorConfig(),
30+
?AttributeConstraintExtractor $attributeExtractor = null,
2831
) {
2932
$this->validationExtractor = $validationExtractor ?? new ValidationConstraintExtractor();
33+
$this->attributeExtractor = $attributeExtractor ?? new AttributeConstraintExtractor();
3034
}
3135

3236
/**
@@ -132,6 +136,10 @@ protected function generateProperty(PropertyInterface $property): ?Property
132136
$validationRules = [];
133137
if ($this->config->enableValidationConstraints) {
134138
$validationRules = $this->extractValidationConstraints($property, $propertyTypes);
139+
$validationRules = \array_merge(
140+
$validationRules,
141+
$this->extractAttributeConstraints($property, $propertyTypes),
142+
);
135143
}
136144

137145
return new Property(
@@ -179,4 +187,21 @@ private function extractValidationConstraints(PropertyInterface $property, array
179187

180188
return $allValidationRules;
181189
}
190+
191+
/**
192+
* Extract validation constraints from property attributes
193+
*/
194+
private function extractAttributeConstraints(PropertyInterface $property, array $propertyTypes): array
195+
{
196+
$allValidationRules = [];
197+
198+
foreach ($propertyTypes as $propertyType) {
199+
if ($propertyType->type instanceof Schema\Type) {
200+
$validationRules = $this->attributeExtractor->extractValidationRules($property, $propertyType->type);
201+
$allValidationRules = \array_merge($allValidationRules, $validationRules);
202+
}
203+
}
204+
205+
return $allValidationRules;
206+
}
182207
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Validation;
6+
7+
use Spiral\JsonSchemaGenerator\Attribute\Constraint\Enum;
8+
use Spiral\JsonSchemaGenerator\Attribute\Constraint\Items;
9+
use Spiral\JsonSchemaGenerator\Attribute\Constraint\Length;
10+
use Spiral\JsonSchemaGenerator\Attribute\Constraint\MultipleOf;
11+
use Spiral\JsonSchemaGenerator\Attribute\Constraint\Pattern;
12+
use Spiral\JsonSchemaGenerator\Attribute\Constraint\Range;
13+
use Spiral\JsonSchemaGenerator\Parser\PropertyInterface;
14+
use Spiral\JsonSchemaGenerator\Schema\Type;
15+
16+
/**
17+
* @internal
18+
*/
19+
final readonly class AttributeConstraintExtractor
20+
{
21+
public function extractValidationRules(PropertyInterface $property, Type $jsonSchemaType): array
22+
{
23+
$validationRules = [];
24+
25+
// Extract Pattern constraint
26+
$pattern = $property->findAttribute(Pattern::class);
27+
if ($pattern instanceof Pattern) {
28+
$validationRules['pattern'] = $pattern->pattern;
29+
}
30+
31+
// Extract Length constraint
32+
$length = $property->findAttribute(Length::class);
33+
if ($length instanceof Length) {
34+
if ($length->min !== null) {
35+
$validationRules[$this->getLengthMinKey($jsonSchemaType)] = $length->min;
36+
}
37+
if ($length->max !== null) {
38+
$validationRules[$this->getLengthMaxKey($jsonSchemaType)] = $length->max;
39+
}
40+
}
41+
42+
// Extract Range constraint
43+
$range = $property->findAttribute(Range::class);
44+
if ($range instanceof Range) {
45+
if ($range->min !== null) {
46+
$key = $range->exclusiveMin === true ? 'exclusiveMinimum' : 'minimum';
47+
$validationRules[$key] = $range->min;
48+
}
49+
if ($range->max !== null) {
50+
$key = $range->exclusiveMax === true ? 'exclusiveMaximum' : 'maximum';
51+
$validationRules[$key] = $range->max;
52+
}
53+
}
54+
55+
// Extract MultipleOf constraint
56+
$multipleOf = $property->findAttribute(MultipleOf::class);
57+
if ($multipleOf instanceof MultipleOf && $this->isNumericType($jsonSchemaType)) {
58+
$validationRules['multipleOf'] = $multipleOf->value;
59+
}
60+
61+
// Extract Items constraint
62+
$items = $property->findAttribute(Items::class);
63+
if ($items instanceof Items && $jsonSchemaType === Type::Array) {
64+
if ($items->min !== null) {
65+
$validationRules['minItems'] = $items->min;
66+
}
67+
if ($items->max !== null) {
68+
$validationRules['maxItems'] = $items->max;
69+
}
70+
if ($items->unique === true) {
71+
$validationRules['uniqueItems'] = true;
72+
}
73+
}
74+
75+
// Extract Enum constraint
76+
$enum = $property->findAttribute(Enum::class);
77+
if ($enum instanceof Enum) {
78+
$validationRules['enum'] = $enum->values;
79+
}
80+
81+
return $validationRules;
82+
}
83+
84+
private function getLengthMinKey(Type $jsonSchemaType): string
85+
{
86+
return match ($jsonSchemaType) {
87+
Type::String => 'minLength',
88+
Type::Array => 'minItems',
89+
default => 'minLength', // fallback
90+
};
91+
}
92+
93+
private function getLengthMaxKey(Type $jsonSchemaType): string
94+
{
95+
return match ($jsonSchemaType) {
96+
Type::String => 'maxLength',
97+
Type::Array => 'maxItems',
98+
default => 'maxLength', // fallback
99+
};
100+
}
101+
102+
private function isNumericType(Type $jsonSchemaType): bool
103+
{
104+
return $jsonSchemaType === Type::Integer || $jsonSchemaType === Type::Number;
105+
}
106+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Tests\Unit\Attribute\Constraint;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Spiral\JsonSchemaGenerator\Attribute\Constraint\Enum;
9+
10+
final class EnumTest extends TestCase
11+
{
12+
public function testEnumWithStringValues(): void
13+
{
14+
$enum = new Enum(['active', 'inactive', 'pending']);
15+
16+
$this->assertSame(['active', 'inactive', 'pending'], $enum->values);
17+
}
18+
19+
public function testEnumWithIntegerValues(): void
20+
{
21+
$enum = new Enum([1, 2, 3, 5, 8]);
22+
23+
$this->assertSame([1, 2, 3, 5, 8], $enum->values);
24+
}
25+
26+
public function testEnumWithMixedValues(): void
27+
{
28+
$enum = new Enum(['draft', 1, 'published', 2]);
29+
30+
$this->assertSame(['draft', 1, 'published', 2], $enum->values);
31+
}
32+
33+
public function testEnumWithSingleValue(): void
34+
{
35+
$enum = new Enum(['only']);
36+
37+
$this->assertSame(['only'], $enum->values);
38+
}
39+
40+
public function testEnumWithEmptyArray(): void
41+
{
42+
$enum = new Enum([]);
43+
44+
$this->assertSame([], $enum->values);
45+
}
46+
47+
public function testEnumWithBooleanValues(): void
48+
{
49+
$enum = new Enum([true, false]);
50+
51+
$this->assertSame([true, false], $enum->values);
52+
}
53+
54+
public function testEnumWithFloatValues(): void
55+
{
56+
$enum = new Enum([1.5, 2.0, 3.14]);
57+
58+
$this->assertSame([1.5, 2.0, 3.14], $enum->values);
59+
}
60+
61+
public function testEnumWithNullValue(): void
62+
{
63+
$enum = new Enum([null, 'value']);
64+
65+
$this->assertSame([null, 'value'], $enum->values);
66+
}
67+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Tests\Unit\Attribute\Constraint;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Spiral\JsonSchemaGenerator\Attribute\Constraint\Items;
9+
10+
final class ItemsTest extends TestCase
11+
{
12+
public function testItemsWithAllConstraints(): void
13+
{
14+
$items = new Items(min: 2, max: 10, unique: true);
15+
16+
$this->assertSame(2, $items->min);
17+
$this->assertSame(10, $items->max);
18+
$this->assertTrue($items->unique);
19+
}
20+
21+
public function testItemsWithOnlyMin(): void
22+
{
23+
$items = new Items(min: 1);
24+
25+
$this->assertSame(1, $items->min);
26+
$this->assertNull($items->max);
27+
$this->assertNull($items->unique);
28+
}
29+
30+
public function testItemsWithOnlyMax(): void
31+
{
32+
$items = new Items(max: 50);
33+
34+
$this->assertNull($items->min);
35+
$this->assertSame(50, $items->max);
36+
$this->assertNull($items->unique);
37+
}
38+
39+
public function testItemsWithOnlyUnique(): void
40+
{
41+
$items = new Items(unique: true);
42+
43+
$this->assertNull($items->min);
44+
$this->assertNull($items->max);
45+
$this->assertTrue($items->unique);
46+
}
47+
48+
public function testItemsWithUniqueSetToFalse(): void
49+
{
50+
$items = new Items(unique: false);
51+
52+
$this->assertFalse($items->unique);
53+
}
54+
55+
public function testItemsWithNoParameters(): void
56+
{
57+
$items = new Items();
58+
59+
$this->assertNull($items->min);
60+
$this->assertNull($items->max);
61+
$this->assertNull($items->unique);
62+
}
63+
64+
public function testItemsWithNamedArguments(): void
65+
{
66+
$items = new Items(unique: true, max: 5, min: 1);
67+
68+
$this->assertSame(1, $items->min);
69+
$this->assertSame(5, $items->max);
70+
$this->assertTrue($items->unique);
71+
}
72+
}

0 commit comments

Comments
 (0)