Skip to content

Commit 8916375

Browse files
authored
feat: Add pattern property feature (#2)
1 parent 458ee97 commit 8916375

File tree

3 files changed

+196
-16
lines changed

3 files changed

+196
-16
lines changed

README.md

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,63 @@ $schema->isValid(['123' => 'value']); // false
434434
$schema->isValid(['validKey' => 'value']); // true
435435
```
436436

437+
Objects also support pattern-based property validation using `patternProperties`:
438+
439+
```php
440+
use Cortex\JsonSchema\SchemaFactory;
441+
442+
$schema = SchemaFactory::object('config')
443+
// Add a single pattern property
444+
->patternProperty('^prefix_',
445+
SchemaFactory::string()->minLength(5)
446+
)
447+
// Add multiple pattern properties
448+
->patternProperties([
449+
'^[A-Z][a-z]+$' => SchemaFactory::string(), // CamelCase properties
450+
'^\d+$' => SchemaFactory::number(), // Numeric properties
451+
]);
452+
453+
// Valid data
454+
$schema->isValid([
455+
'prefix_hello' => 'world123', // Matches ^prefix_ and meets minLength
456+
'Name' => 'John', // Matches ^[A-Z][a-z]+$
457+
'123' => 42, // Matches ^\d+$
458+
]); // true
459+
460+
// Invalid data
461+
$schema->isValid([
462+
'prefix_hi' => 'hi', // Too short for minLength
463+
'invalid' => 'no pattern', // Doesn't match any pattern
464+
'123' => 'not a number', // Wrong type for pattern
465+
]); // false
466+
```
467+
468+
Pattern properties can be combined with regular properties and `additionalProperties`:
469+
470+
```php
471+
$schema = SchemaFactory::object('user')
472+
->properties(
473+
SchemaFactory::string('name')->required(),
474+
SchemaFactory::integer('age')->required(),
475+
)
476+
->patternProperty('^custom_', SchemaFactory::string())
477+
->additionalProperties(false);
478+
479+
// Valid:
480+
$schema->isValid([
481+
'name' => 'John',
482+
'age' => 30,
483+
'custom_field' => 'value', // Matches pattern
484+
]);
485+
486+
// Invalid (property doesn't match pattern):
487+
$schema->isValid([
488+
'name' => 'John',
489+
'age' => 30,
490+
'invalid_field' => 'value',
491+
]);
492+
```
493+
437494
<details>
438495
<summary>View JSON Schema</summary>
439496

@@ -444,25 +501,18 @@ $schema->isValid(['validKey' => 'value']); // true
444501
"title": "user",
445502
"properties": {
446503
"name": {
447-
"type": "string",
448-
"title": "name"
449-
},
450-
"email": {
451-
"type": "string",
452-
"title": "email"
504+
"type": "string"
453505
},
454-
"settings": {
455-
"type": "object",
456-
"title": "settings",
457-
"properties": {
458-
"theme": {
459-
"type": "string",
460-
"title": "theme"
461-
}
462-
}
506+
"age": {
507+
"type": "integer"
508+
}
509+
},
510+
"patternProperties": {
511+
"^custom_": {
512+
"type": "string"
463513
}
464514
},
465-
"required": ["name", "email"],
515+
"required": ["name", "age"],
466516
"additionalProperties": false
467517
}
468518
```

src/Types/Concerns/HasProperties.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ trait HasProperties
2828

2929
protected ?Schema $propertyNames = null;
3030

31+
/**
32+
* @var array<string, \Cortex\JsonSchema\Contracts\Schema>
33+
*/
34+
protected array $patternProperties = [];
35+
3136
/**
3237
* Set properties.
3338
*
@@ -112,6 +117,39 @@ public function propertyNames(Schema $schema): static
112117
return $this;
113118
}
114119

120+
/**
121+
* Add a pattern property schema.
122+
*
123+
* @throws \Cortex\JsonSchema\Exceptions\SchemaException
124+
*/
125+
public function patternProperty(string $pattern, Schema $schema): static
126+
{
127+
// Validate the pattern is a valid regex
128+
if (@preg_match('/' . $pattern . '/', '') === false) {
129+
throw new SchemaException('Invalid pattern: ' . $pattern);
130+
}
131+
132+
$this->patternProperties[$pattern] = $schema;
133+
134+
return $this;
135+
}
136+
137+
/**
138+
* Add multiple pattern property schemas.
139+
*
140+
* @param array<string, \Cortex\JsonSchema\Contracts\Schema> $patterns
141+
*
142+
* @throws \Cortex\JsonSchema\Exceptions\SchemaException
143+
*/
144+
public function patternProperties(array $patterns): static
145+
{
146+
foreach ($patterns as $pattern => $schema) {
147+
$this->patternProperty($pattern, $schema);
148+
}
149+
150+
return $this;
151+
}
152+
115153
/**
116154
* @return array<int, string>
117155
*/
@@ -137,6 +175,14 @@ protected function addPropertiesToSchema(array $schema): array
137175
}
138176
}
139177

