Skip to content

Commit

Permalink
feat: Add pattern property feature (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
tymondesigns authored Jan 21, 2025
1 parent 458ee97 commit 8916375
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 16 deletions.
82 changes: 66 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,63 @@ $schema->isValid(['123' => 'value']); // false
$schema->isValid(['validKey' => 'value']); // true
```

Objects also support pattern-based property validation using `patternProperties`:

```php
use Cortex\JsonSchema\SchemaFactory;

$schema = SchemaFactory::object('config')
// Add a single pattern property
->patternProperty('^prefix_',
SchemaFactory::string()->minLength(5)
)
// Add multiple pattern properties
->patternProperties([
'^[A-Z][a-z]+$' => SchemaFactory::string(), // CamelCase properties
'^\d+$' => SchemaFactory::number(), // Numeric properties
]);

// Valid data
$schema->isValid([
'prefix_hello' => 'world123', // Matches ^prefix_ and meets minLength
'Name' => 'John', // Matches ^[A-Z][a-z]+$
'123' => 42, // Matches ^\d+$
]); // true

// Invalid data
$schema->isValid([
'prefix_hi' => 'hi', // Too short for minLength
'invalid' => 'no pattern', // Doesn't match any pattern
'123' => 'not a number', // Wrong type for pattern
]); // false
```

Pattern properties can be combined with regular properties and `additionalProperties`:

```php
$schema = SchemaFactory::object('user')
->properties(
SchemaFactory::string('name')->required(),
SchemaFactory::integer('age')->required(),
)
->patternProperty('^custom_', SchemaFactory::string())
->additionalProperties(false);

// Valid:
$schema->isValid([
'name' => 'John',
'age' => 30,
'custom_field' => 'value', // Matches pattern
]);

// Invalid (property doesn't match pattern):
$schema->isValid([
'name' => 'John',
'age' => 30,
'invalid_field' => 'value',
]);
```

<details>
<summary>View JSON Schema</summary>

Expand All @@ -444,25 +501,18 @@ $schema->isValid(['validKey' => 'value']); // true
"title": "user",
"properties": {
"name": {
"type": "string",
"title": "name"
},
"email": {
"type": "string",
"title": "email"
"type": "string"
},
"settings": {
"type": "object",
"title": "settings",
"properties": {
"theme": {
"type": "string",
"title": "theme"
}
}
"age": {
"type": "integer"
}
},
"patternProperties": {
"^custom_": {
"type": "string"
}
},
"required": ["name", "email"],
"required": ["name", "age"],
"additionalProperties": false
}
```
Expand Down
46 changes: 46 additions & 0 deletions src/Types/Concerns/HasProperties.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ trait HasProperties

protected ?Schema $propertyNames = null;

/**
* @var array<string, \Cortex\JsonSchema\Contracts\Schema>
*/
protected array $patternProperties = [];

/**
* Set properties.
*
Expand Down Expand Up @@ -112,6 +117,39 @@ public function propertyNames(Schema $schema): static
return $this;
}

/**
* Add a pattern property schema.
*
* @throws \Cortex\JsonSchema\Exceptions\SchemaException
*/
public function patternProperty(string $pattern, Schema $schema): static
{
// Validate the pattern is a valid regex
if (@preg_match('/' . $pattern . '/', '') === false) {
throw new SchemaException('Invalid pattern: ' . $pattern);
}

$this->patternProperties[$pattern] = $schema;

return $this;
}

/**
* Add multiple pattern property schemas.
*
* @param array<string, \Cortex\JsonSchema\Contracts\Schema> $patterns
*
* @throws \Cortex\JsonSchema\Exceptions\SchemaException
*/
public function patternProperties(array $patterns): static
{
foreach ($patterns as $pattern => $schema) {
$this->patternProperty($pattern, $schema);
}

return $this;
}

/**
* @return array<int, string>
*/
Expand All @@ -137,6 +175,14 @@ protected function addPropertiesToSchema(array $schema): array
}
}

