From 71c0244f6301538877eca3765becff143f1b649b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Ondr=C3=A1=C4=8Dek?= Date: Sat, 17 Oct 2026 13:39:41 +0200 Subject: [PATCH] feat: Support unevaluatedItems in Draft 2019-09 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unevaluatedItems keyword was ignored, so arrays with items that no other keyword evaluated passed validation. In Draft 2019-09 the evaluated items always form a prefix of the array, so the constraint derives how many leading items are evaluated by items, additionalItems, nested unevaluatedItems and the valid branches of $ref, allOf, anyOf, oneOf and if/then/else. The remaining items are validated against the unevaluatedItems schema or reported as unevaluated. The JSON Schema Test Suite cases for unevaluatedItems are no longer skipped, except those relying on $recursiveRef. The ref.json case "$ref with $recursiveAnchor: extra items allowed for inner arrays" is skipped now, as $ref "#" resolves against the root schema instead of the embedded $id resource. Signed-off-by: Roman Ondráček --- src/JsonSchema/ConstraintError.php | 2 + .../Drafts/Draft2019/Draft2019Constraint.php | 1 + .../Constraints/Drafts/Draft2019/Factory.php | 1 + .../Draft2019/UnevaluatedItemsConstraint.php | 162 ++++++++++++++++++ .../Drafts/Draft2019/UnevaluatedItemsTest.php | 91 ++++++++++ tests/JsonSchemaTestSuiteTest.php | 21 +-- 6 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedItemsConstraint.php create mode 100644 tests/Constraints/Drafts/Draft2019/UnevaluatedItemsTest.php diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index 95145617f..657fdbd4d 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -62,6 +62,7 @@ class ConstraintError extends Enum public const PROPERTY_NAMES = 'propertyNames'; public const TYPE = 'type'; public const UNIQUE_ITEMS = 'uniqueItems'; + public const UNEVALUATED_ITEMS = 'unevaluatedItems'; public const UNEVALUATED_PROPERTIES = 'unevaluatedProperties'; public const CONTENT_MEDIA_TYPE = 'contentMediaType'; public const CONTENT_ENCODING = 'contentEncoding'; @@ -127,6 +128,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_ITEMS => 'The item at index %d is not evaluated and the definition does not allow unevaluated items', 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 da459bb7a..568ed6ec9 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/Draft2019Constraint.php @@ -47,6 +47,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n $this->checkForKeyword('oneOf', $value, $schema, $path, $i); $this->checkForKeyword('ifThenElse', $value, $schema, $path, $i); $this->checkForKeyword('unevaluatedProperties', $value, $schema, $path, $i); + $this->checkForKeyword('unevaluatedItems', $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 0c8e9f17f..8caa8a750 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php @@ -13,6 +13,7 @@ class Factory extends \JsonSchema\Constraints\Factory 'schema' => Draft2019Constraint::class, 'additionalProperties' => AdditionalPropertiesConstraint::class, 'unevaluatedProperties' => UnevaluatedPropertiesConstraint::class, + 'unevaluatedItems' => UnevaluatedItemsConstraint::class, 'additionalItems' => AdditionalItemsConstraint::class, 'dependentSchemas' => DependentSchemasConstraint::class, 'dependentRequired' => DependentRequiredConstraint::class, diff --git a/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedItemsConstraint.php new file mode 100644 index 000000000..bdbacd6de --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft2019/UnevaluatedItemsConstraint.php @@ -0,0 +1,162 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!is_object($schema) || !property_exists($schema, 'unevaluatedItems') || !is_array($value)) { + return; + } + + if ($schema->unevaluatedItems === true) { + return; + } + + $evaluatedCount = $this->countEvaluatedItems($schema, $value, $path); + $unevaluated = array_slice($value, $evaluatedCount, null, true); + if (!$unevaluated) { + return; + } + + $basePath = $path ?? new JsonPointer(''); + foreach ($unevaluated as $index => $itemValue) { + $itemPath = $basePath->withPropertyPaths(array_merge($basePath->getPropertyPaths(), [$index])); + + if (is_object($schema->unevaluatedItems)) { + $itemConstraint = $this->factory->createInstanceFor('schema'); + $itemConstraint->check($itemValue, $schema->unevaluatedItems, $itemPath, $i); + if ($itemConstraint->isValid()) { + continue; + } + + $this->addErrors($itemConstraint->getErrors()); + continue; + } + + $this->addError(ConstraintError::UNEVALUATED_ITEMS(), $itemPath, ['index' => $index]); + } + } + + /** + * In Draft 2019-09 the evaluated items always form a prefix of the array, so it is + * sufficient to derive how many leading items are evaluated by the supported schema branches. + * + * @param array $value + * @param array $visitedRefs + */ + private function countEvaluatedItems(object $schema, array $value, ?JsonPointer $path = null, array $visitedRefs = []): int + { + $itemCount = count($value); + $evaluated = 0; + + if (property_exists($schema, '$ref') && is_string($schema->{'$ref'})) { + $reference = $schema->{'$ref'}; + if (in_array($reference, $visitedRefs, true)) { + return 0; + } + + try { + $visitedRefs[] = $reference; + $resolvedSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); + if (is_object($resolvedSchema)) { + $evaluated = max($evaluated, $this->countEvaluatedItems($resolvedSchema, $value, $path, $visitedRefs)); + } + } catch (\Exception $e) { + // Let the normal reference validation report resolution errors. + } + } + + if (property_exists($schema, 'unevaluatedItems') && $schema->unevaluatedItems === true) { + return $itemCount; + } + + if (property_exists($schema, 'items')) { + if (is_array($schema->items)) { + $evaluated = max($evaluated, min(count($schema->items), $itemCount)); + if (property_exists($schema, 'additionalItems')) { + return $itemCount; + } + } else { + return $itemCount; + } + } + + foreach (['allOf', 'anyOf'] as $keyword) { + if (!isset($schema->{$keyword}) || !is_array($schema->{$keyword})) { + continue; + } + + foreach ($schema->{$keyword} as $branch) { + if (!is_object($branch) || !$this->schemaIsValid($branch, $value, $path)) { + continue; + } + + $evaluated = max($evaluated, $this->countEvaluatedItems($branch, $value, $path, $visitedRefs)); + } + } + + if (isset($schema->oneOf) && is_array($schema->oneOf)) { + $validBranchCount = 0; + $validBranches = []; + foreach ($schema->oneOf as $branch) { + if ($this->schemaIsValid($branch, $value, $path)) { + ++$validBranchCount; + if (is_object($branch)) { + $validBranches[] = $branch; + } + } + } + + if ($validBranchCount === 1 && count($validBranches) === 1) { + $evaluated = max($evaluated, $this->countEvaluatedItems($validBranches[0], $value, $path, $visitedRefs)); + } + } + + if (property_exists($schema, 'if')) { + if ($this->schemaIsValid($schema->if, $value, $path)) { + if (is_object($schema->if)) { + $evaluated = max($evaluated, $this->countEvaluatedItems($schema->if, $value, $path, $visitedRefs)); + } + if (property_exists($schema, 'then') && is_object($schema->then) && $this->schemaIsValid($schema->then, $value, $path)) { + $evaluated = max($evaluated, $this->countEvaluatedItems($schema->then, $value, $path, $visitedRefs)); + } + } elseif (property_exists($schema, 'else') && is_object($schema->else) && $this->schemaIsValid($schema->else, $value, $path)) { + $evaluated = max($evaluated, $this->countEvaluatedItems($schema->else, $value, $path, $visitedRefs)); + } + } + + return min($evaluated, $itemCount); + } + + /** + * @param mixed $schema + * @param mixed $value + */ + private function schemaIsValid($schema, $value, ?JsonPointer $path = null): bool + { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $schema, $path); + + return $schemaConstraint->isValid(); + } +} diff --git a/tests/Constraints/Drafts/Draft2019/UnevaluatedItemsTest.php b/tests/Constraints/Drafts/Draft2019/UnevaluatedItemsTest.php new file mode 100644 index 000000000..12fb82b71 --- /dev/null +++ b/tests/Constraints/Drafts/Draft2019/UnevaluatedItemsTest.php @@ -0,0 +1,91 @@ +