diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 76097f3..e02d34f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,6 +97,14 @@ jobs: with: persist-credentials: false + - name: Checkout contract PR + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: rasuvaeff/openapi-contract + ref: master + path: contract-source + persist-credentials: false + - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: @@ -115,6 +123,11 @@ jobs: - name: Install dependencies run: composer install --no-interaction --no-progress --prefer-dist + - name: Use contract PR source + run: | + rm -rf vendor/rasuvaeff/openapi-contract + ln -s "$GITHUB_WORKSPACE/contract-source" vendor/rasuvaeff/openapi-contract + - name: Build run: composer build @@ -130,6 +143,14 @@ jobs: with: persist-credentials: false + - name: Checkout contract PR + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: rasuvaeff/openapi-contract + ref: master + path: contract-source + persist-credentials: false + - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: @@ -148,6 +169,11 @@ jobs: - name: Install dependencies run: composer update --prefer-lowest --prefer-stable --no-interaction --no-progress + - name: Use contract PR source + run: | + rm -rf vendor/rasuvaeff/openapi-contract + ln -s "$GITHUB_WORKSPACE/contract-source" vendor/rasuvaeff/openapi-contract + - name: Build run: composer build @@ -208,6 +234,14 @@ jobs: with: persist-credentials: false + - name: Checkout contract PR + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: rasuvaeff/openapi-contract + ref: master + path: contract-source + persist-credentials: false + - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: @@ -226,6 +260,11 @@ jobs: - name: Install dependencies run: composer install --no-interaction --no-progress --prefer-dist + - name: Use contract PR source + run: | + rm -rf vendor/rasuvaeff/openapi-contract + ln -s "$GITHUB_WORKSPACE/contract-source" vendor/rasuvaeff/openapi-contract + - name: Restore property regression corpus uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/README.md b/README.md index 9bb6df2..b922bc3 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,12 @@ the same way. | `minLength`, `maxLength` (capped at 64), `pattern` (PCRE subset) | supported | | `format`: `uuid`, `email`, `ipv4`, `uri`, `uri-reference`, `url`, `date`, `date-time`, `password` (annotation) | supported; a length window the format cannot satisfy, or `pattern` combined with an asserted format, fails closed | | `items`, `minItems`, `maxItems` (capped at 16), `uniqueItems` | supported; `uniqueItems` over a finite item domain smaller than `minItems` fails closed | -| `properties`, `required`, `minProperties`, `maxProperties` (capped at 16), `additionalProperties` (boolean or schema) | supported; the cardinality is met by construction (an optional past the ceiling is left out, one needed for the floor brought in) | +| `properties`, `patternProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties` (capped at 16), `additionalProperties` (boolean or schema) | supported; pattern keys are generated from the declared regexes and values satisfy every matching pattern schema; the cardinality is met by construction | | `readOnly` (requests), `writeOnly` (responses) | dropped per direction | | `anyOf`, `oneOf` (provably disjoint branches, or one `integer` beside one `number` branch: a value is kept only when exactly one admits it, the number branch's `multipleOf` judged by the contract's own `SchemaCheck::isMultipleOf()`), `allOf` (mergeable branches; a branch bounding `additionalProperties` must declare every sibling property) | supported | | `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`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` | +| `if`/`then`/`else`, `contains`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedItems`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` | 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 2d97c7d..aaf393b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -131,12 +131,12 @@ request-направлению схемы — `readOnly`-свойства ухо | `minLength`, `maxLength` (не более 64), `pattern` (подмножество PCRE) | поддержано | | `format`: `uuid`, `email`, `ipv4`, `uri`, `uri-reference`, `url`, `date`, `date-time`, `password` (аннотация) | поддержано; окно длины, которое format не может удовлетворить, или `pattern` вместе с проверяемым format падают fail-closed | | `items`, `minItems`, `maxItems` (не более 16), `uniqueItems` | поддержано; `uniqueItems` над конечным доменом элементов меньше `minItems` падает fail-closed | -| `properties`, `required`, `minProperties`, `maxProperties` (не более 16), `additionalProperties` (boolean или схема) | поддержано; кардинальность выполняется по построению (optional сверх потолка выпадает, нужный для пола — добавляется) | +| `properties`, `patternProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties` (не более 16), `additionalProperties` (boolean или схема) | поддержано; ключи pattern генерируются из объявленных regex, значения удовлетворяют всем совпавшим схемам, кардинальность выполняется по построению | | `readOnly` (requests), `writeOnly` (responses) | отбрасываются по направлению | | `anyOf`, `oneOf` (доказуемо непересекающиеся ветви, либо одна ветвь `integer` рядом с одной `number`: значение остаётся, только если его допускает ровно одна, а `multipleOf` number-ветви судит собственный `SchemaCheck::isMultipleOf()` контракта), `allOf` (сливаемые ветви; ветвь, ограничивающая `additionalProperties`, обязана объявлять все свойства соседей) | поддержано | | `not` с `const`, `enum` или `type` | поддержано; `not`, исключающий каждый объявленный тип, падает fail-closed | | `$defs` + локальный `$ref` (`#/$defs/…`) — скомпилированная контрактом форма схемы, ссылающейся на саму себя: дерево, чьи `children` тоже деревья | поддерживается; def разворачивается на три уровня под корнем и заканчивается листом, который опускает рекурсивный член (необязательное свойство пропущено, массив, которому можно быть пустым, пуст, ветка выбора пропущена); def, единственный путь вниз которого — обязательный член, равный самому def, не имеет конечного экземпляра и fail-closed; `$ref` куда-либо кроме собственных `$defs` схемы — fail-closed | -| `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` | +| `if`/`then`/`else`, `contains`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedItems`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` | Каждая невыполнимая комбинация, которую компилятор способен распознать, отвергается при компиляции; то, что остаётся вероятностным (`pattern`, редко diff --git a/llms.txt b/llms.txt index 1a91e63..999c4fa 100644 --- a/llms.txt +++ b/llms.txt @@ -300,8 +300,10 @@ form property is omitted at materialization (RFC 6570: undefined), and required array/object parameters and form properties are generated non-empty. Required parameters and request bodies are always generated. Optional parameters and JSON bodies are generated as both present and absent branches. -Object generation supports bounded `minProperties`, `maxProperties`, and -boolean or schema-valued `additionalProperties`. +Object generation supports bounded `minProperties`, `maxProperties`, +`patternProperties`, `propertyNames`, and boolean or schema-valued +`additionalProperties`; generated pattern values satisfy every matching +pattern schema. The constructive `not` subset supports `const`, `enum`, and `type` exclusions; other negative assertions fail closed. A schema that refers to itself arrives from the contract as `$defs` plus a diff --git a/src/Internal/Compile/ContainerArbitraries.php b/src/Internal/Compile/ContainerArbitraries.php index 007aa3a..9b23ea8 100644 --- a/src/Internal/Compile/ContainerArbitraries.php +++ b/src/Internal/Compile/ContainerArbitraries.php @@ -97,6 +97,35 @@ private function finiteDomain(array $items): ?int public function object(array $schema): 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'); + $propertyNames = array_key_exists('propertyNames', $schema) + ? $this->facts->schemaObject($schema['propertyNames'], 'propertyNames must be a schema object') + : null; + if ($propertyNames !== null) { + foreach (array_keys($propertyNames) as $keyword) { + if ($keyword !== 'pattern') { + throw UnsupportedGeneration::forSchema(sprintf('propertyNames keyword "%s" is outside the supported generator subset', $keyword)); + } + } + } + $patternArbitraries = []; + /** @var array> $typedPatterns */ + $typedPatterns = []; + foreach ($patterns as $pattern => $patternSchema) { + if (!is_array($patternSchema) || array_is_list($patternSchema)) { + throw UnsupportedGeneration::forSchema('patternProperties must contain named schema objects'); + } + /** @var array $patternSchema */ + $typedPatterns[$pattern] = $patternSchema; + + try { + $key = Gen::stringMatching($pattern); + } catch (\InvalidArgumentException $exception) { + throw UnsupportedGeneration::forSchema(sprintf('patternProperties key is not supported: %s', $exception->getMessage())); + } + $patternArbitraries[] = [$pattern, $key]; + } + $patterns = $typedPatterns; $required = $schema['required'] ?? []; if (!is_array($required) || !array_is_list($required)) { throw UnsupportedGeneration::forSchema('required must be a list of property names'); @@ -120,6 +149,9 @@ public function object(array $schema): ArbitraryInterface $reserved = []; /** @var array $requiredNames */ foreach ($properties as $name => $property) { + if ($propertyNames !== null && !$this->matchesSchema($name, $propertyNames)) { + throw UnsupportedGeneration::forSchema('propertyNames excludes a declared property'); + } if (!is_array($property) || array_is_list($property)) { throw UnsupportedGeneration::forSchema('object properties must contain named schema objects'); } @@ -132,7 +164,14 @@ public function object(array $schema): ArbitraryInterface continue; } - $compiled = $this->producible($property); + $applicable = [$property]; + foreach ($patterns as $pattern => $patternSchema) { + if (preg_match('~' . $pattern . '~', $name) === 1) { + /** @var array $patternSchema */ + $applicable[] = $patternSchema; + } + } + $compiled = $this->producible(count($applicable) === 1 ? $property : ['allOf' => $applicable]); if (!$compiled instanceof \Rasuvaeff\PropertyTesting\ArbitraryInterface) { // The leaf of a recursive def: an optional member that would // hold more of it is left out, a required one gives it up. @@ -156,7 +195,7 @@ public function object(array $schema): ArbitraryInterface } $additional = $this->facts->additionalPropertiesSchema($schema); - if ($additional === false && $minProperties > count($shape)) { + if ($additional === false && $patternArbitraries === [] && $minProperties > count($shape)) { throw UnsupportedGeneration::forSchema('minProperties requires additional properties, but additionalProperties is false'); } // The declared optionals meet the cardinality by construction: an @@ -182,23 +221,74 @@ public function object(array $schema): ArbitraryInterface return self::objectValues($typed, $requiredNames, $declaredFloor, $maxProperties); }); - if ($additional === false || $minProperties <= 0 && $shape !== []) { + if (($additional === false && $patternArbitraries === []) || ($minProperties <= 0 && $shape !== [] && $patternArbitraries === [])) { return $base; } $keyAlphabet = 'abcdefghijklmnopqrstuvwxyz'; - /** @var ArbitraryInterface $key */ - $key = Gen::map( - Gen::filter( - Gen::stringFrom($keyAlphabet, minLength: 1, maxLength: 8), - static fn(string $name): bool => !array_key_exists($name, $shape) && !isset($reserved[$name]), - ), - static fn(string $name): string => $name, - ); + /** @var ArbitraryInterface $key */ + if ($patternArbitraries !== []) { + $key = Gen::map(Gen::frequency(array_map( + function (array $entry) use ($shape, $reserved, $propertyNames, $patterns): array { + /** @var string $pattern */ + [$pattern, $source] = $entry; + $filtered = Gen::filter($source, function (mixed $name) use ($pattern, $shape, $reserved, $propertyNames, $patterns): bool { + if (!is_string($name) || array_key_exists($name, $shape) || isset($reserved[$name])) { + return false; + } + if ($propertyNames !== null && !$this->matchesSchema($name, $propertyNames)) { + return false; + } + $matches = []; + foreach (array_keys($patterns) as $candidate) { + if (preg_match('~' . $candidate . '~', $name) === 1) { + $matches[] = $candidate; + } + } + + return in_array($pattern, $matches, strict: true); + }); + + return [1, $filtered]; + }, + $patternArbitraries, + )), static fn(string $name): string => $name); + } else { + $key = Gen::map( + Gen::filter( + Gen::stringFrom($keyAlphabet, minLength: 1, maxLength: 8), + fn(string $name): bool => !array_key_exists($name, $shape) && !isset($reserved[$name]) + && ($propertyNames === null || $this->matchesSchema($name, $propertyNames)), + ), + static fn(string $name): string => $name, + ); + } /** @var ArbitraryInterface $value */ - $value = is_array($additional) && $additional !== [] - ? $this->compiler->compile($additional) - : $this->additionalValue(); + 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]); + } + } + + return is_array($additional) && $additional !== [] + ? $this->compiler->compile($additional) + : $this->additionalValue(); + }); + } else { + $value = is_array($additional) && $additional !== [] + ? $this->compiler->compile($additional) + : $this->additionalValue(); + } return Gen::flatMap($base, fn(array $values): ArbitraryInterface => $this->additionalProperties( $values, @@ -209,6 +299,26 @@ public function object(array $schema): ArbitraryInterface )); } + /** @param array $schema */ + private function matchesSchema(string $value, array $schema): bool + { + $pattern = $schema['pattern'] ?? null; + + if ($pattern === null) { + return true; + } + if (!is_string($pattern)) { + throw UnsupportedGeneration::forSchema('propertyNames pattern must be a string'); + } + + $result = preg_match('~' . $pattern . '~', $value); + if ($result === false) { + throw UnsupportedGeneration::forSchema('propertyNames pattern is not supported'); + } + + return $result === 1; + } + /** @return ArbitraryInterface */ public function additionalValue(): ArbitraryInterface { @@ -231,7 +341,7 @@ public function additionalValue(): ArbitraryInterface /** * @param array $values - * @param ArbitraryInterface $key + * @param ArbitraryInterface $key * @param ArbitraryInterface $value * @return ArbitraryInterface> */ diff --git a/src/SchemaArbitraryCompiler.php b/src/SchemaArbitraryCompiler.php index 1bf3d91..b25431b 100644 --- a/src/SchemaArbitraryCompiler.php +++ b/src/SchemaArbitraryCompiler.php @@ -196,8 +196,8 @@ private function assertSupported(array $schema): void { foreach ([ 'allOf', 'anyOf', 'oneOf', 'if', 'then', 'else', - 'contains', 'prefixItems', 'patternProperties', - 'propertyNames', 'unevaluatedProperties', + '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/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index d1bd829..e2ebf41 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -439,6 +439,32 @@ public function rejectsUnsupportedPatternSyntax(): void (new SchemaArbitraryCompiler())->compile(['type' => 'string', 'pattern' => '(?=x)']); } + public function generatesPatternPropertiesAndConstrainedPropertyNames(): void + { + $schema = [ + 'type' => 'object', + 'minProperties' => 2, + 'maxProperties' => 3, + 'properties' => ['x-id' => ['type' => 'integer']], + 'patternProperties' => [ + '^x-' => ['type' => 'integer', 'minimum' => 1], + '^x-.*$' => ['maximum' => 5], + ], + 'propertyNames' => ['pattern' => '^x-'], + 'additionalProperties' => false, + ]; + $values = Gen::sample((new SchemaArbitraryCompiler())->compile($schema), count: 80, seed: 817); + foreach ($values as $value) { + Assert::true(is_array($value) && count($value) >= 2 && count($value) <= 3); + foreach ($value as $name => $member) { + Assert::true(is_string($name) && preg_match('/^x-/', $name) === 1); + if (str_starts_with($name, 'x-')) { + Assert::true(is_int($member) && $member >= 1 && $member <= 5); + } + } + } + } + public function rejectsUnknownFormats(): void { Expect::exception(UnsupportedGeneration::class); @@ -457,7 +483,7 @@ public function rejectsUnsupportedAssertionKeywords(string $keyword): void /** @return iterable */ public static function unsupportedKeywordCases(): iterable { - foreach (['allOf', 'anyOf', 'oneOf', 'if', 'then', 'else', 'contains', 'prefixItems', 'patternProperties', 'propertyNames', 'unevaluatedProperties'] as $keyword) { + foreach (['allOf', 'anyOf', 'oneOf', 'if', 'then', 'else', 'contains', 'prefixItems', 'unevaluatedProperties', 'dependentRequired', 'dependentSchemas', 'unevaluatedItems'] as $keyword) { yield $keyword => [$keyword]; } } diff --git a/tests/Support/ZooContracts.php b/tests/Support/ZooContracts.php index d2472d6..0894965 100644 --- a/tests/Support/ZooContracts.php +++ b/tests/Support/ZooContracts.php @@ -29,7 +29,7 @@ final class ZooContracts 'delimited.get', 'reserved.get', 'unions.get', 'uploads.create', 'dual.create', 'encoded.create', 'mixed.create', 'numeric.create', 'headers.get', 'verified.get', 'search.get', 'narrowed.create', 'bounded.create', 'pages.get', 'profiles.create', - 'labels.get', 'sparse.create', 'amounts.create', 'settings.create', 'precise.get', + 'labels.get', 'patternLabels.create', 'sparse.create', 'amounts.create', 'settings.create', 'precise.get', 'tree.create', ]; @@ -370,6 +370,18 @@ public static function document(): array ], 'responses' => ['204' => []], ]], + '/pattern-labels' => ['post' => [ + 'operationId' => 'patternLabels.create', + 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'minProperties' => 1, + 'maxProperties' => 4, + 'patternProperties' => ['^x-[a-z]+$' => ['type' => 'integer', 'minimum' => 1]], + 'propertyNames' => ['pattern' => '^x-'], + 'additionalProperties' => false, + ]]]], + 'responses' => ['204' => []], + ]], // Twelve optionals under `maxProperties: 1`: the cardinality // is met by construction, not by filtering independent // presence choices until three runs in four exhaust (#123).