178+
if ($this->patternProperties !== []) {
179+
$schema['patternProperties'] = [];
180+
181+
foreach ($this->patternProperties as $pattern => $prop) {
182+
$schema['patternProperties'][$pattern] = $prop->toArray(includeSchemaRef: false, includeTitle: false);
183+
}
184+
}
185+
140186
if ($this->requiredProperties !== []) {
141187
$schema['required'] = array_values(array_unique($this->requiredProperties));
142188
}

tests/Unit/Targets/ObjectSchemaTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Cortex\JsonSchema\Tests\Unit;
66

77
use Cortex\JsonSchema\Enums\SchemaFormat;
8+
use Cortex\JsonSchema\Types\ObjectSchema;
89
use Opis\JsonSchema\Errors\ValidationError;
910
use Cortex\JsonSchema\SchemaFactory as Schema;
1011
use Cortex\JsonSchema\Exceptions\SchemaException;
@@ -196,3 +197,86 @@
196197
'name' => 123, // invalid property name pattern
197198
]))->toThrow(SchemaException::class, 'The properties must match schema: name');
198199
});
200+
201+
it('can create an object schema with pattern properties', function (): void {
202+
$schema = Schema::object('config')
203+
->patternProperty('^prefix_', Schema::string()->minLength(5))
204+
->patternProperties([
205+
'^[A-Z][a-z]+$' => Schema::string(),
206+
'^\d+$' => Schema::number(),
207+
]);
208+
209+
$schemaArray = $schema->toArray();
210+
211+
// Check schema structure
212+
expect($schemaArray)->toHaveKey('patternProperties');
213+
expect($schemaArray['patternProperties'])->toHaveKey('^prefix_');
214+
expect($schemaArray['patternProperties'])->toHaveKey('^[A-Z][a-z]+$');
215+
expect($schemaArray['patternProperties'])->toHaveKey('^\d+$');
216+
217+
// Valid data tests
218+
expect(fn() => $schema->validate([
219+
'prefix_hello' => 'world123', // Matches ^prefix_ and meets minLength
220+
'Name' => 'John', // Matches ^[A-Z][a-z]+$
221+
'123' => 42, // Matches ^\d+$
222+
]))->not->toThrow(SchemaException::class);
223+
224+
// Invalid pattern property value (too short)
225+
expect(fn() => $schema->validate([
226+
'prefix_hello' => 'hi', // Matches pattern but fails minLength
227+
]))->toThrow(SchemaException::class);
228+
229+
// Invalid pattern property type
230+
expect(fn() => $schema->validate([
231+
'123' => 'not a number', // Matches pattern but wrong type
232+
]))->toThrow(SchemaException::class);
233+
});
234+
235+
it('throws exception for invalid regex patterns', function (): void {
236+
$schema = Schema::object('test');
237+
238+
expect(fn(): ObjectSchema => $schema->patternProperty('[a-z', Schema::string()))
239+
->toThrow(SchemaException::class, 'Invalid pattern: [a-z');
240+
241+
expect(fn(): ObjectSchema => $schema->patternProperties([
242+
'^valid$' => Schema::string(),
243+
'[a-z' => Schema::string(),
244+
]))->toThrow(SchemaException::class, 'Invalid pattern: [a-z');
245+
});
246+
247+
it('can combine pattern properties with regular properties', function (): void {
248+
$schema = Schema::object('user')
249+
->properties(
250+
Schema::string('name')->required(),
251+
Schema::integer('age')->required(),
252+
)
253+
->patternProperty('^custom_', Schema::string())
254+
->additionalProperties(false);
255+
256+
$schemaArray = $schema->toArray();
257+
258+
// Check schema structure
259+
expect($schemaArray)->toHaveKey('properties');
260+
expect($schemaArray)->toHaveKey('patternProperties');
261+
expect($schemaArray)->toHaveKey('additionalProperties', false);
262+
263+
// Valid data
264+
expect(fn() => $schema->validate([
265+
'name' => 'John',
266+
'age' => 30,
267+
'custom_field' => 'value',
268+
]))->not->toThrow(SchemaException::class);
269+
270+
// Missing required property
271+
expect(fn() => $schema->validate([
272+
'name' => 'John',
273+
'custom_field' => 'value',
274+
]))->toThrow(SchemaException::class);
275+
276+
// Invalid additional property (doesn't match pattern)
277+
expect(fn() => $schema->validate([
278+
'name' => 'John',
279+
'age' => 30,
280+
'invalid_field' => 'value',
281+
]))->toThrow(SchemaException::class);
282+
});

0 commit comments

Comments
 (0)