From 66785197c761751b28099b42d5fbd156920504f4 Mon Sep 17 00:00:00 2001 From: ahmadalguydi Date: Thu, 6 Aug 2026 12:16:15 +0300 Subject: [PATCH 1/4] feat: support unevaluated properties in draft 2019 Updates #907 --- src/JsonSchema/ConstraintError.php | 2 + .../Drafts/Draft2019/Draft2019Constraint.php | 1 + .../Constraints/Drafts/Draft2019/Factory.php | 1 + .../UnevaluatedPropertiesConstraint.php | 105 ++++++++++++++++++ .../Constraints/UnevaluatedPropertiesTest.php | 44 ++++++++ 5 files changed, 153 insertions(+) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php create mode 100644 tests/Constraints/UnevaluatedPropertiesTest.php diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index 081e38ad..07b489a9 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -60,6 +60,7 @@ class ConstraintError extends Enum public const PROPERTY_NAMES = 'propertyNames'; public const TYPE = 'type'; public const UNIQUE_ITEMS = 'uniqueItems'; + public const UNEVALUATED_PROPERTIES = 'unevaluatedProperties'; public const CONTENT_MEDIA_TYPE = 'contentMediaType'; public const CONTENT_ENCODING = 'contentEncoding'; @@ -122,6 +123,7 @@ public function getMessage() self::PROPERTY_NAMES => 'Property name %s is invalid', self::TYPE => '%s value found, but %s is required', self::UNIQUE_ITEMS => 'There are no duplicates allowed in the array', + self::UNEVALUATED_PROPERTIES => 'The property %s is not evaluated and the definition does not allow unevaluated properties', self::CONTENT_MEDIA_TYPE => 'Value is not valid with content media type', self::CONTENT_ENCODING => 'Value is not valid with content encoding', ]; diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php index 0b003b54..da459bb7 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php @@ -46,6 +46,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('anyOf', $value, $schema, $path, $i); $this->checkForKeyword('oneOf', $value, $schema, $path, $i); $this->checkForKeyword('ifThenElse', $value, $schema, $path, $i); + $this->checkForKeyword('unevaluatedProperties', $value, $schema, $path, $i); $this->checkForKeyword('additionalProperties', $value, $schema, $path, $i); $this->checkForKeyword('items', $value, $schema, $path, $i); diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php index cc490225..0c8e9f17 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php @@ -12,6 +12,7 @@ class Factory extends \JsonSchema\Constraints\Factory protected $constraintMap = [ 'schema' => Draft2019Constraint::class, 'additionalProperties' => AdditionalPropertiesConstraint::class, + 'unevaluatedProperties' => UnevaluatedPropertiesConstraint::class, 'additionalItems' => AdditionalItemsConstraint::class, 'dependentSchemas' => DependentSchemasConstraint::class, 'dependentRequired' => DependentRequiredConstraint::class, diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php new file mode 100644 index 00000000..52ace5b7 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php @@ -0,0 +1,105 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'unevaluatedProperties') || !is_object($value)) { + return; + } + + if ($schema->unevaluatedProperties === true) { + return; + } + + $evaluated = $this->collectEvaluatedProperties($schema, $value); + $unevaluated = array_diff_key(get_object_vars($value), array_flip($evaluated)); + if (!$unevaluated) { + return; + } + + $basePath = $path ?? new JsonPointer(''); + foreach ($unevaluated as $propertyName => $propertyValue) { + $propertyPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$propertyName])); + + if (is_object($schema->unevaluatedProperties)) { + $propertyConstraint = $this->factory->createInstanceFor('schema'); + $propertyConstraint->check($propertyValue, $schema->unevaluatedProperties, $propertyPath, $i); + if ($propertyConstraint->isValid()) { + continue; + } + + $this->addErrors($propertyConstraint->getErrors()); + continue; + } + + $this->addError(ConstraintError::UNEVALUATED_PROPERTIES(), $propertyPath, ['found' => $propertyName]); + } + } + + /** + * @return array + */ + private function collectEvaluatedProperties($schema, object $value): array + { + $evaluated = []; + + if (isset($schema->properties) && is_object($schema->properties)) { + $evaluated = array_merge($evaluated, array_keys(get_object_vars($schema->properties))); + } + + if (isset($schema->patternProperties) && is_object($schema->patternProperties)) { + foreach (get_object_vars($value) as $propertyName => $_) { + foreach (array_keys(get_object_vars($schema->patternProperties)) as $pattern) { + if (preg_match($this->createPregMatchPattern($pattern), (string) $propertyName)) { + $evaluated[] = $propertyName; + break; + } + } + } + } + + if (isset($schema->allOf) && is_array($schema->allOf)) { + foreach ($schema->allOf as $branch) { + $evaluated = array_merge($evaluated, $this->collectEvaluatedProperties($branch, $value)); + } + } + + return array_values(array_unique($evaluated)); + } + + private function createPregMatchPattern(string $pattern): string + { + $pattern = str_replace('\\p{digit}', '\\p{Nd}', $pattern); + $pattern = str_replace('\\p{Letter}', '\\p{L}', $pattern); + + return '/' . str_replace('/', '\\/', $pattern) . '/u'; + } +} diff --git a/tests/Constraints/UnevaluatedPropertiesTest.php b/tests/Constraints/UnevaluatedPropertiesTest.php new file mode 100644 index 00000000..eb760deb --- /dev/null +++ b/tests/Constraints/UnevaluatedPropertiesTest.php @@ -0,0 +1,44 @@ + Date: Fri, 7 Aug 2026 10:07:59 +0300 Subject: [PATCH 2/4] test: run unevaluated properties in draft 2019 mode --- .../Drafts/Draft2019/UnevaluatedPropertiesConstraint.php | 8 +++++++- tests/Constraints/UnevaluatedPropertiesTest.php | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php index 52ace5b7..3d61327b 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php @@ -31,7 +31,7 @@ public function __construct(?Factory $factory = null) public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void { - if (!property_exists($schema, 'unevaluatedProperties') || !is_object($value)) { + if (!is_object($schema) || !property_exists($schema, 'unevaluatedProperties') || !is_object($value)) { return; } @@ -65,10 +65,16 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } /** + * @param object $schema + * @param object $value * @return array */ private function collectEvaluatedProperties($schema, object $value): array { + if (!is_object($schema)) { + return []; + } + $evaluated = []; if (isset($schema->properties) && is_object($schema->properties)) { diff --git a/tests/Constraints/UnevaluatedPropertiesTest.php b/tests/Constraints/UnevaluatedPropertiesTest.php index eb760deb..d8929aeb 100644 --- a/tests/Constraints/UnevaluatedPropertiesTest.php +++ b/tests/Constraints/UnevaluatedPropertiesTest.php @@ -5,6 +5,7 @@ namespace JsonSchema\Tests\Constraints; use JsonSchema\DraftIdentifiers; +use JsonSchema\Constraints\Constraint; class UnevaluatedPropertiesTest extends BaseTestCase { @@ -23,6 +24,7 @@ public function getInvalidTests(): \Generator {"properties":{"world":{"type":"string"}},"required":["world"]} ] }', + Constraint::CHECK_MODE_STRICT, ]; } @@ -39,6 +41,7 @@ public function getValidTests(): \Generator {"properties":{"world":{"type":"string"}},"required":["world"]} ] }', + Constraint::CHECK_MODE_STRICT, ]; } } From 4bbddfa969477d02300f1c707350232af96794bc Mon Sep 17 00:00:00 2001 From: ahmadalguydi Date: Fri, 7 Aug 2026 10:33:17 +0300 Subject: [PATCH 3/4] style: align unevaluated properties constraint --- .../Drafts/Draft2019/UnevaluatedPropertiesConstraint.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php index 3d61327b..22287725 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php @@ -29,7 +29,7 @@ public function __construct(?Factory $factory = null) $this->initialiseErrorBag($this->factory); } - public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + public function check(& $value, $schema = null, ?JsonPointer $path = null, $i = null): void { if (!is_object($schema) || !property_exists($schema, 'unevaluatedProperties') || !is_object($value)) { return; @@ -67,6 +67,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n /** * @param object $schema * @param object $value + * * @return array */ private function collectEvaluatedProperties($schema, object $value): array From f6bf33a72344cd6b5b48e0ee2019895e7bca34c0 Mon Sep 17 00:00:00 2001 From: ahmadalguydi Date: Fri, 7 Aug 2026 10:52:31 +0300 Subject: [PATCH 4/4] style: match project reference spacing --- .../Drafts/Draft2019/UnevaluatedPropertiesConstraint.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php index 22287725..0d7d40fd 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedPropertiesConstraint.php @@ -29,7 +29,7 @@ public function __construct(?Factory $factory = null) $this->initialiseErrorBag($this->factory); } - public function check(& $value, $schema = null, ?JsonPointer $path = null, $i = null): void + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void { if (!is_object($schema) || !property_exists($schema, 'unevaluatedProperties') || !is_object($value)) { return;