Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/JsonSchema/ConstraintError.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/JsonSchema/Constraints/Drafts/Draft2019/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

declare(strict_types=1);

namespace JsonSchema\Constraints\Drafts\Draft2019;

use JsonSchema\ConstraintError;
use JsonSchema\Constraints\ConstraintInterface;
use JsonSchema\Entity\ErrorBagProxy;
use JsonSchema\Entity\JsonPointer;

class UnevaluatedItemsConstraint implements ConstraintInterface
{
use ErrorBagProxy;

/** @var Factory */
private $factory;

public function __construct(?Factory $factory = null)
{
$this->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<int, mixed> $value
* @param array<int, string> $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();
}
}
91 changes: 91 additions & 0 deletions tests/Constraints/Drafts/Draft2019/UnevaluatedItemsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace JsonSchema\Tests\Constraints\Drafts\Draft2019;

use JsonSchema\Constraints\Constraint;
use JsonSchema\DraftIdentifiers;
use JsonSchema\Tests\Constraints\BaseTestCase;

class UnevaluatedItemsTest extends BaseTestCase
{
protected $schemaSpec = DraftIdentifiers::DRAFT_2019_09;

public function getInvalidTests(): \Generator
{
yield [
'["foo",42]',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"array",
"items":[{"type":"string"}],
"unevaluatedItems":false
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'["foo","bar",42]',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"array",
"items":[{"const":"foo"}],
"anyOf":[{"items":[true,{"const":"bar"}]}],
"unevaluatedItems":false
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'["foo",42]',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"array",
"items":[{"type":"string"}],
"unevaluatedItems":{"type":"string"}
}',
Constraint::CHECK_MODE_STRICT,
];
}

public function getValidTests(): \Generator
{
yield [
'["foo","bar","then"]',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"array",
"items":[{"const":"foo"}],
"allOf":[{"items":[true,{"const":"bar"}]}],
"if":{"items":[true,true]},
"then":{"items":[true,true,{"const":"then"}]},
"unevaluatedItems":false
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'["foo",42,true]',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"array",
"items":[{"type":"string"}],
"additionalItems":true,
"unevaluatedItems":false
}',
Constraint::CHECK_MODE_STRICT,
];

yield [
'["foo","bar"]',
'{
"$schema":"' . DraftIdentifiers::DRAFT_2019_09 . '",
"type":"array",
"items":[{"type":"string"}],
"unevaluatedItems":{"type":"string"}
}',
Constraint::CHECK_MODE_STRICT,
];
}
}
21 changes: 1 addition & 20 deletions tests/JsonSchemaTestSuiteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,27 +189,8 @@ private function shouldNotYieldTest(string $name): bool
'[draft2019-09/ref.json]: ref with absolute-path-reference: an integer is invalid is expected to be invalid',
'[draft2019-09/ref.json]: order of evaluation: $id and $ref on nested schema: data is invalid against nested sibling is expected to be invalid',
'[draft2019-09/ref.json]: $ref with $recursiveAnchor: extra items disallowed for root is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems false: with unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems as schema: with invalid unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with tuple: with unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with ignored additionalItems: invalid under unevaluatedItems is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with ignored applicator additionalItems: invalid under unevaluatedItems is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with nested tuple: with unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with nested items: with invalid additional item is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with anyOf: when one schema matches and has unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with anyOf: when two schemas match and has unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with oneOf: with unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with not: with unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with if/then/else: when if matches and it has unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with if/then/else: when if doesn\'t match and it has unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with boolean schemas: with unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with $ref: with unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems can\'t see inside cousins: always fails is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: item is evaluated in an uncle schema to unevaluatedItems: uncle keyword evaluation is not significant is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems can see annotations from if without then and else: invalid in case if is evaluated is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems before $ref: with unevaluated items is expected to be invalid',
'[draft2019-09/ref.json]: $ref with $recursiveAnchor: extra items allowed for inner arrays is expected to be valid', // $ref "#" resolves against the root instead of the embedded $id resource
'[draft2019-09/unevaluatedItems.json]: unevaluatedItems with $recursiveRef: with unevaluated items is expected to be invalid',
'[draft2019-09/unevaluatedItems.json]: Evaluated items collection needs to consider instance location: with an unevaluated item that exists at another location is expected to be invalid',
'[draft2019-09/refRemote.json]: anchor within remote ref: remote anchor invalid is expected to be invalid',
'[draft2019-09/refRemote.json]: remote ref with ref to defs: invalid is expected to be invalid',
'[draft2019-09/refRemote.json]: Location-independent identifier in remote ref: string is invalid is expected to be invalid',
Expand Down
Loading