if ($this->patternProperties !== []) {
$schema['patternProperties'] = [];

foreach ($this->patternProperties as $pattern => $prop) {
$schema['patternProperties'][$pattern] = $prop->toArray(includeSchemaRef: false, includeTitle: false);
}
}

if ($this->requiredProperties !== []) {
$schema['required'] = array_values(array_unique($this->requiredProperties));
}
Expand Down
84 changes: 84 additions & 0 deletions tests/Unit/Targets/ObjectSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Cortex\JsonSchema\Tests\Unit;

use Cortex\JsonSchema\Enums\SchemaFormat;
use Cortex\JsonSchema\Types\ObjectSchema;
use Opis\JsonSchema\Errors\ValidationError;
use Cortex\JsonSchema\SchemaFactory as Schema;
use Cortex\JsonSchema\Exceptions\SchemaException;
Expand Down Expand Up @@ -196,3 +197,86 @@
'name' => 123, // invalid property name pattern
]))->toThrow(SchemaException::class, 'The properties must match schema: name');
});

it('can create an object schema with pattern properties', function (): void {
$schema = Schema::object('config')
->patternProperty('^prefix_', Schema::string()->minLength(5))
->patternProperties([
'^[A-Z][a-z]+$' => Schema::string(),
'^\d+$' => Schema::number(),
]);

$schemaArray = $schema->toArray();

// Check schema structure
expect($schemaArray)->toHaveKey('patternProperties');
expect($schemaArray['patternProperties'])->toHaveKey('^prefix_');
expect($schemaArray['patternProperties'])->toHaveKey('^[A-Z][a-z]+$');
expect($schemaArray['patternProperties'])->toHaveKey('^\d+$');

// Valid data tests
expect(fn() => $schema->validate([
'prefix_hello' => 'world123', // Matches ^prefix_ and meets minLength
'Name' => 'John', // Matches ^[A-Z][a-z]+$
'123' => 42, // Matches ^\d+$
]))->not->toThrow(SchemaException::class);

// Invalid pattern property value (too short)
expect(fn() => $schema->validate([
'prefix_hello' => 'hi', // Matches pattern but fails minLength
]))->toThrow(SchemaException::class);

// Invalid pattern property type
expect(fn() => $schema->validate([
'123' => 'not a number', // Matches pattern but wrong type
]))->toThrow(SchemaException::class);
});

it('throws exception for invalid regex patterns', function (): void {
$schema = Schema::object('test');

expect(fn(): ObjectSchema => $schema->patternProperty('[a-z', Schema::string()))
->toThrow(SchemaException::class, 'Invalid pattern: [a-z');

expect(fn(): ObjectSchema => $schema->patternProperties([
'^valid$' => Schema::string(),
'[a-z' => Schema::string(),
]))->toThrow(SchemaException::class, 'Invalid pattern: [a-z');
});

it('can combine pattern properties with regular properties', function (): void {
$schema = Schema::object('user')
->properties(
Schema::string('name')->required(),
Schema::integer('age')->required(),
)
->patternProperty('^custom_', Schema::string())
->additionalProperties(false);

$schemaArray = $schema->toArray();

// Check schema structure
expect($schemaArray)->toHaveKey('properties');
expect($schemaArray)->toHaveKey('patternProperties');
expect($schemaArray)->toHaveKey('additionalProperties', false);

// Valid data
expect(fn() => $schema->validate([
'name' => 'John',
'age' => 30,
'custom_field' => 'value',
]))->not->toThrow(SchemaException::class);

// Missing required property
expect(fn() => $schema->validate([
'name' => 'John',
'custom_field' => 'value',
]))->toThrow(SchemaException::class);

// Invalid additional property (doesn't match pattern)
expect(fn() => $schema->validate([
'name' => 'John',
'age' => 30,
'invalid_field' => 'value',
]))->toThrow(SchemaException::class);
});

0 comments on commit 8916375

Please sign in to comment.