diff --git a/CHANGELOG.md b/CHANGELOG.md index 1563ffb..ed6a195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- **Added.** Bounded constructive generation for `prefixItems`/`contains` + (including `minContains`/`maxContains`), `dependentRequired`, + `dependentSchemas`, and `unevaluatedProperties`/`unevaluatedItems` (#157). - **Added.** Conditional schemas (`if`/`then`/`else`) are generated constructively, including boolean and nested branches. Each candidate is checked by `openapi-contract`'s `SchemaCheck`; unreachable branches are omitted and a conditional with no finite value fails closed (#157). +- **Fixed.** A member under `patternProperties` is now generated against the + schema of the name it actually lands under. The value was compiled once for + the whole map, so every name received the schema of whichever name was drawn + first — an object whose `^a` member carried a boolean and whose `z$` member + carried a string, which the contract rejects (#157). ## 0.18.0 — 2026-09-29 diff --git a/README.md b/README.md index b29db99..5215508 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ the same way. | `not` with `const`, `enum`, or `type` | supported; a `not` that excludes every declared type fails closed | | `$defs` + local `$ref` (`#/$defs/…`) — the contract's compiled form of a schema that refers to itself, a tree whose `children` are trees | supported; the def unfolds three levels below the root and ends at a leaf that leaves the recursive member out (an optional property omitted, an array that may be empty left empty, a branch of a choice skipped); a def whose only way down is a required member that is the def itself has no finite instance and fails closed; a `$ref` to anything but the schema's own `$defs` fails closed | | `if`/`then`/`else` (including boolean schemas and nested conditionals) | supported; values are constructed for both reachable branches and checked with the contract's `SchemaCheck`; an unsatisfiable branch is skipped, and a conditional with no producible branch fails closed | -| `contains`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedItems`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` | +| `prefixItems` with `items`/`minItems`/`maxItems`, `contains` with `minContains`/`maxContains`, `unevaluatedItems` | supported for bounded constructive arrays; `contains` emits matching elements by construction, while `prefixItems` emits each tuple position; combinations with `uniqueItems` or conflicting applicators fail closed | +| `dependentRequired`, `dependentSchemas`, `unevaluatedProperties` | supported for bounded plain objects; presence combinations are built explicitly and closures become the additional-property policy; conditional/union/pattern combinations outside this subset fail closed | Every unsatisfiable combination the compiler can recognise is refused at compile time; what remains probabilistic (a `pattern` that rarely matches its diff --git a/README.ru.md b/README.ru.md index 8a35ba6..2d75bd6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -137,7 +137,8 @@ request-направлению схемы — `readOnly`-свойства ухо | `not` с `const`, `enum` или `type` | поддержано; `not`, исключающий каждый объявленный тип, падает fail-closed | | `$defs` + локальный `$ref` (`#/$defs/…`) — скомпилированная контрактом форма схемы, ссылающейся на саму себя: дерево, чьи `children` тоже деревья | поддерживается; def разворачивается на три уровня под корнем и заканчивается листом, который опускает рекурсивный член (необязательное свойство пропущено, массив, которому можно быть пустым, пуст, ветка выбора пропущена); def, единственный путь вниз которого — обязательный член, равный самому def, не имеет конечного экземпляра и fail-closed; `$ref` куда-либо кроме собственных `$defs` схемы — fail-closed | | `if`/`then`/`else` (включая boolean schemas и вложенные conditionals) | поддержано; значения конструктивно строятся для обеих достижимых веток и проверяются собственным `SchemaCheck` контракта; невыполнимая ветка пропускается, а conditional без единой производимой ветки падает fail-closed | -| `contains`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedItems`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` | +| `prefixItems` с `items`/`minItems`/`maxItems`, `contains` с `minContains`/`maxContains`, `unevaluatedItems` | поддержано для ограниченных конструктивных массивов; `contains` строит matching-элементы по построению, `prefixItems` — каждую позицию tuple; сочетания с `uniqueItems` или конфликтующими applicator падают fail-closed | +| `dependentRequired`, `dependentSchemas`, `unevaluatedProperties` | поддержано для ограниченных обычных object; комбинации присутствия строятся явно, closure становится политикой дополнительных свойств; conditional/union/pattern вне этого подмножества падают fail-closed | Каждая невыполнимая комбинация, которую компилятор способен распознать, отвергается при компиляции; то, что остаётся вероятностным (`pattern`, редко diff --git a/llms.txt b/llms.txt index efda9f6..6f6b6b2 100644 --- a/llms.txt +++ b/llms.txt @@ -312,6 +312,15 @@ side from the base schema and `else`, each side filtered by the condition the contract's own `SchemaCheck` judges. Boolean subschemas are honoured; a side that admits nothing is dropped, and a conditional where neither side survives fails closed. +Bounded tuple arrays use `prefixItems` position by position and `items` for +the tail. `contains` uses an intersection of `items` and the contains schema +so every emitted element is a witness, honoring `minContains`/`maxContains`. +`unevaluatedItems` closes the tail when no `items` schema is present. +`dependentRequired` and simple `dependentSchemas` enumerate trigger presence +combinations and generate each branch constructively; `unevaluatedProperties` +becomes the policy for undeclared members. `uniqueItems` with these applicators +and complex conditional/union/pattern combinations fail closed as +`UnsupportedGeneration`. A schema that refers to itself arrives from the contract as `$defs` plus a local `{$ref: '#/$defs/'}` per reference back; the def unfolds three levels below the root and ends at a leaf that leaves the recursive member out diff --git a/src/Internal/Compile/CompositionArbitraries.php b/src/Internal/Compile/CompositionArbitraries.php index e79c690..a8bbdbd 100644 --- a/src/Internal/Compile/CompositionArbitraries.php +++ b/src/Internal/Compile/CompositionArbitraries.php @@ -40,6 +40,16 @@ public function combinator(array $schema): ?ArbitraryInterface $schemas = $this->schemaBranches($schema[$keyword], $keyword); if ($keyword === 'allOf') { + foreach ($schemas as $branch) { + if (array_key_exists('unevaluatedProperties', $branch) || array_key_exists('unevaluatedItems', $branch)) { + throw UnsupportedGeneration::forSchema('unevaluated keywords inside allOf branches are outside the constructive subset'); + } + } + foreach (['unevaluatedProperties', 'unevaluatedItems'] as $closure) { + if (array_key_exists($closure, $schema)) { + $schemas[] = [$closure => $schema[$closure]]; + } + } [$schemas, $inlined] = $this->inlineReferences($schemas); return $this->definitions->inlining($inlined, fn(): ArbitraryInterface => $this->compiler->compile($this->mergeAllOf($schemas))); @@ -340,7 +350,7 @@ private function valueType(mixed $value): string /** @param list> $branches * @return array */ - private function mergeAllOf(array $branches): array + public function mergeAllOf(array $branches): array { $merged = []; $required = []; @@ -411,6 +421,13 @@ private function mergeAllOf(array $branches): array $merged['required'] = array_keys($required); } $this->assertAdditionalPropertiesAdmitSiblings($branches, array_keys($properties)); + if (array_key_exists('prefixItems', $merged)) { + foreach ($branches as $branch) { + if (array_key_exists('items', $branch) && ($branch['prefixItems'] ?? []) !== $merged['prefixItems']) { + throw UnsupportedGeneration::forSchema('allOf items cannot be moved across sibling prefixItems'); + } + } + } return $merged; } @@ -525,6 +542,9 @@ private function assertNoCombinatorSiblings(array $schema, string $keyword): voi if ($name === $keyword || isset($annotations[$name])) { continue; } + if ($keyword === 'allOf' && in_array($name, ['unevaluatedProperties', 'unevaluatedItems'], strict: true)) { + continue; + } throw UnsupportedGeneration::forSchema(sprintf('%s with sibling keyword "%s" is outside the supported subset', $keyword, $name)); } diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index 9b23ea8..7356310 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -28,6 +28,9 @@ public function __construct( /** @param array $schema */ public function array(array $schema): ArbitraryInterface { + if (array_key_exists('prefixItems', $schema) || array_key_exists('contains', $schema) || array_key_exists('unevaluatedItems', $schema)) { + return $this->extendedArray($schema); + } $items = $schema['items'] ?? null; if (!is_array($items) || array_is_list($items)) { throw UnsupportedGeneration::forSchema('array items must be a schema object'); @@ -61,6 +64,111 @@ public function array(array $schema): ArbitraryInterface return Gen::uniqueArrayOf($element, $min, $max); } + /** @param array $schema */ + private function extendedArray(array $schema): ArbitraryInterface + { + if (($schema['uniqueItems'] ?? false) === true) { + throw UnsupportedGeneration::forSchema('uniqueItems with prefixItems, contains or unevaluatedItems is outside the constructive subset'); + } + $prefix = $schema['prefixItems'] ?? []; + if (!is_array($prefix) || !array_is_list($prefix)) { + throw UnsupportedGeneration::forSchema('prefixItems must be a list of schemas'); + } + $min = $this->facts->nonNegativeInt($schema, 'minItems', 0); + $max = min($this->facts->nonNegativeInt($schema, 'maxItems', self::MAX_COLLECTION_SIZE), self::MAX_COLLECTION_SIZE); + $tail = $this->subschema($schema['items'] ?? true, 'items'); + $contains = array_key_exists('contains', $schema) ? $this->subschema($schema['contains'], 'contains') : null; + $unevaluated = array_key_exists('unevaluatedItems', $schema) ? $this->subschema($schema['unevaluatedItems'], 'unevaluatedItems') : true; + if ($contains !== null) { + if ($prefix !== []) { + throw UnsupportedGeneration::forSchema('contains combined with prefixItems is outside the constructive subset'); + } + $min = max($min, $this->facts->nonNegativeInt($schema, 'minContains', 1)); + $max = min($max, $this->facts->nonNegativeInt($schema, 'maxContains', self::MAX_COLLECTION_SIZE)); + if ($contains === false || $tail === false) { + $max = 0; + } + // Every emitted element is a witness. This bounded subset avoids + // predicting which filler values fail the membership schema. + $tail = $this->intersection($tail, $contains); + } elseif (!array_key_exists('items', $schema)) { + $tail = $unevaluated; + } + if ($tail === false) { + $max = min($max, count($prefix)); + } + $positions = []; + /** @var mixed $member */ + foreach ($prefix as $index => $member) { + $position = $this->subschema($member, 'prefixItems'); + if ($position === false) { + $max = min($max, $index); + + break; + } + if ($index >= $max) { + break; + } + $positions[] = $this->compileSubschema($position); + } + if ($min > $max) { + throw UnsupportedGeneration::forSchema('array bounds cannot be met by the constructive prefixItems/contains subset'); + } + $tailArbitrary = $tail === false ? null : $this->compileSubschema($tail); + $pairs = []; + for ($length = $min; $length <= $max; ++$length) { + $elements = array_slice($positions, 0, $length); + while (count($elements) < $length) { + if (!$tailArbitrary instanceof \Rasuvaeff\PropertyTesting\ArbitraryInterface) { + throw new \LogicException('Closed tuple exceeds its prefix'); + } + $elements[] = $tailArbitrary; + } + $pairs[] = [1, $elements === [] ? Gen::constant(value: []) : Gen::tuple(...$elements)]; + } + + return Gen::frequency($pairs); + } + + /** @return bool|array */ + private function subschema(mixed $value, string $keyword): bool|array + { + return is_bool($value) ? $value : $this->facts->schemaObject($value, sprintf('%s must be a boolean or schema object', $keyword)); + } + + /** @param bool|array $schema */ + private function compileSubschema(bool|array $schema): ArbitraryInterface + { + if ($schema === false) { + /** @var ArbitraryInterface $impossible */ + $impossible = Gen::filter(Gen::constant(value: null), static fn(): bool => false); + + return $impossible; + } + + return $schema === true ? $this->additionalValue() : $this->compiler->compile($schema); + } + + /** + * @param bool|array $left + * @param bool|array $right + * @return bool|array + */ + private function intersection(bool|array $left, bool|array $right): bool|array + { + if ($left === false || $right === false) { + return false; + } + if ($left === true || $left === []) { + return $right; + } + if ($right === true || $right === []) { + return $left; + } + + return ['allOf' => [$left, $right]]; + } + /** * The number of distinct values an item schema can take when that number * is knowable from its shape alone. @@ -93,8 +201,11 @@ private function finiteDomain(array $items): ?int }; } - /** @param array $schema */ - public function object(array $schema): ArbitraryInterface + /** + * @param array $schema + * @param list $excluded + */ + public function object(array $schema, array $excluded = []): ArbitraryInterface { $properties = $this->facts->schemaObject($schema['properties'] ?? [], 'object properties must be an object'); $patterns = $this->facts->schemaObject($schema['patternProperties'] ?? [], 'patternProperties must be an object of schemas'); @@ -147,6 +258,13 @@ public function object(array $schema): ArbitraryInterface $omitted = $this->direction?->foreignFlag(); /** @var array $reserved */ $reserved = []; + foreach ($excluded as $name) { + $reserved[$name] = true; + if (isset($requiredNames[$name])) { + throw UnsupportedGeneration::forSchema('dependent schema requires an inactive trigger'); + } + unset($properties[$name]); + } /** @var array $requiredNames */ foreach ($properties as $name => $property) { if ($propertyNames !== null && !$this->matchesSchema($name, $propertyNames)) { @@ -195,6 +313,9 @@ public function object(array $schema): ArbitraryInterface } $additional = $this->facts->additionalPropertiesSchema($schema); + if (!array_key_exists('additionalProperties', $schema) && array_key_exists('unevaluatedProperties', $schema)) { + $additional = $this->subschema($schema['unevaluatedProperties'], 'unevaluatedProperties'); + } if ($additional === false && $patternArbitraries === [] && $minProperties > count($shape)) { throw UnsupportedGeneration::forSchema('minProperties requires additional properties, but additionalProperties is false'); } @@ -226,9 +347,9 @@ public function object(array $schema): ArbitraryInterface } $keyAlphabet = 'abcdefghijklmnopqrstuvwxyz'; - /** @var ArbitraryInterface $key */ + /** @var ArbitraryInterface $name */ if ($patternArbitraries !== []) { - $key = Gen::map(Gen::frequency(array_map( + $name = Gen::map(Gen::frequency(array_map( function (array $entry) use ($shape, $reserved, $propertyNames, $patterns): array { /** @var string $pattern */ [$pattern, $source] = $entry; @@ -254,7 +375,7 @@ function (array $entry) use ($shape, $reserved, $propertyNames, $patterns): arra $patternArbitraries, )), static fn(string $name): string => $name); } else { - $key = Gen::map( + $name = Gen::map( Gen::filter( Gen::stringFrom($keyAlphabet, minLength: 1, maxLength: 8), fn(string $name): bool => !array_key_exists($name, $shape) && !isset($reserved[$name]) @@ -263,39 +384,43 @@ function (array $entry) use ($shape, $reserved, $propertyNames, $patterns): arra static fn(string $name): string => $name, ); } - /** @var ArbitraryInterface $value */ - if ($patternArbitraries !== []) { - $value = Gen::flatMap($key, function (mixed $name) use ($patterns, $additional): ArbitraryInterface { - if (is_string($name)) { - $matching = []; - /** @var list> $matching */ - foreach ($patterns as $pattern => $patternSchema) { - if (preg_match('~' . $pattern . '~', $name) === 1) { - /** @var array $patternSchema */ - $matching[] = $patternSchema; - } - } - if ($matching !== []) { - return $this->compiler->compile(['allOf' => $matching]); + $value = is_array($additional) && $additional !== [] + ? $this->compiler->compile($additional) + : $this->additionalValue(); + // The schema a member answers can depend on the name it lands under — + // that is what `patternProperties` means — so the name and its value + // are drawn together. A value arbitrary picked once for the whole map + // hands every name the value compiled for whichever name came first, + // and an object where `a` carries the boolean and `z` the string is + // exactly what then reaches the contract and fails validation. + /** @var ArbitraryInterface $entry */ + $entry = Gen::flatMap($name, function (mixed $drawn) use ($patterns, $value): ArbitraryInterface { + $matching = []; + if (is_string($drawn)) { + /** @var list> $matching */ + foreach ($patterns as $pattern => $patternSchema) { + if (preg_match('~' . $pattern . '~', $drawn) === 1) { + /** @var array $patternSchema */ + $matching[] = $patternSchema; } } - - return is_array($additional) && $additional !== [] - ? $this->compiler->compile($additional) - : $this->additionalValue(); - }); - } else { - $value = is_array($additional) && $additional !== [] - ? $this->compiler->compile($additional) - : $this->additionalValue(); - } + } + $member = $matching === [] + ? $value + : $this->compiler->compile(['allOf' => $matching]); + + return Gen::map( + Gen::tuple(Gen::constant(value: $drawn), $member), + /** @param list $pair @return array{0: string, 1: mixed} */ + static fn(array $pair): array => [$pair[0], $pair[1]], + ); + }); return Gen::flatMap($base, fn(array $values): ArbitraryInterface => $this->additionalProperties( $values, $minProperties, $maxProperties, - $key, - $value, + $entry, )); } @@ -341,16 +466,15 @@ public function additionalValue(): ArbitraryInterface /** * @param array $values - * @param ArbitraryInterface $key - * @param ArbitraryInterface $value + * @param ArbitraryInterface $entry a member + * name drawn together with the value that name declares * @return ArbitraryInterface> */ private function additionalProperties( array $values, int $minProperties, int $maxProperties, - ArbitraryInterface $key, - ArbitraryInterface $value, + ArbitraryInterface $entry, ): ArbitraryInterface { $missing = max(0, $minProperties - count($values)); $room = max(0, $maxProperties - count($values)); @@ -361,12 +485,33 @@ private function additionalProperties( return $result; } - $extras = Gen::dictOf($key, $value, minSize: $missing, maxSize: $room); + $extras = Gen::uniqueArrayOf( + $entry, + minSize: $missing, + maxSize: $room, + /** @param mixed $member */ + by: static function (mixed $member): string { + if (!is_array($member)) { + throw new \LogicException('Generated additional property has an invalid shape'); + } + $name = $member[0] ?? null; + if (!is_string($name)) { + throw new \LogicException('Generated additional property has an invalid name'); + } + + return $name; + }, + ); - /** @var ArbitraryInterface> $result */ - $result = Gen::map($extras, static fn(array $extra): array => array_replace($values, $extra)); + return Gen::map($extras, static function (array $extra) use ($values): array { + $typed = []; + /** @var array{0: string, 1: mixed} $member */ + foreach ($extra as $member) { + $typed = array_replace($typed, [$member[0] => $member[1]]); + } - return $result; + return array_replace($values, $typed); + }); } /** diff --git a/src/Internal/JsonBodyEncoder.php b/src/Internal/JsonBodyEncoder.php index 8d43742..b62c75b 100644 --- a/src/Internal/JsonBodyEncoder.php +++ b/src/Internal/JsonBodyEncoder.php @@ -49,10 +49,24 @@ private function jsonValueIn(mixed $value, array $schema, array $defs): mixed if (isset($schema['$ref'])) { $schema = $this->dereference($schema['$ref'], $defs); } + $schema = $this->encodingSchema($schema, $value); if (SchemaShape::isArray($schema) && is_array($value) && array_is_list($value)) { - $items = $this->schemaObject($schema['items'] ?? null, 'Array items must be a schema object'); + $result = []; + /** @var mixed $item */ + foreach ($value as $index => $item) { + /** @var array $prefix */ + $prefix = is_array($schema['prefixItems'] ?? null) ? $schema['prefixItems'] : []; + $member = $this->memberSchema(array_key_exists($index, $prefix) + ? $prefix[$index] + : ($schema['items'] ?? $schema['contains'] ?? $schema['unevaluatedItems'] ?? [])); + $items = is_bool($member) ? [] : $this->schemaObject($member, 'Array items must be a schema object'); + if (is_array($schema['contains'] ?? null)) { + $items = $this->encodingSchema(['allOf' => [$items, $schema['contains']]], $item); + } + $result = [...$result, $this->jsonValueIn($item, $items, $defs)]; + } - return array_map(fn(mixed $item): mixed => $this->jsonValueIn($item, $items, $defs), $value); + return $result; } // `array_is_list()` is not a guess here: a negative case deliberately // sends a list where an object is declared, and the wire has to carry @@ -108,7 +122,61 @@ private function dereference(mixed $reference, array $defs): array private function additionalSchema(array $schema): mixed { // `true` and `false` carry no shape of their own. - return is_array($schema['additionalProperties'] ?? null) ? $schema['additionalProperties'] : []; + if (is_array($schema['additionalProperties'] ?? null)) { + return $schema['additionalProperties']; + } + if (is_array($schema['unevaluatedProperties'] ?? null)) { + return $schema['unevaluatedProperties']; + } + + return []; + } + + /** + * @param array $schema + * @return array + */ + private function encodingSchema(array $schema, mixed $value): array + { + /** @var list $branches */ + $branches = is_array($schema['allOf'] ?? null) ? $schema['allOf'] : []; + if (is_array($value) && is_array($schema['dependentSchemas'] ?? null)) { + /** @var array $dependentSchemas */ + $dependentSchemas = $schema['dependentSchemas']; + foreach (array_keys($dependentSchemas) as $trigger) { + if (array_key_exists($trigger, $value) && is_array($dependentSchemas[$trigger] ?? null)) { + $branches[] = $this->schemaObject($dependentSchemas[$trigger], 'Dependent schema must be an object'); + } + } + } + foreach ($branches as $branch) { + if (!is_array($branch)) { + continue; + } + $branch = $this->encodingSchema($this->schemaObject($branch, 'Applicator branch must be a schema object'), $value); + foreach (['type', 'items', 'prefixItems'] as $keyword) { + if (!array_key_exists($keyword, $schema) && array_key_exists($keyword, $branch)) { + $schema = [...$schema, $keyword => $branch[$keyword]]; + } + } + if (is_array($branch['properties'] ?? null)) { + $properties = $this->memberMap($schema['properties'] ?? [], 'Object properties must be an object'); + $members = $this->memberMap($branch['properties'], 'Object properties must be an object'); + $schema['properties'] = array_replace($properties, $members); + } + } + + return $schema; + } + + /** @return bool|array */ + private function memberSchema(mixed $value): bool|array + { + if (is_bool($value)) { + return $value; + } + + return $this->schemaObject($value, 'Array items must be a schema object'); } /** diff --git a/src/Internal/SchemaShape.php b/src/Internal/SchemaShape.php index 5cb85bc..1c75c22 100644 --- a/src/Internal/SchemaShape.php +++ b/src/Internal/SchemaShape.php @@ -16,7 +16,8 @@ /** @param array $schema */ public static function isArray(array $schema): bool { - return self::declares($schema, 'array') || array_key_exists('items', $schema); + return self::declares($schema, 'array') || array_key_exists('items', $schema) + || array_key_exists('prefixItems', $schema) || array_key_exists('contains', $schema); } /** @param array $schema */ diff --git a/src/SchemaArbitraryCompiler.php b/src/SchemaArbitraryCompiler.php index 7a31ce7..c7812fd 100644 --- a/src/SchemaArbitraryCompiler.php +++ b/src/SchemaArbitraryCompiler.php @@ -91,6 +91,13 @@ public function compile(array $schema): ArbitraryInterface if (array_key_exists('$ref', $schema)) { return $this->reference($schema['$ref']); } + if (array_key_exists('dependentRequired', $schema) || array_key_exists('dependentSchemas', $schema)) { + return $this->dependencies($schema); + } + if ((array_key_exists('unevaluatedProperties', $schema) || array_key_exists('unevaluatedItems', $schema)) + && (array_key_exists('if', $schema) || array_key_exists('anyOf', $schema) || array_key_exists('oneOf', $schema))) { + throw UnsupportedGeneration::forSchema('unevaluated keywords with conditional or union applicators are outside the constructive subset'); + } if (array_key_exists('if', $schema) || array_key_exists('then', $schema) || array_key_exists('else', $schema)) { return $this->conditional($schema); } @@ -218,6 +225,104 @@ private function conditional(array $schema): ArbitraryInterface return Gen::frequency($pairs); } + /** @param array $schema */ + private function dependencies(array $schema): ArbitraryInterface + { + if (($schema['type'] ?? 'object') !== 'object' || array_key_exists('patternProperties', $schema) + || array_key_exists('if', $schema) || array_key_exists('allOf', $schema) + || array_key_exists('anyOf', $schema) || array_key_exists('oneOf', $schema)) { + throw UnsupportedGeneration::forSchema('dependent keywords require a plain object schema'); + } + $requiredMap = $this->facts->schemaObject($schema['dependentRequired'] ?? [], 'dependentRequired must be an object of lists'); + $schemaMap = $this->facts->schemaObject($schema['dependentSchemas'] ?? [], 'dependentSchemas must be an object of schemas'); + /** @var array $requiredMap */ + /** @var array $schemaMap */ + $properties = $this->facts->schemaObject($schema['properties'] ?? [], 'object properties must be an object'); + /** @var list $triggers */ + $triggers = array_values(array_unique([...array_keys($requiredMap), ...array_keys($schemaMap)])); + if (count($triggers) > 8) { + throw UnsupportedGeneration::forSchema('dependent keywords exceed the eight-trigger generation budget'); + } + foreach ($triggers as $trigger) { + if (!array_key_exists($trigger, $properties)) { + throw UnsupportedGeneration::forSchema('dependent trigger must have a declared property schema'); + } + } + foreach ($requiredMap as $members) { + if (!is_array($members) || !array_is_list($members)) { + throw UnsupportedGeneration::forSchema('dependentRequired values must be lists of names'); + } + /** @var mixed $member */ + foreach ($members as $member) { + if (!is_string($member)) { + throw UnsupportedGeneration::forSchema('dependentRequired values must contain names'); + } + } + } + /** @var mixed $member */ + foreach ($schemaMap as $member) { + if (!is_array($member) || ($member !== [] && array_is_list($member))) { + throw UnsupportedGeneration::forSchema('dependentSchemas values must be schema objects'); + } + /** @var array $member */ + foreach (array_keys($member) as $keyword) { + if (!in_array($keyword, ['type', 'required', 'properties', 'minProperties', 'maxProperties', 'additionalProperties', 'description', 'title'], strict: true)) { + throw UnsupportedGeneration::forSchema(sprintf('dependentSchemas keyword "%s" is outside the constructive subset', $keyword)); + } + } + } + $foreignFlag = $this->direction?->foreignFlag(); + if ($foreignFlag !== null) { + /** @var mixed $property */ + foreach ($properties as $property) { + if (is_array($property) && ($property[$foreignFlag] ?? false) === true) { + throw UnsupportedGeneration::forSchema('dependent keywords with foreign-direction properties are outside the constructive subset'); + } + } + } + unset($schema['dependentRequired'], $schema['dependentSchemas']); + $pairs = []; + for ($mask = 0; $mask < (1 << count($triggers)); ++$mask) { + $present = []; + $absent = []; + $branches = [$schema]; + foreach ($triggers as $index => $trigger) { + $triggerName = $trigger; + if (($mask & (1 << $index)) === 0) { + $absent[] = $triggerName; + + continue; + } + $present[] = $triggerName; + if (isset($requiredMap[$triggerName])) { + /** @var list $members */ + $members = $requiredMap[$triggerName]; + $branches[] = ['required' => $members]; + } + if (isset($schemaMap[$triggerName])) { + /** @var array $dependent */ + $dependent = $schemaMap[$triggerName]; + $branches[] = $dependent; + } + } + $branches[] = ['required' => $present]; + + try { + $merged = $this->composition->mergeAllOf($branches); + $this->assertSupported($merged); + $pairs[] = [1, $this->containers->object($merged, $absent)]; + } catch (UnsupportedGeneration|Unproducible) { + // A presence combination can be impossible: a required + // trigger is absent, or its dependency exceeds cardinality. + } + } + if ($pairs === []) { + throw UnsupportedGeneration::forSchema('dependent keywords admit no constructible presence combination'); + } + + return Gen::frequency($pairs); + } + /** @return bool|array */ private function conditionSchema(mixed $value, string $keyword): bool|array { @@ -333,7 +438,10 @@ private function type(array $schema): string if (array_key_exists('properties', $schema)) { return 'object'; } - if (array_key_exists('items', $schema)) { + if (array_key_exists('dependentRequired', $schema) || array_key_exists('dependentSchemas', $schema) || array_key_exists('unevaluatedProperties', $schema)) { + return 'object'; + } + if (array_key_exists('items', $schema) || array_key_exists('prefixItems', $schema) || array_key_exists('contains', $schema)) { return 'array'; } @@ -345,8 +453,6 @@ private function assertSupported(array $schema): void { foreach ([ 'allOf', 'anyOf', 'oneOf', - 'contains', 'prefixItems', 'unevaluatedProperties', - 'dependentRequired', 'dependentSchemas', 'unevaluatedItems', ] as $keyword) { if (array_key_exists($keyword, $schema)) { throw UnsupportedGeneration::forSchema(sprintf('keyword "%s" is outside the initial support matrix', $keyword)); diff --git a/tests/ResponseMaterializerTest.php b/tests/ResponseMaterializerTest.php index 16182b5..760c758 100644 --- a/tests/ResponseMaterializerTest.php +++ b/tests/ResponseMaterializerTest.php @@ -12,6 +12,7 @@ use Rasuvaeff\PropertyTesting\OpenApi\Tests\Support\ResponseContracts; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Data\DataProvider; use Testo\Expect; use Testo\Test; @@ -184,6 +185,14 @@ public function jsonEncoderKeepsEmptyObjectsInsideLists(): void Assert::same((new JsonBodyEncoder())->encode([[]], ['items' => ['type' => 'object']]), '[{}]'); Assert::same((new JsonBodyEncoder())->encode(['a', 'b'], ['type' => 'object']), '["a","b"]'); Assert::same((new JsonBodyEncoder())->encode(['a' => [], 'b' => []], ['type' => 'object', 'properties' => ['a' => ['type' => 'object'], 'b' => ['type' => 'object']]]), '{"a":{},"b":{}}'); + Assert::same( + (new JsonBodyEncoder())->encode(['v1', 2], ['type' => 'array', 'prefixItems' => [['type' => 'string'], ['type' => 'integer']]]), + '["v1",2]', + ); + Assert::same( + (new JsonBodyEncoder())->encode([5], ['type' => 'array', 'contains' => ['type' => 'integer']]), + '[5]', + ); } /** @@ -205,6 +214,214 @@ public function jsonEncoderReadsTheAdditionalPropertiesSchema(): void $encoder->encode(['k' => []], ['type' => 'object', 'properties' => ['k' => []], 'additionalProperties' => ['type' => 'object']]), '{"k":[]}', ); + Assert::same( + $encoder->encode(['k' => []], ['type' => 'object', 'unevaluatedProperties' => ['type' => 'object']]), + '{"k":{}}', + ); + Assert::same( + $encoder->encode(['kind' => 'x', 'detail' => []], [ + 'type' => 'object', + 'dependentSchemas' => ['kind' => ['properties' => ['detail' => ['type' => 'object']]]], + ]), + '{"kind":"x","detail":{}}', + ); + } + + public function encodesTheTailOfAPrefixTupleAndNestedApplicators(): void + { + $encoder = new JsonBodyEncoder(); + + // An element past `prefixItems` is declared by `contains` when there + // is no `items`, and by `unevaluatedItems` when there is neither. + Assert::same( + $encoder->encode(['a', 2, 3], [ + 'type' => 'array', + 'prefixItems' => [['type' => 'string']], + 'contains' => ['type' => 'integer'], + ]), + '["a",2,3]', + ); + Assert::same( + $encoder->encode([[], []], [ + 'type' => 'array', + 'unevaluatedItems' => ['type' => 'object'], + ]), + '[{},{}]', + ); + + // An `allOf` branch that declares the array shape the root leaves out. + Assert::same( + $encoder->encode(['k' => []], [ + 'allOf' => [['properties' => ['k' => ['type' => 'object']]]], + ]), + '{"k":{}}', + ); + + // A dependent branch contributes its members only when the trigger is + // there; an absent trigger leaves the member undeclared, and an + // undeclared empty member keeps the array encoding it arrived with. + Assert::same( + $encoder->encode(['other' => []], [ + 'type' => 'object', + 'dependentSchemas' => ['kind' => ['properties' => ['other' => ['type' => 'object']]]], + ]), + '{"other":[]}', + ); + + // `items` wins over `contains`: the first element reads as a string, + // which only encodes as one if `items` was consulted. + Assert::same( + $encoder->encode(['x'], ['type' => 'array', 'items' => ['type' => 'string'], 'contains' => ['type' => 'integer']]), + '["x"]', + ); + + // The root's `$defs` is what a local `$ref` below it resolves against, + // and the referenced shape decides the encoding. + Assert::same( + $encoder->encode( + ['k' => []], + [ + 'type' => 'object', + '$defs' => ['node' => ['type' => 'object', 'properties' => ['k' => ['type' => 'object']]]], + 'additionalProperties' => ['$ref' => '#/$defs/node'], + ], + ), + '{"k":{}}', + ); + } + + public function aLocalReferenceIsResolvedAgainstTheRootDefs(): void + { + $encoder = new JsonBodyEncoder(); + + // The name is JSON-Pointer-escaped: `a/b` is written `a~1b`, and a + // `~` in a name is written `~0`. Both resolve, and the referenced shape + // is what decides the encoding. + Assert::same( + $encoder->encode( + ['k' => []], + [ + '$defs' => ['a/b~c' => ['type' => 'object', 'properties' => ['k' => ['type' => 'object']]]], + '$ref' => '#/$defs/a~1b~0c', + ], + ), + '{"k":{}}', + ); + } + + /** + * A `$ref` the encoder cannot resolve is refused by name: it would + * otherwise encode the value against no schema at all, and a nested empty + * object as `[]` — the exact loss the local form exists to prevent. + * + * @param array $schema + */ + #[DataProvider('unresolvableReferences')] + public function anUnresolvableReferenceIsRefused(array $schema, string $reason): void + { + Expect::exception(InvalidCase::class)->withMessage($reason); + (new JsonBodyEncoder())->encode(['k' => []], [...$schema, 'type' => 'object']); + } + + /** @return iterable, string}> */ + public static function unresolvableReferences(): iterable + { + yield 'a remote pointer' => [ + ['$ref' => 'other.json#/$defs/node'], + 'Schema $ref must be a local reference into $defs', + ]; + yield 'a non-string pointer' => [ + ['$ref' => 42], + 'Schema $ref must be a local reference into $defs', + ]; + yield 'another root' => [ + ['$ref' => '#/definitions/node'], + 'Schema $ref must be a local reference into $defs', + ]; + yield 'an absent member' => [ + ['$defs' => ['node' => ['type' => 'object']], '$ref' => '#/$defs/missing'], + 'Schema $ref "#/$defs/missing" names no $defs member', + ]; + yield 'a defs that is a list' => [ + ['$defs' => [['type' => 'object']], '$ref' => '#/$defs/node'], + 'Schema $defs must be an object', + ]; + } + + public function aDefsThatIsAListCarriesNoMemberToName(): void + { + foreach ([ + ['$defs' => [['type' => 'object']]], + ['$defs' => 'invalid'], + ] as $schema) { + try { + (new JsonBodyEncoder())->encode(['k' => []], ['type' => 'object', ...$schema]); + Assert::true(actual: false); + } catch (InvalidCase) { + Assert::true(actual: true); + } + } + } + + public function anApplicatorBranchContributesTheShapeTheRootLacks(): void + { + $encoder = new JsonBodyEncoder(); + + // The root says only `allOf`; each of the three shape keywords a + // branch may carry decides the encoding on its own, and asking for one + // proves nothing about the others. + Assert::same( + $encoder->encode(['a', 'b'], ['allOf' => [['type' => 'array', 'items' => ['type' => 'string']]]]), + '["a","b"]', + ); + Assert::same( + $encoder->encode([1, 2], ['allOf' => [['type' => 'array', 'items' => ['type' => 'integer']]]]), + '[1,2]', + ); + Assert::same( + $encoder->encode( + ['first' => [], 'second' => []], + ['allOf' => [['type' => 'object', 'properties' => ['first' => ['type' => 'object'], 'second' => ['type' => 'object']]]]], + ), + '{"first":{},"second":{}}', + ); + + // `prefixItems` decides position by position, so the second element is + // read against the second schema and not against `items`. + Assert::same( + $encoder->encode(['a', ['b' => []]], [ + 'allOf' => [[ + 'type' => 'array', + 'prefixItems' => [['type' => 'string'], ['type' => 'object', 'properties' => ['b' => ['type' => 'object']]]], + ]], + ]), + '["a",{"b":{}}]', + ); + + // A shape the root already declares wins: the branch is not allowed + // to overrule it, so the encoding follows the root. + Assert::same( + $encoder->encode([[]], ['type' => 'array', 'items' => ['type' => 'object'], 'allOf' => [['items' => ['type' => 'string']]]]), + '[{}]', + ); + + // Properties merge rather than replace: a branch that declares one + // member of an object the root declared another keeps both. + Assert::same( + $encoder->encode( + ['kept' => [], 'added' => []], + [ + 'allOf' => [ + ['properties' => ['kept' => ['type' => 'object']]], + ['properties' => ['added' => ['type' => 'object']]], + ], + ], + ), + '{"kept":{},"added":{}}', + ); + + // A branch that is not an object cannot contribute a shape. + Assert::same($encoder->encode(['a', 'b'], ['allOf' => ['ignored', ['type' => 'array', 'items' => ['type' => 'string']]]]), '["a","b"]'); } /** diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index 2825f6f..bfaaf27 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -8,6 +8,7 @@ use Rasuvaeff\OpenApiContract\Contract; use Rasuvaeff\OpenApiContract\SchemaCheck; use Rasuvaeff\OpenApiContract\SchemaDialect; +use Rasuvaeff\OpenApiContract\SchemaDirection; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\Internal\Compile\CompositionArbitraries; @@ -604,11 +605,156 @@ public function rejectsUnsupportedAssertionKeywords(string $keyword): void /** @return iterable */ public static function unsupportedKeywordCases(): iterable { - foreach (['allOf', 'anyOf', 'oneOf', 'contains', 'prefixItems', 'unevaluatedProperties', 'dependentRequired', 'dependentSchemas', 'unevaluatedItems'] as $keyword) { + foreach (['allOf', 'anyOf', 'oneOf'] as $keyword) { yield $keyword => [$keyword]; } } + public function generatesTupleAndContainsArrays(): void + { + $compiler = new SchemaArbitraryCompiler(); + $tuple = Gen::sample($compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'v1'], ['type' => 'integer', 'minimum' => 1, 'maximum' => 1]], + 'items' => false, + 'minItems' => 2, + 'maxItems' => 2, + ]), count: 20, seed: 19); + foreach ($tuple as $value) { + Assert::same($value, ['v1', 1]); + } + $contains = $compiler->compile([ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 5], + 'minContains' => 2, + 'maxContains' => 2, + 'minItems' => 2, + 'maxItems' => 2, + ]); + foreach (Gen::sample($contains, count: 20, seed: 23) as $value) { + Assert::true(is_array($value) && count($value) === 2); + Assert::true($value[0] >= 5 && $value[1] >= 5); + } + + $openTuple = Gen::sample($compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'head']], + 'items' => false, + 'minItems' => 0, + 'maxItems' => 1, + ]), count: 20, seed: 31); + Assert::true(in_array([], $openTuple, strict: true)); + Assert::true(in_array(['head'], $openTuple, strict: true)); + + Expect::exception(UnsupportedGeneration::class); + $compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['type' => 'string']], + 'contains' => ['type' => 'string'], + ]); + } + + public function rejectsImpossibleExtendedArraysAndInvalidShapes(): void + { + $compiler = new SchemaArbitraryCompiler(); + foreach ([ + ['type' => 'array', 'prefixItems' => 'invalid'], + ['type' => 'array', 'prefixItems' => [['type' => 'string']], 'items' => false, 'minItems' => 2, 'maxItems' => 2], + ['type' => 'array', 'contains' => false, 'minContains' => 1], + ['type' => 'array', 'contains' => ['type' => 'integer'], 'items' => false, 'minItems' => 1], + ['type' => 'array', 'unevaluatedItems' => false, 'minItems' => 1], + ['type' => 'array', 'prefixItems' => [['type' => 'string']], 'uniqueItems' => true], + ] as $schema) { + try { + $compiler->compile($schema); + Assert::true(actual: false); + } catch (UnsupportedGeneration) { + Assert::true(actual: true); + } + } + } + + public function generatesDependentAndUnevaluatedObjects(): void + { + $compiler = new SchemaArbitraryCompiler(); + $schema = [ + 'type' => 'object', + 'properties' => [ + 'card' => ['const' => 'x'], + 'address' => ['type' => 'string'], + ], + 'dependentRequired' => ['card' => ['address']], + 'unevaluatedProperties' => false, + ]; + foreach (Gen::sample($compiler->compile($schema), count: 20, seed: 29) as $value) { + Assert::true(is_array($value)); + if (array_key_exists('card', $value)) { + Assert::true(array_key_exists('address', $value)); + } + Assert::true(array_diff(array_keys($value), ['card', 'address']) === []); + } + + // An object-typed `unevaluatedProperties` is what an undeclared member + // is declared by, exactly as `additionalProperties` would be — and it + // is the `additionalProperties` key that wins when both are present. + $unevaluated = [ + 'type' => 'object', + 'properties' => ['card' => ['const' => 'x']], + 'unevaluatedProperties' => ['type' => 'integer'], + ]; + foreach (Gen::sample($compiler->compile($unevaluated), count: 40, seed: 31) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if ($name !== 'card') { + Assert::true(is_int($member)); + } + } + } + $declared = $unevaluated; + $declared['additionalProperties'] = ['type' => 'boolean']; + foreach (Gen::sample($compiler->compile($declared), count: 40, seed: 37) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if ($name !== 'card') { + Assert::true(is_bool($member)); + } + } + } + + $dependent = [ + 'type' => 'object', + 'properties' => [ + 'kind' => ['type' => 'string'], + 'detail' => ['const' => 'required'], + ], + 'dependentSchemas' => [ + 'kind' => ['required' => ['detail']], + ], + 'additionalProperties' => false, + ]; + foreach (Gen::sample($compiler->compile($dependent), count: 100, seed: 41) as $value) { + Assert::true(is_array($value)); + if (array_key_exists('kind', $value)) { + Assert::same($value['detail'], 'required'); + } + } + + foreach ([ + ['type' => 'object', 'dependentRequired' => ['missing' => []]], + ['type' => 'object', 'dependentRequired' => ['x' => 'invalid']], + ['type' => 'object', 'dependentSchemas' => ['x' => ['items' => []]]], + ['type' => 'object', 'dependentRequired' => array_fill_keys(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'], [])], + ] as $invalid) { + try { + $compiler->compile($invalid); + Assert::true(actual: false); + } catch (UnsupportedGeneration) { + Assert::true(actual: true); + } + } + } + public function rejectsMalformedSchemaCombinatorsAndObjects(): void { $compiler = new SchemaArbitraryCompiler(); @@ -2071,4 +2217,785 @@ public function nullableWithAnEnumYieldsNullOnlyWhenTheEnumListsIt(): void Assert::true($sawNull, 'an enum listing null never produced it'); } + + public function extendedArraysHonourContainsCardinalityAndClosedTails(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `minContains` above `minItems`, and a tail that must satisfy both + // `items` and `contains`. + $witnesses = $compiler->compile([ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 5], + 'minContains' => 3, + 'maxItems' => 4, + ]); + foreach (Gen::sample($witnesses, count: 20, seed: 53) as $value) { + Assert::true(is_array($value) && count($value) >= 3); + foreach ($value as $item) { + Assert::true(is_int($item) && $item >= 5); + } + } + + // `maxContains` below `maxItems` bounds the length from above. + $bounded = $compiler->compile([ + 'type' => 'array', + 'contains' => ['type' => 'boolean'], + 'minContains' => 0, + 'maxContains' => 1, + 'maxItems' => 5, + ]); + foreach (Gen::sample($bounded, count: 20, seed: 59) as $value) { + Assert::true(is_array($value) && count($value) <= 1); + } + + // `contains: false` with no `items` admits nothing but the empty array. + $refused = $compiler->compile([ + 'type' => 'array', + 'contains' => false, + 'minContains' => 0, + ]); + Assert::same(Gen::sample($refused, count: 5, seed: 61), [[], [], [], [], []]); + + // `unevaluatedItems` is the tail of an array that declares no `items`. + $unevaluated = $compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'head']], + 'unevaluatedItems' => ['type' => 'string', 'maxLength' => 3], + 'minItems' => 1, + 'maxItems' => 3, + ]); + foreach (Gen::sample($unevaluated, count: 20, seed: 67) as $value) { + Assert::true(is_array($value) && $value !== [] && $value[0] === 'head'); + foreach (array_slice($value, 1) as $item) { + Assert::true(is_string($item) && mb_strlen($item) <= 3); + } + } + + // A prefix that closes the tuple: `items: false` caps the length at + // the prefix, and a `false` member closes it earlier still. + $closed = $compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'a'], ['const' => 'b'], ['const' => 'c']], + 'items' => false, + ]); + $lengths = []; + foreach (Gen::sample($closed, count: 30, seed: 71) as $value) { + Assert::true(is_array($value)); + $lengths[count($value)] = true; + } + Assert::true(count($lengths) === 4, 'a closed prefix did not offer every length'); + + // A `false` prefix member truncates the tuple at that index. + $truncated = $compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'a'], false, ['const' => 'c']], + ]); + foreach (Gen::sample($truncated, count: 20, seed: 73) as $value) { + Assert::true(is_array($value) && count($value) <= 1); + } + + // A prefix longer than `maxItems` contributes only what fits. + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'prefixItems' => [['const' => 'a'], ['const' => 'b'], ['const' => 'c']], + 'items' => false, + 'maxItems' => 2, + ]), count: 10, seed: 79) as $value) { + Assert::true(is_array($value) && count($value) <= 2); + } + } + + public function everyDependentTriggerCombinationIsReachable(): void + { + $compiler = new SchemaArbitraryCompiler(); + $schema = [ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'boolean'], + 'b' => ['type' => 'boolean'], + 'x' => ['type' => 'boolean'], + 'y' => ['type' => 'boolean'], + ], + 'dependentRequired' => ['a' => ['x'], 'b' => ['y']], + 'additionalProperties' => false, + ]; + $check = new SchemaCheck(); + $seen = []; + foreach (Gen::sample($compiler->compile($schema), count: 200, seed: 83) as $value) { + Assert::true(is_array($value)); + Assert::true($check->accepts((object) $value, $schema, SchemaDialect::OpenApi31)); + // Only the two triggers are enumerated; `x`/`y` follow from them. + $seen[implode(',', array_intersect(['a', 'b'], array_keys($value)))] = true; + } + + // Every one of the eight presence masks must be constructible: the + // enumeration that builds them is the code under test. + Assert::same(count($seen), 4, 'not every dependent trigger mask was generated'); + + // A trigger that arrives through `dependentSchemas` rather than + // `dependentRequired`: the two maps are merged into one trigger list. + $pair = [ + 'type' => 'object', + 'properties' => ['x' => ['type' => 'string'], 'y' => ['type' => 'string'], 'z' => ['type' => 'string']], + 'dependentRequired' => ['x' => ['y']], + 'dependentSchemas' => ['y' => ['required' => ['z']]], + ]; + $masks = []; + foreach (Gen::sample($compiler->compile($pair), count: 200, seed: 89) as $value) { + Assert::true(is_array($value)); + Assert::true($check->accepts((object) $value, $pair, SchemaDialect::OpenApi31)); + $masks[implode(',', array_intersect(['x', 'y'], array_keys($value)))] = true; + } + // Three of the four masks: `x` present with `y` absent is the one the + // enumeration has to notice and drop, and dropping it is the code + // under test — a mask that survived would fail the check above. + Assert::same(count($masks), 3); + Assert::false(isset($masks['x'])); + } + + public function aNullableConstOrEnumNeverWidensToNull(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `nullable: true` beside a `const` the document wrote: the value is + // the constant, never null. + foreach (range(1, 20) as $seed) { + Assert::same($compiler->compile(['type' => 'string', 'nullable' => true, 'const' => null])->generate(new Random($seed))->value, null); + Assert::same($compiler->compile(['const' => 7, 'nullable' => true])->generate(new Random($seed))->value, 7); + } + + // An enum that lists null selects it; one that does not, does not. + $openEnum = $compiler->compile(['type' => 'string', 'nullable' => true, 'enum' => ['a', 'b']]); + foreach (range(1, 20) as $seed) { + Assert::true(in_array($openEnum->generate(new Random($seed))->value, ['a', 'b'], strict: true)); + } + + // A bare `nullable` with no enum or const is the only shape that + // reaches `Gen::nullable()`. + $bare = $compiler->compile(['type' => 'integer', 'minimum' => 3, 'maximum' => 5, 'nullable' => true]); + $values = []; + foreach (range(1, 40) as $seed) { + $values[] = $bare->generate(new Random($seed))->value; + } + Assert::true(in_array(null, $values, strict: true)); + foreach ($values as $value) { + Assert::true($value === null || (is_int($value) && $value >= 3 && $value <= 5)); + } + } + + public function aDependentSchemaUnderADirectionRefusesAForeignProperty(): void + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'kind' => ['type' => 'string'], + 'detail' => ['type' => 'string'], + ], + 'dependentSchemas' => ['kind' => ['required' => ['detail']]], + ]; + $withFlag = [...$schema, 'properties' => ['kind' => ['type' => 'string'], 'detail' => ['type' => 'string', 'writeOnly' => true]]]; + + foreach ([SchemaDirection::Request, SchemaDirection::Response] as $direction) { + $compiler = new SchemaArbitraryCompiler(direction: $direction); + Expect::exception(UnsupportedGeneration::class); + $compiler->compile($withFlag); + } + + // The same schema without the foreign-direction property generates in + // both directions, so the refusal above is about the flag and not + // about the direction itself. + foreach ([ + ['type' => 'object', 'dependentRequired' => ['missing' => []]], + $schema, + ] as $case) { + $values = Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Response))->compile($case), count: 10, seed: 97); + Assert::same(count($values), 10); + } + } + + public function containsWithNoWitnessIsStillGeneratedWhenMinContainsIsZero(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `minContains: 0` lets the empty array through, which a positive + // `minContains` refuses; `contains: false` with it admits nothing else. + $schema = [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 5], + 'minContains' => 0, + 'maxItems' => 3, + ]; + $lengths = []; + foreach (Gen::sample($compiler->compile($schema), count: 30, seed: 101) as $value) { + Assert::true(is_array($value)); + $lengths[count($value)] = true; + foreach ($value as $item) { + Assert::true(is_int($item) && $item >= 5); + } + } + Assert::true(array_key_exists(0, $lengths), 'minContains: 0 never produced the empty array'); + + // `items: false` alongside `contains` closes the array at length 0. + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'items' => false, + 'contains' => ['type' => 'integer'], + 'minContains' => 0, + ]), count: 5, seed: 103) as $value) { + Assert::same($value, []); + } + + // A tail whose type contradicts `contains` is refused outright, rather + // than silently generating an array that satisfies neither. + Expect::exception(UnsupportedGeneration::class); + $compiler->compile([ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'string'], + ]); + + // `contains: false` with `items: false` is the same closed tuple. + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'items' => false, + 'contains' => false, + ]), count: 5, seed: 107) as $value) { + Assert::same($value, []); + } + } + + public function aForeignDirectionPropertyIsNeverSentButKeepsItsName(): void + { + // A request never carries a `readOnly` member and a response never + // carries a `writeOnly` one — each is the flag the other direction + // owns. + $schema = [ + 'type' => 'object', + 'required' => ['id', 'computed'], + 'properties' => [ + 'id' => ['type' => 'integer'], + 'computed' => ['type' => 'string', 'readOnly' => true], + ], + 'additionalProperties' => false, + ]; + + foreach (Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Request))->compile($schema), count: 40, seed: 109) as $value) { + Assert::true(is_array($value) && array_key_exists('id', $value)); + Assert::false(array_key_exists('computed', $value)); + } + + // The mirror image: the same document under the response direction, + // where `computed` is now the member that travels. + foreach (Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Response))->compile($schema), count: 40, seed: 113) as $value) { + Assert::true(is_array($value) && array_key_exists('computed', $value)); + } + + // And a `writeOnly` member the other way round. + $secret = [ + 'type' => 'object', + 'required' => ['id', 'secret'], + 'properties' => [ + 'id' => ['type' => 'integer'], + 'secret' => ['type' => 'string', 'writeOnly' => true], + ], + 'additionalProperties' => false, + ]; + foreach (Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Response))->compile($secret), count: 40, seed: 127) as $value) { + Assert::true(is_array($value) && array_key_exists('id', $value)); + Assert::false(array_key_exists('secret', $value)); + } + + foreach (Gen::sample((new SchemaArbitraryCompiler(direction: SchemaDirection::Request))->compile($secret), count: 20, seed: 131) as $value) { + Assert::true(is_array($value) && array_key_exists('secret', $value)); + } + } + + public function propertyNamesMustAcceptEveryDeclaredProperty(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // A pattern that admits every declared name generates; one that + // excludes a declared name is refused rather than dropping it. + $admitting = [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer'], 'tag' => ['type' => 'string']], + 'propertyNames' => ['pattern' => '^[a-z]+$'], + 'additionalProperties' => ['type' => 'boolean'], + ]; + foreach (Gen::sample($compiler->compile($admitting), count: 20, seed: 131) as $value) { + Assert::true(is_array($value)); + foreach (array_keys($value) as $name) { + Assert::true(is_string($name) && preg_match('~^[a-z]+$~', $name) === 1); + } + } + + foreach ([ + ['pattern' => '^id$'], + ['pattern' => 42], + ['pattern' => '([a-z'], + ] as $propertyNames) { + Expect::exception(UnsupportedGeneration::class); + $compiler->compile(['type' => 'object', 'properties' => ['id' => ['type' => 'integer'], 'tag' => ['type' => 'string']], 'propertyNames' => $propertyNames]); + } + + // A `propertyNames` with no `pattern` constrains nothing. + $unconstrained = [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'propertyNames' => ['maxLength' => 4], + 'additionalProperties' => ['type' => 'string', 'maxLength' => 2], + ]; + foreach (Gen::sample($compiler->compile($unconstrained), count: 20, seed: 137) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if ($name !== 'id') { + Assert::true(is_string($member) && mb_strlen($member) <= 2); + } + } + } + } + + public function uniqueItemsSeesTheExclusiveBoundsOfANumericItemDomain(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `0..1` with both bounds exclusive leaves exactly one value, so + // `minItems: 2` is refused for want of a domain rather than left to + // exhaust at run time (#123). With the bounds inclusive it holds two. + foreach ([ + ['type' => 'integer', 'minimum' => 0, 'maximum' => 1, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], + ['type' => 'integer', 'minimum' => 0, 'maximum' => 3, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], + ] as $index) { + Expect::exception(UnsupportedGeneration::class); + $compiler->compile(['type' => 'array', 'items' => $index, 'uniqueItems' => true, 'minItems' => 2]); + } + + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 1, 'exclusiveMinimum' => true, 'exclusiveMaximum' => true], + 'uniqueItems' => true, + 'minItems' => 1, + 'maxItems' => 1, + ]), count: 10, seed: 139) as $value) { + Assert::same($value, [1]); + } + + // A one-value domain is `const`, a two-value one an `enum`, and a + // `boolean` a pair: each is knowable from the shape alone. + foreach ([ + [['const' => 'x'], ['x']], + [['enum' => ['x', 'y']], ['x', 'y']], + [['type' => 'boolean'], [true, false]], + ] as [$item, $expected]) { + Assert::same( + Gen::sample($compiler->compile(['type' => 'array', 'items' => $item, 'uniqueItems' => true, 'minItems' => 2, 'maxItems' => 2]), count: 10, seed: 149), + array_fill(0, 10, $expected), + ); + } + + // A `null` domain holds exactly one value. + foreach (Gen::sample($compiler->compile(['type' => 'array', 'items' => ['type' => 'null'], 'uniqueItems' => true, 'minItems' => 1, 'maxItems' => 1]), count: 5, seed: 151) as $value) { + Assert::same($value, [null]); + } + } + + public function containsIntersectsTheTailWithTheMembershipSchema(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // Every emitted element answers both `items` and `contains`, which is + // the intersection the compiler builds rather than either alone. + $schema = [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + 'contains' => ['type' => 'integer', 'minimum' => 10], + 'minContains' => 2, + 'maxItems' => 3, + ]; + foreach (Gen::sample($compiler->compile($schema), count: 20, seed: 157) as $value) { + Assert::true(is_array($value) && count($value) >= 2); + foreach ($value as $item) { + Assert::true(is_int($item) && $item >= 10); + } + } + + // A `contains` with no `items` narrows nothing: the tail is whatever + // an open array carries. + foreach (Gen::sample($compiler->compile([ + 'type' => 'array', + 'contains' => ['type' => 'integer'], + 'minContains' => 1, + 'maxItems' => 2, + ]), count: 20, seed: 163) as $value) { + Assert::true(is_array($value) && count($value) >= 1); + foreach ($value as $item) { + Assert::true(is_int($item)); + } + } + } + + public function aPatternNameIsToldApartFromAFreeOne(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // Two patterns that a generated name may match either of: the value + // under the name answers the schema of every pattern it matched. + $schema = [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'patternProperties' => [ + '^a' => ['type' => 'string', 'maxLength' => 3], + 'z$' => ['type' => 'boolean'], + ], + 'additionalProperties' => false, + ]; + $check = new SchemaCheck(); + $both = 0; + foreach (Gen::sample($compiler->compile($schema), count: 100, seed: 167) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if ($name === 'id') { + Assert::true(is_int($member)); + + continue; + } + $matchesA = str_starts_with((string) $name, 'a'); + $matchesZ = str_ends_with((string) $name, 'z'); + Assert::true($matchesA || $matchesZ); + if ($matchesA && $matchesZ) { + Assert::true(is_string($member) || is_bool($member)); + ++$both; + } elseif ($matchesA) { + Assert::true(is_string($member) && mb_strlen($member) <= 3); + } else { + Assert::true(is_bool($member)); + } + } + } + Assert::true($check->accepts((object) [], $schema, SchemaDialect::OpenApi31)); + + // A name the pattern matches answers the pattern, never + // `additionalProperties` — the two declarations are not alternatives + // between which a name is chosen. + $patternWins = [ + 'type' => 'object', + 'patternProperties' => ['^p_' => ['type' => 'string', 'maxLength' => 4]], + 'additionalProperties' => ['type' => 'integer'], + ]; + foreach (Gen::sample($compiler->compile($patternWins), count: 40, seed: 173) as $value) { + Assert::true(is_array($value)); + foreach ($value as $name => $member) { + if (str_starts_with((string) $name, 'p_')) { + Assert::true(is_string($member) && mb_strlen($member) <= 4); + } else { + Assert::true(is_int($member)); + } + } + } + + Assert::true($check->accepts((object) [], $patternWins, SchemaDialect::OpenApi31)); + + // A `patternProperties` entry that is not a named schema object is + // refused before any name is drawn. + Expect::exception(UnsupportedGeneration::class); + $compiler->compile(['type' => 'object', 'patternProperties' => ['^a' => ['string']]]); + } + + public function everyMemberAnswersTheSchemaOfItsOwnName(): void + { + $compiler = new SchemaArbitraryCompiler(); + $check = new SchemaCheck(); + + // The value a member carries is compiled against the name it lands + // under. Drawing the value once for the whole map handed every name + // the schema of whichever name was drawn first, and two thirds of the + // objects that reached the contract failed validation. + $schema = [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'patternProperties' => [ + '^a' => ['type' => 'string', 'maxLength' => 3], + 'z$' => ['type' => 'boolean'], + ], + 'additionalProperties' => false, + ]; + foreach (Gen::sample($compiler->compile($schema), count: 300, seed: 5) as $value) { + Assert::true($check->accepts((object) $value, $schema, SchemaDialect::OpenApi31)); + } + + // The same holds when the free member and the patterned ones declare + // different types, and for a name several patterns match: it answers + // all of them at once. + foreach ([ + [ + 'type' => 'object', + 'patternProperties' => ['^p_' => ['type' => 'string', 'maxLength' => 4]], + 'additionalProperties' => ['type' => 'integer'], + ], + [ + 'type' => 'object', + 'patternProperties' => [ + '^both' => ['type' => 'string'], + 'both$' => ['type' => 'string', 'maxLength' => 2], + ], + 'additionalProperties' => false, + ], + ] as $case) { + foreach (Gen::sample($compiler->compile($case), count: 200, seed: 7) as $value) { + Assert::true($check->accepts((object) $value, $case, SchemaDialect::OpenApi31)); + } + } + } + + public function anUnevaluatedKeywordClosesAnAllOfBranchItCannotSee(): void + { + $compiler = new SchemaArbitraryCompiler(); + $check = new SchemaCheck(); + + // `unevaluatedProperties` beside `allOf` closes the object: the merge + // has to carry it as a branch of its own, since a member no declared + // property answers is what it forbids. + $object = [ + 'allOf' => [ + ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']], 'required' => ['id']], + ], + 'unevaluatedProperties' => false, + ]; + foreach (Gen::sample($compiler->compile($object), count: 40, seed: 181) as $value) { + Assert::same(array_keys((array) $value), ['id']); + Assert::true($check->accepts((object) $value, $object, SchemaDialect::OpenApi31)); + } + + $array = [ + 'allOf' => [ + ['type' => 'array', 'prefixItems' => [['type' => 'integer']], 'minItems' => 1], + ], + 'unevaluatedItems' => false, + ]; + foreach (Gen::sample($compiler->compile($array), count: 40, seed: 181) as $value) { + Assert::same($value, [$value[0]]); + Assert::true($check->accepts($value, $array, SchemaDialect::OpenApi31)); + } + + // Without the keyword the same object admits an undeclared member, so + // the two runs differ in the closure alone and not in anything the + // merge decided. + $open = [ + 'allOf' => [ + ['type' => 'object', 'properties' => ['id' => ['type' => 'integer']], 'minProperties' => 2], + ], + ]; + $extras = 0; + foreach (Gen::sample($compiler->compile($open), count: 60, seed: 181) as $value) { + $extras += count($value) - 1; + } + Assert::true($extras > 0, 'a closed object was generated as an open one'); + + // An `allOf` sibling is refused by name: a member the branches do not + // mention cannot be closed after the fact. + foreach (['properties' => ['id' => ['type' => 'integer']], 'type' => 'object'] as $keyword => $value) { + Expect::exception(UnsupportedGeneration::class) + ->withMessage(sprintf('Unsupported OpenAPI schema generation: allOf with sibling keyword "%s" is outside the supported subset', $keyword)); + $compiler->compile(['allOf' => [['type' => 'object']], $keyword => $value, 'description' => 'annotated']); + } + + // A branch that closes the object on its own is refused too — the + // merge would have to decide which of the two closures wins. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: unevaluated keywords inside allOf branches are outside the constructive subset'); + $compiler->compile(['allOf' => [['unevaluatedProperties' => false]], 'unevaluatedProperties' => false]); + } + + public function anAllOfCannotMoveItemsAcrossASiblingTuple(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // Two branches disagreeing about where the tuple ends cannot be + // merged into one array: the head of one is not the head of the other. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: allOf items cannot be moved across sibling prefixItems'); + $compiler->compile([ + 'allOf' => [ + ['type' => 'array', 'prefixItems' => [['type' => 'integer'], ['type' => 'string']]], + ['items' => false], + ], + ]); + + // Branches that agree keep the tuple, and the merged array answers it. + $check = new SchemaCheck(); + $schema = [ + 'allOf' => [ + ['type' => 'array', 'prefixItems' => [['type' => 'integer'], ['type' => 'string']], 'minItems' => 2, 'maxItems' => 2, 'unevaluatedItems' => false], + ], + ]; + foreach (Gen::sample($compiler->compile($schema), count: 20, seed: 191) as $value) { + Assert::same($value, [$value[0], $value[1]]); + Assert::true($check->accepts($value, $schema, SchemaDialect::OpenApi31)); + } + } + + public function aNotLeavesAtLeastOneValueOfTheEnumItNarrows(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // Two of three values survive, so the filter keeps the schema and the + // generator only ever answers with one of them. + $narrowed = $compiler->compile(['enum' => ['x', 'y', 'z'], 'not' => ['enum' => ['x']]]); + $values = array_unique(Gen::sample($narrowed, count: 40, seed: 193)); + sort($values); + Assert::same($values, ['y', 'z']); + + // The `not` may list more than one value, and an enum whose values are + // written as a map (which JSON forbids, but a hand-built schema can + // carry) is re-indexed before the comparison. + foreach (Gen::sample($compiler->compile(['enum' => ['x', 'y', 'z'], 'not' => ['enum' => ['x', 'y']]]), count: 40, seed: 197) as $value) { + Assert::same($value, 'z'); + } + + // A `not` beside a `const` narrows nothing, so the constant stands. + foreach (range(1, 10) as $seed) { + Assert::same($compiler->compile(['const' => 'x', 'not' => ['const' => 'y']])->generate(new Random($seed))->value, 'x'); + } + + // A `not` that forbids a type the source does not declare at all + // changes nothing, and the values keep their declared type. + foreach (Gen::sample($compiler->compile(['type' => 'integer', 'not' => ['type' => 'boolean']]), count: 20, seed: 199) as $value) { + Assert::true(is_int($value)); + } + } + + public function everyRefusalMessageIsTheOneTheCallerIsShown(): void + { + $compiler = new SchemaArbitraryCompiler(); + + // `propertyNames` is a filter on the names an object may carry, and a + // declared one it filters out is refused rather than silently dropped. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: propertyNames excludes a declared property'); + $compiler->compile([ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer'], 'Tag' => ['type' => 'string']], + 'propertyNames' => ['pattern' => '^[a-z]+$'], + ]); + } + + public function aDependentBudgetOverflowIsRefusedByName(): void + { + // The enumeration has a budget, and a document past it is told so + // rather than generating an exponential number of combinations. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: dependent keywords exceed the eight-trigger generation budget'); + (new SchemaArbitraryCompiler())->compile([ + 'type' => 'object', + 'properties' => array_fill_keys(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'], ['type' => 'string']), + 'dependentRequired' => array_fill_keys(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'], []), + ]); + } + + public function aForeignDirectionMemberUnderADependentKeywordIsRefusedByName(): void + { + // A dependent keyword beside a property the other direction owns has + // no constructible answer: the member is never sent, and the trigger + // it keys on can therefore never be there. + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: dependent keywords with foreign-direction properties are outside the constructive subset'); + (new SchemaArbitraryCompiler(direction: SchemaDirection::Request))->compile([ + 'type' => 'object', + 'properties' => [ + 'kind' => ['type' => 'string'], + 'detail' => ['type' => 'string', 'readOnly' => true], + ], + 'dependentSchemas' => ['kind' => ['required' => ['detail']]], + ]); + } + + /** + * Each half of a guard chain is a refusal the caller can hit on its own, + * so each is provoked on its own: a chain tested only through one of its + * branches cannot tell the branches apart. + * + * @param array $schema + */ + #[DataProvider('refusedShapes')] + public function aRefusedShapeIsRefusedWhateverElseItCarries(array $schema, string $refusal): void + { + Expect::exception(UnsupportedGeneration::class) + ->withMessage('Unsupported OpenAPI schema generation: ' . $refusal); + (new SchemaArbitraryCompiler())->compile($schema); + } + + /** @return iterable, string}> */ + public static function refusedShapes(): iterable + { + // An unevaluated keyword beside a conditional or a union is outside + // the constructive subset — one refusal, six ways to ask for it. + foreach (['unevaluatedProperties' => ['type' => 'object'], 'unevaluatedItems' => ['type' => 'array']] as $keyword => $subschema) { + foreach (['if' => true, 'anyOf' => [[]], 'oneOf' => [[]]] as $applicator => $value) { + yield $keyword . ' beside ' . $applicator => [ + ['type' => $keyword === 'unevaluatedItems' ? 'array' : 'object'] + [$keyword => $subschema, $applicator => $value], + 'unevaluated keywords with conditional or union applicators are outside the constructive subset', + ]; + } + } + + // A dependent keyword needs a plain object: each of the six ways of + // not being one is refused by the same message. + foreach (['patternProperties' => ['^a' => ['type' => 'string']], 'if' => true, 'allOf' => [[]], 'anyOf' => [[]], 'oneOf' => [[]]] as $keyword => $value) { + yield 'dependentRequired beside ' . $keyword => [ + ['type' => 'object', 'properties' => ['a' => ['type' => 'string']], 'dependentRequired' => ['a' => []], $keyword => $value], + 'dependent keywords require a plain object schema', + ]; + } + yield 'dependentRequired on a non-object' => [ + ['type' => 'string', 'dependentRequired' => ['a' => []]], + 'dependent keywords require a plain object schema', + ]; + } + + /** + * A schema may name no type at all and still say what it is: each of + * these keywords answers on its own, and a document carrying one is + * generated as the type it implies. + * + * @param array $schema + */ + #[DataProvider('typeInference')] + public function aStructuralKeywordIsEnoughToSayWhatTheSchemaIs(array $schema, string $expected): void + { + foreach (Gen::sample((new SchemaArbitraryCompiler())->compile($schema), count: 20, seed: 179) as $value) { + Assert::true(is_array($value)); + Assert::true($expected === 'object' ? !array_is_list($value) || $value === [] : array_is_list($value)); + } + } + + /** @return iterable, string}> */ + public static function typeInference(): iterable + { + // A dependent keyword needs its trigger declared, so the object these + // produce is carried by `properties` and it is `dependentRequired` + // alone that answers the question of what type the schema is. + yield 'dependentRequired' => [[ + 'properties' => ['a' => ['type' => 'integer']], + 'dependentRequired' => ['a' => []], + ], 'object']; + yield 'dependentSchemas' => [[ + 'properties' => ['a' => ['type' => 'integer']], + 'dependentSchemas' => ['a' => ['maxProperties' => 1]], + ], 'object']; + yield 'unevaluatedProperties' => [['unevaluatedProperties' => true], 'object']; + yield 'properties' => [['properties' => ['id' => ['type' => 'integer']]], 'object']; + yield 'items' => [['items' => ['type' => 'integer']], 'array']; + yield 'prefixItems' => [['prefixItems' => [['type' => 'integer']]], 'array']; + yield 'contains' => [['contains' => ['type' => 'integer']], 'array']; + + // Each is asked for alone: one keyword of the pair would answer just + // as well, and the pair would then prove nothing about the other. + yield 'dependentRequired with a declared trigger' => [[ + 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']], + 'dependentRequired' => ['a' => ['b']], + ], 'object']; + } }