diff --git a/CLAUDE.md b/CLAUDE.md index a871df00..8262ec5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ output is available for analysis without re-running. Use `--display-warnings` to details and `--no-coverage` to skip slow coverage collection: ```bash -php -d memory_limit=128M ./vendor/bin/phpunit --no-coverage --display-warnings 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > /tmp/phpunit-output.txt; tail -5 /tmp/phpunit-output.txt +php -d memory_limit=256M ./vendor/bin/phpunit --no-coverage --display-warnings 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > /tmp/phpunit-output.txt; tail -5 /tmp/phpunit-output.txt ``` Then analyse with: `grep -E "FAIL|ERROR|WARN|Tests:" /tmp/phpunit-output.txt` @@ -280,6 +280,36 @@ After finishing an implementation task, always stage all relevant changed files Never add `.claude/` files (issues, topics, memory, etc.) to git unless the user explicitly asks. These are working notes for the session and must not appear in commits. +**Always review the diff against the repo rules before staging.** Before running `git add`, +inspect the full diff (`git diff` for unstaged work, plus `git diff --staged` afterwards) and +verify every change conforms to the rules in this file. In particular, run the recovery procedure +from the "No implementation-plan references in code" rule: grep the diff for `Stage `, `Phase `, +`decision `, `§`, and `#` followed by a number, and rewrite every match in source, test, or +prod-lib code (test data providers, DocBlocks, and inline comments included) before staging. +This review is *mandatory*, not optional — staging without it lets violations slip into commits. + +The same review applies to changes pushed to a coordinated production-library checkout: source +code in `php-json-schema-model-generator-production` is bound by the same rules as code in this +repo. Planning artefacts under `.claude/` are the only place plan references may live. + +### Pre-existing rule violations in touched files + +Whenever you edit, read, or otherwise touch a file as part of any task, sweep it for *all* +pre-existing violations of the rules in this file — implementation-plan references, single- +letter variables, leading-backslash class references, missing `use` imports, copy-pasted +docblocks, PHPCS errors visible in the local run, and anything else CLAUDE.md forbids — and +fix every one in the same change. Do not leave a known violation sitting just because it +predates your edit; "broken windows" is exactly how decay accumulates and the rule erodes. + +Scope: this is about files you *touch*, not a codebase-wide audit. If you edit a method, +scan the whole file (not just the surrounding lines) and fix everything visible. If +fixing the pre-existing violations would balloon the diff into an unrelated refactor, +flag it (and only then) before proceeding — that is the only escape hatch. Default is: +clean it up. + +The rule applies symmetrically to the production-library checkout when you edit anything +there. + ### Reading files Always use the dedicated `Read` tool to read file contents. Never use `sed`, `head`, `tail`, `cat`, or `awk` to read or extract portions of files. The `Read` tool supports `offset` and `limit` parameters for reading partial files when needed. @@ -290,6 +320,15 @@ Never use single-character variable names. All variables must have meaningful, d that convey their purpose. For example, use `$typeName` instead of `$t`, `$validator` instead of `$v`, `$property` instead of `$p`. +Never prefix local variables with an underscore. The underscore prefix is reserved for *class +member* identifiers (instance properties, internal methods like `_validateTags`) where it marks +the symbol as internal-by-convention. Applying the same prefix to local variables blurs the +member/local distinction without adding information. Use the plain name instead: +`$branchContainsMatches`, not `$_branchContainsMatches`. The rule applies symmetrically to +variables declared inside generated template code that escape into the generated PHP output — +local variables inside template-emitted closures and IIFEs are still local PHP variables and +must not carry the underscore prefix. + ### PHP import style Always add `use` imports for every class referenced in a file, including global PHP classes such as @@ -414,23 +453,111 @@ typo, a config flag that doesn't mean what you assumed) — and say so explicitl reason the original input was invalid, not just "adjusted the probe." A silently adjusted probe erases the evidence that a bug exists. -#### No implementation-plan references in code +#### How to handle every bug found during development + +This rule generalises the test-evasion rule above to every bug, however it is discovered — +through a failing test, while reading code, during a debugging session, or as a side-observation +in an unrelated task. + +**Every bug must be acknowledged explicitly the moment it is found.** State, in the user-facing +response, *exactly* what the bug is — the failure mode, the code path, and a minimal reproducer +or pointer to one. Do not let bugs surface implicitly as test failures the user has to dig out +of the log; surface them in prose. + +**Write a test that reproduces the bug *before* fixing it.** This applies whether the bug is +about to be fixed in the same change (path 1 below) or deferred (path 2). The test name encodes +the specific scenario so a regression surfaces immediately as a named, self-explaining failure +rather than a cryptic assertion error elsewhere. When deferring, the test is marked failing +(`$this->expectException(...)` plus an explicit assertion of the *current wrong* behaviour, or +`#[Test] #[ExpectedFailure]` if the framework supports it) so the gap is visible in CI until +the fix lands. When fixing in the same change, the test starts red and turns green as the fix +lands — the diff carries proof that the change actually closes the reported scenario, not just +that other tests still pass. Sequence: reproduce → confirm red → fix → confirm green. Do not +skip the "confirm red" step; a test that was always green is no evidence of anything. + +**Every acknowledged bug takes one of three paths. Choose explicitly, never silently:** + +1. **Fix it in the same change.** Default path. If the bug is reachable from the work in + progress and fixing it does not balloon the diff into an unrelated refactor, fix it now and + note the fix in the response. +2. **Defer with a tracked artifact.** Only when the fix is genuinely out of scope (different + subsystem, requires user direction on architecture, blocked on external work). Deferral + requires *both*: + - A tracking entry: a `.claude/topics//` plan stub, a `@expectedException` test + fixture marked failing, OR an entry in the active plan's post-implementation review list + — whichever fits the current workflow. + - A surfacing mechanism in the codebase: the failing test, a `throw new \LogicException(...)` + at the unreachable site, or a documented assertion. Comments alone are not enough — a + comment without an enforcement mechanism rots silently. + + State the deferral and the chosen tracking + surfacing mechanism in the same response that + announces the bug. +3. **Reject it as not-a-bug.** When closer reading reveals the apparent bug is correct behaviour + under a constraint you missed. Explain the constraint in the response and update any + misleading comment, test, or doc that suggested otherwise. + +**Routing around a bug is forbidden.** Removing a test, swapping a fixture for one that does not +trigger the bug, narrowing a test's assertion to skip the affected output, choosing an +alternative implementation path purely to avoid touching the buggy subsystem, or deciding "this +edge case is rare so I'll not test it" — all of these are silent suppression. They are explicitly +not in the deferral path: deferral requires the bug to remain *visible*, just not yet fixed. + +**A bug downstream of your change is still your bug.** When your edit causes a previously-passing +test to fail, the test is reporting a real defect in your change — even if the test was +"unrelated" before. Do not dismiss it as "pre-existing brittleness"; the failure path is now in +scope. Either your change has a bug, or the test was wrong all along and now is the right time +to fix it (explicitly, with justification). The same applies symmetrically: when you discover an +existing bug while reading code, the "broken windows" rule from "Pre-existing rule violations in +touched files" still applies — flag it, then decide between paths 1, 2, and 3. + +**Why this matters.** Silent bug suppression is the most insidious form of code rot because each +individual instance looks like a reasonable scope-management decision. Over a long session the +cumulative effect is a codebase where everyone knows "you can't go that way" and the deferred +defects compound. Surfacing every bug — every time, in prose, with a path forward — is the only +discipline that prevents this. -Do not embed references to implementation-plan phases, issue numbers, or source-code line numbers -in comments, docblocks, filenames, or any other artifact that lands in the repository. These -references decay immediately (phases complete, line numbers shift) and add noise without adding -meaning. +#### No implementation-plan references in code +Do not embed references to implementation-plan phases, section numbers, decision identifiers, +issue numbers, or source-code line numbers in comments, docblocks, filenames, test fixture +descriptions, or any other artifact that lands in the repository. These references decay +immediately (plans get restructured, phases complete, sections renumber, line numbers shift) +and add noise without adding meaning to a reader who does not have the plan open in another +tab. + +**Patterns that violate the rule** — anything in this category must be rewritten: +- `Phase N`, `Phase N's`, `phase N landed` +- `decision N.N`, `Decision N.N`, `per decision N.N` +- `§N.N`, `§N.N.N`, `section N.N`, `§N.N's matrix` +- `Per §N.N`, `Follows §N.N`, `the §N.N test list` +- Issue numbers (`#123`) used as a stand-in for an explanation +- Specific line numbers in the codebase (`lines 130-158`, `line 429`) +- References to documents under `.claude/` from anywhere outside `.claude/` + +**Examples:** - ❌ `// Phase 2 guarantees anyOf/oneOf have uniform spaces` - ✅ `// Static rejection guarantees anyOf/oneOf have uniform spaces` +- ❌ `* Emission policy follows the §3.5.2.1 / decision 0.10 matrix` +- ✅ `* Emission policy: emit when the keyword's reach is non-empty (additionalProperties absent or true)` +- ❌ `// Dead-code rows from §4.1: additionalProperties: false or {schema}` +- ✅ `// additionalProperties: false / {schema} leave the unevaluated bucket permanently empty` - ❌ `* Covers FilterValidator::runCompatibilityCheck lines 130–158` - ✅ `* Validates the zero-overlap rejection path in FilterValidator` - ❌ `* exercises FilterProcessor line 429 (else branch of classifyValidatorAdjustments)` - ✅ `* exercises the else branch of classifyValidatorAdjustments` +- ❌ `// Decision 0.3: Also harvest inline branch property names` +- ❌ `// Decision 0.6 unconditional rollback` +- ❌ `// Phase 3's UnevaluatedPropertiesValidator can query...` +- ❌ `// not with inline branch — Decision 0.6: slot permanently success=false` -This rule applies equally to DocBlocks in test files: do not reference specific line numbers of -the code under test. Line numbers shift whenever the file is edited, making such references -misleading immediately after refactoring. Describe *what the code does or why* instead. +This rule applies equally to DocBlocks in test files: do not reference specific line numbers +of the code under test, decision identifiers from the plan, or section numbers anywhere in the +plan. Line numbers shift whenever the file is edited, and section/decision numbers decay +whenever the plan is restructured. Describe *what the code does or why* instead. + +**Recovery procedure when this rule is violated:** before staging a change, grep the diff for +`Phase `, `decision `, `§`, and `#` followed by a number. Rewrite every match found in source +or test files to a self-contained explanation of the rule or behaviour. Describe *what the code does or why* — not where it came from in a planning document. @@ -466,6 +593,19 @@ Never use multiple `assertStringContainsString` calls on the same exception mess message can be constructed. A single `assertSame($expectedMessage, $exception->getMessage())` is both stronger and self-documenting. +When the expected exception message spans multiple lines (e.g. an `ErrorRegistryException` +joining several sub-errors with `"\n"`, or any nested-exception format that embeds newlines), +**always write the expected value as a heredoc**, never as a `sprintf` call with `\n` escapes +or as concatenated `.` string fragments. Heredoc preserves the literal layout of the message +exactly as it will appear at runtime, so the test source reads as the message and a diff +against the actual output is line-by-line. Use the variable-interpolating `<<`__ for the full explanation. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +every ``allOf`` branch always contributes to the evaluated set — ``allOf`` requires every branch +to succeed, so all branches' declarations apply. + +For an object-level composition: + +- Property names declared in each branch's ``properties`` count as evaluated. +- Names matched by each branch's ``patternProperties`` count as evaluated (per key with a passing + value). +- Names claimed by each branch's ``additionalProperties`` count as evaluated (per key with a + passing value). + +For a value-typed ``array`` composition, the analogous indices contributed by each branch's +``items``/``additionalItems``/``contains`` count as evaluated. + +.. note:: + + *Omitting* ``additionalProperties`` from a branch is **not** the same as writing + ``additionalProperties: true``. An omitted keyword produces no annotation and therefore + credits nothing to the enclosing ``unevaluatedProperties`` — only an *explicit* + ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with + identical extras behaviour but one writing the keyword and the other omitting it will + therefore credit different evaluated sets. This is a spec-mandated distinction from + JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + +.. note:: + + Only this *up* direction (a branch's own declarations propagating to an enclosing + ``unevaluatedProperties``/``unevaluatedItems``) is implemented. The reverse — a branch's own + ``unevaluatedProperties``/``unevaluatedItems`` (other than a literal ``true``, which never + rejects anything) seeing property names or indices declared by the *enclosing* schema or a + *sibling* branch — is not currently supported. Generation throws + ``PHPModelGenerator\Exception\UnsupportedSchemaFeatureException`` for a branch shaped like + this: + + .. code-block:: json + + { + "type": "object", + "properties": { "name": { "type": "string" } }, + "allOf": [ { "unevaluatedProperties": false } ] + } + + since the branch has no way to know that ``name`` is already declared and validated by the + enclosing schema. diff --git a/docs/source/combinedSchemas/anyOf.rst b/docs/source/combinedSchemas/anyOf.rst index 1c61c1cb..9fcc73ff 100644 --- a/docs/source/combinedSchemas/anyOf.rst +++ b/docs/source/combinedSchemas/anyOf.rst @@ -112,3 +112,37 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Any See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full explanation. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +each ``anyOf`` branch contributes to the evaluated set **only if it succeeded** during the +current validation. Failed branches contribute nothing. Because the branches that succeed +depend on the actual input, the evaluated set is derived per validation call and refreshed +whenever the model is mutated. + +Each successful branch credits property names claimed by its ``properties``, ``patternProperties``, +and ``additionalProperties`` (per key with a passing value), and — on the array side — the +indices claimed by its ``items``/``additionalItems``/``contains``. + +.. note:: + + *Omitting* ``additionalProperties`` from a branch is **not** the same as writing + ``additionalProperties: true``. An omitted keyword produces no annotation and therefore + credits nothing to the enclosing ``unevaluatedProperties`` — only an *explicit* + ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with + identical extras behaviour but one writing the keyword and the other omitting it will + therefore credit different evaluated sets. This is a spec-mandated distinction from + JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + +.. note:: + + Only this *up* direction (a branch's own declarations propagating to an enclosing + ``unevaluatedProperties``/``unevaluatedItems``) is implemented. The reverse — a branch's own + ``unevaluatedProperties``/``unevaluatedItems`` (other than a literal ``true``, which never + rejects anything) seeing property names or indices declared by the *enclosing* schema or a + *sibling* branch — is not currently supported and throws + ``PHPModelGenerator\Exception\UnsupportedSchemaFeatureException`` at generation time. See + `All Of `__'s equivalent note for a concrete example. diff --git a/docs/source/combinedSchemas/if.rst b/docs/source/combinedSchemas/if.rst index af511fe8..cad8af18 100644 --- a/docs/source/combinedSchemas/if.rst +++ b/docs/source/combinedSchemas/if.rst @@ -220,3 +220,46 @@ When only a ``then`` block is present (no ``else``), the branch may not apply at See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full explanation. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +each of the three branches (``if``, ``then``, ``else``) contributes independently: + +- ``if`` runs unconditionally. When it succeeds, its own declarations count as evaluated — + the JSON Schema 2019-09 spec treats ``if`` as a positive applicator whose annotations + survive. +- ``then`` runs only when ``if`` succeeded; it contributes if it also succeeds. +- ``else`` runs only when ``if`` failed; it contributes if it succeeds. + +At most two of the three branches actually execute per validation call, so the effective +contribution is: (``if``'s + ``then``'s) when the condition holds, or (``else``'s) when it does +not. Each contributing branch credits property names claimed by its ``properties``, +``patternProperties``, and ``additionalProperties`` (per key with a passing value), and — on the +array side — the indices claimed by its ``items``/``additionalItems``/``contains``. + +Because branch outcomes depend on the input, the evaluated set is derived per validation call +and refreshed whenever the model is mutated. + +.. note:: + + *Omitting* ``additionalProperties`` from a branch is **not** the same as writing + ``additionalProperties: true``. An omitted keyword produces no annotation and therefore + credits nothing to the enclosing ``unevaluatedProperties`` — only an *explicit* + ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with + identical extras behaviour but one writing the keyword and the other omitting it will + therefore credit different evaluated sets. This is a spec-mandated distinction from + JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + +.. note:: + + Only this *up* direction (a branch's own declarations propagating to an enclosing + ``unevaluatedProperties``/``unevaluatedItems``) is implemented. The reverse — a ``then`` or + ``else`` branch's own ``unevaluatedProperties``/``unevaluatedItems`` (other than a literal + ``true``, which never rejects anything) seeing property names or indices declared by the + *enclosing* schema, ``if``'s own declarations, or the other conditional branch — is not + currently supported and throws + ``PHPModelGenerator\Exception\UnsupportedSchemaFeatureException`` at generation time. See + `All Of `__'s equivalent note for a concrete example. diff --git a/docs/source/combinedSchemas/not.rst b/docs/source/combinedSchemas/not.rst index d0acd56c..092c54de 100644 --- a/docs/source/combinedSchemas/not.rst +++ b/docs/source/combinedSchemas/not.rst @@ -65,3 +65,14 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\Not instantiated — ``not`` describes what the value must *not* be, so no representation class is needed for it. See `Composition-implied objects `__ for the full explanation. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +``not`` is a **negative** applicator and contributes nothing. ``not`` succeeds when its inner +subschema *fails*, so any keys or indices touched by that inner subschema are, by definition, +not evaluated. The generator explicitly discards anything the inner subschema tried to record — +even an inner ``unevaluatedProperties``/``unevaluatedItems`` cannot leak into the enclosing +accumulator. diff --git a/docs/source/combinedSchemas/oneOf.rst b/docs/source/combinedSchemas/oneOf.rst index f5878eec..580b129f 100644 --- a/docs/source/combinedSchemas/oneOf.rst +++ b/docs/source/combinedSchemas/oneOf.rst @@ -125,3 +125,38 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\ComposedValue\\One See `Default values <../generic/default.html#branch-defaults-in-compositions>`__ for the full explanation and examples. + +Property and item evaluation propagation +---------------------------------------- + +For an enclosing schema that uses `unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ +or `unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +only the **single successful** ``oneOf`` branch contributes to the evaluated set. If two or more +branches match, the ``oneOf`` fails as a whole and no branch contributes. If none match, again +no branch contributes. + +The active branch credits property names claimed by its ``properties``, ``patternProperties``, +and ``additionalProperties`` (per key with a passing value), and — on the array side — the +indices claimed by its ``items``/``additionalItems``/``contains``. Because the identity of the +active branch depends on the input, the evaluated set is derived per validation call and +refreshed whenever the model is mutated. + +.. note:: + + *Omitting* ``additionalProperties`` from a branch is **not** the same as writing + ``additionalProperties: true``. An omitted keyword produces no annotation and therefore + credits nothing to the enclosing ``unevaluatedProperties`` — only an *explicit* + ``additionalProperties`` (whether ``true`` or ``{schema}``) contributes. Two branches with + identical extras behaviour but one writing the keyword and the other omitting it will + therefore credit different evaluated sets. This is a spec-mandated distinction from + JSON Schema 2019-09. The same rule applies to ``additionalItems`` on the array side. + +.. note:: + + Only this *up* direction (a branch's own declarations propagating to an enclosing + ``unevaluatedProperties``/``unevaluatedItems``) is implemented. The reverse — a branch's own + ``unevaluatedProperties``/``unevaluatedItems`` (other than a literal ``true``, which never + rejects anything) seeing property names or indices declared by the *enclosing* schema or a + *sibling* branch — is not currently supported and throws + ``PHPModelGenerator\Exception\UnsupportedSchemaFeatureException`` at generation time. See + `All Of `__'s equivalent note for a concrete example. diff --git a/docs/source/complexTypes/array.rst b/docs/source/complexTypes/array.rst index 6fc6b909..c6b6b2dc 100644 --- a/docs/source/complexTypes/array.rst +++ b/docs/source/complexTypes/array.rst @@ -334,6 +334,157 @@ any element satisfies the constraint). ``contains: false`` — no element could ever satisfy the constraint; any array value raises a ``ContainsException`` at runtime. The generator also emits a warning at generation time. +Unevaluated Items +----------------- + +The ``unevaluatedItems`` keyword (Draft 2019-09 and later) constrains every index that was not +evaluated by any positive sibling applicator at the same schema level. Indices claimed by +``items`` (single-schema or tuple form), by ``additionalItems`` when present, by a passing +``contains`` match, or by a **successful** composition branch (``allOf``, ``anyOf``, ``oneOf``, +``if``/``then``/``else``, ``$ref``) count as evaluated. ``not`` is a negative applicator and +contributes nothing. + +Unlike ``additionalItems``, ``unevaluatedItems`` looks across composition branches: an index +covered by a branch that ended up succeeding is credited, and an index covered only by a branch +that failed is not. + +Using ``false`` +^^^^^^^^^^^^^^^ + +Setting ``unevaluatedItems: false`` forbids any array index left uncovered by a sibling. + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "allOf": [ + { + "items": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + ], + "unevaluatedItems": false + } + } + } + +Indices 0 and 1 are covered by the tuple items of the outer or the ``allOf`` branch. Any +further element raises an ``UnevaluatedItemsException`` with the offending indices: + +.. code-block:: none + + Provided JSON for example contains not allowed unevaluated items [#2, #3] + +The thrown exception will be a *PHPModelGenerator\\Exception\\Arrays\\UnevaluatedItemsException* +which provides the following methods to get further error details: + +.. code-block:: php + + // Get the zero-based indices of the offending items + public function getUnevaluatedItems(): array + // get the name of the property which failed + public function getPropertyName(): string + // get the value provided to the property + public function getProvidedValue() + // get the JSON pointer to the schema keyword that rejected the value + public function getJsonPointer(): JsonPointer + +Using a schema +^^^^^^^^^^^^^^ + +When ``unevaluatedItems`` is set to a schema, every unevaluated index's value must validate +against that schema: + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "unevaluatedItems": { + "type": "integer" + } + } + } + } + +If invalid unevaluated items are provided a detailed exception is thrown containing all +violations: + +.. code-block:: none + + Invalid unevaluated items in array example: + - invalid unevaluated item #1 + * Invalid type for item of array example. Requires int, got string + +The thrown exception will be a *PHPModelGenerator\\Exception\\Arrays\\InvalidUnevaluatedItemsException* +which provides the following methods to get further error details: + +.. code-block:: php + + // returns a two-dimensional array which contains all validation exceptions grouped by item index + public function getInvalidItems(): array + // get the name of the property which failed + public function getPropertyName(): string + // get the value provided to the property + public function getProvidedValue() + // get the JSON pointer to the schema keyword that rejected the value + public function getJsonPointer(): JsonPointer + +Interaction with sibling applicators +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When a sibling applicator already claims every index, the ``unevaluatedItems`` validator has +nothing to check. The generator recognises these dead-code shapes at generation time, emits a +warning, and skips ``unevaluatedItems`` entirely: + +- ``items: false`` (or ``items: {schema}`` in single-schema form) leaves no unclaimed index at + all, because ``items`` covers every position. +- ``additionalItems: false`` alongside tuple ``items`` blocks any index past the tuple length — + every index is either tuple-covered (and evaluated) or rejected by ``additionalItems``. +- ``additionalItems: {schema}`` alongside tuple ``items`` claims every index past the tuple + length — every index is again covered. +- An implicit ``additionalItems: false`` produced by the deny setting is treated the same way. + +.. note:: + + ``contains`` credits only the indices that actually satisfy its subschema, so any index not + matched by ``contains`` remains available for ``unevaluatedItems``. When + ``minContains: 0`` is set, ``contains`` may succeed with zero matches and contributes nothing + to the evaluated set. + +.. note:: + + ``unevaluatedItems`` also accepts the boolean literal ``true``. This is a no-op — every index + is considered evaluated — and no validator is emitted. + +.. hint:: + + See `combined schemas <../toc-combinedSchemas.html>`__ for the per-keyword rules that govern + how each composition contributes to the evaluated set. + Size validation --------------- diff --git a/docs/source/complexTypes/object.rst b/docs/source/complexTypes/object.rst index 83b391aa..ccf7c916 100644 --- a/docs/source/complexTypes/object.rst +++ b/docs/source/complexTypes/object.rst @@ -85,6 +85,44 @@ If `error collection <../gettingStarted.html#collect-errors-vs-early-return>`__ If the class created for a nested object is instantiated manually you will either get a collection exception or a specific exception based on your error collection configuration if invalid data is provided. +Object applicators without an explicit type +------------------------------------------- + +A property subschema that declares object applicators — ``properties``, ``patternProperties``, ``additionalProperties``, ``unevaluatedProperties``, ``propertyNames``, ``minProperties`` or ``maxProperties`` — is treated as an object even when it omits ``type: object``. JSON Schema applicators are not gated on a type declaration, so the keywords apply whenever the provided value is an object. + +Because an untyped schema imposes no type constraint, a value of any other type is still accepted and passes through unchanged. The property is therefore *not* typed as the nested class alone: + +.. code-block:: json + + { + "type": "object", + "properties": { + "child": { + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + } + } + +Generated interface — an object value for ``child`` is wrapped in and validated by the generated nested class, while any other value is accepted unchanged: + +.. code-block:: php + + /** @return Child|mixed */ + public function getChild(): mixed; + +The native return type is ``mixed``, not ``Child | mixed``: ``mixed`` already subsumes every type, and PHP rejects it inside a union. The annotation carries the additional information that the value may be an instance of the generated nested class. + +A ``child`` object carrying an unevaluated key is rejected, while a scalar ``child`` value is accepted without modification. A bare untyped schema (``{}``) declares no applicators, so no nested class is generated and the property remains a plain ``mixed`` value. + +.. note:: + + The same principle applies to scalar and array applicators: a subschema declaring ``minLength`` without ``type: string``, or ``minItems`` without ``type: array``, enforces the constraint only when the value is of the matching type and accepts values of every other type. + Namespaces ---------- @@ -345,6 +383,148 @@ The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\InvalidAdd The validation of additional properties is independently from the `implicit null <../gettingStarted.html#implicit-null>`__ setting. If you require your additional properties to accept null define a `multi type `__ with explicit null. +Unevaluated Properties +---------------------- + +The ``unevaluatedProperties`` keyword (Draft 2019-09 and later) constrains every property whose +name was not evaluated by any positive sibling applicator at the same schema level. A key is +counted as *evaluated* when it is claimed by ``properties``, matched by ``patternProperties``, +claimed by ``additionalProperties`` (when that keyword is present), or claimed by a **successful** +branch of an adjacent composition (``allOf``, ``anyOf``, ``oneOf``, ``if``/``then``/``else``, +``$ref``). ``not`` is a negative applicator and contributes nothing. + +Unlike ``additionalProperties``, ``unevaluatedProperties`` looks across composition branches: +a property covered by a branch that ended up succeeding is credited, and a property covered only +by a branch that failed is not. This makes the keyword suitable for enforcing "no leftovers" at +the outermost schema level while individual branches are free to declare their own properties. + +.. hint:: + + If you define constraints via ``unevaluatedProperties`` you may want to use the + `UnevaluatedPropertiesAccessorPostProcessor <../generator/builtin/unevaluatedPropertiesAccessorPostProcessor.html>`__ + to access and modify unevaluated properties on the model. + +Using ``false`` +^^^^^^^^^^^^^^^ + +Setting ``unevaluatedProperties: false`` forbids any property not claimed by a sibling. + +.. code-block:: json + + { + "$id": "person", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "age": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false + } + +The generated model accepts ``name`` (claimed by ``properties``) and ``age`` (claimed by the +``allOf`` branch). Any other key raises an ``UnevaluatedPropertiesException``: + +.. code-block:: none + + Provided JSON for person contains not allowed unevaluated properties [extra] + +The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\UnevaluatedPropertiesException* +which provides the following methods to get further error details: + +.. code-block:: php + + // Get the list of property names that were left unevaluated + public function getUnevaluatedProperties(): array + // get the name of the property which failed + public function getPropertyName(): string + // get the value provided to the property + public function getProvidedValue() + // get the JSON pointer to the schema keyword that rejected the value + public function getJsonPointer(): JsonPointer + +Using a schema +^^^^^^^^^^^^^^ + +When ``unevaluatedProperties`` is set to a schema, every unevaluated key's value must validate +against that schema: + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "integer" + } + }, + "unevaluatedProperties": { + "type": "string", + "maxLength": 10 + } + } + +If invalid unevaluated properties are provided a detailed exception is thrown containing all +violations: + +.. code-block:: none + + Provided JSON for example contains invalid unevaluated properties. + - invalid unevaluated property 'note' + * Value for unevaluated property must not be longer than 10 + +The thrown exception will be a *PHPModelGenerator\\Exception\\Object\\InvalidUnevaluatedPropertiesException* +which provides the following methods to get further error details: + +.. code-block:: php + + // returns a two-dimensional array which contains all validation exceptions grouped by property names + public function getNestedExceptions(): array + // get the name of the property which failed + public function getPropertyName(): string + // get the value provided to the property + public function getProvidedValue() + // get the JSON pointer to the schema keyword that rejected the value + public function getJsonPointer(): JsonPointer + +Interaction with ``additionalProperties`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When ``additionalProperties`` is present at the same schema level, it claims every key not +covered by ``properties`` or ``patternProperties`` first — leaving nothing for +``unevaluatedProperties`` to see. The generator recognises these dead-code shapes at generation +time and emits a warning, then skips the ``unevaluatedProperties`` validator entirely: + +- ``additionalProperties: true`` alongside any ``unevaluatedProperties`` value. +- ``additionalProperties: false`` alongside any ``unevaluatedProperties`` value. +- ``additionalProperties: {schema}`` alongside any ``unevaluatedProperties`` value. +- An implicit ``additionalProperties: false`` produced by the + `deny additional properties setting <../gettingStarted.html#deny-additional-properties>`__. + +Contradictory inner schemas (e.g. ``unevaluatedProperties`` combining a subschema that no value +could satisfy with an outer configuration that still allows keys to reach it) are rejected with +a ``SchemaException`` at generation time. + +.. note:: + + ``unevaluatedProperties`` also accepts the boolean literal ``true``. This is a no-op — every + property is considered evaluated — and no validator is emitted. + +.. hint:: + + See `combined schemas <../toc-combinedSchemas.html>`__ for the per-keyword rules that govern + how each composition contributes to the evaluated set. + Recursive Objects ----------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index f5c84852..de346526 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -85,7 +85,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [] # Custom sidebar templates, must be a dictionary that maps document names # to template names. diff --git a/docs/source/development/testInfrastructure.rst b/docs/source/development/testInfrastructure.rst index fe51b037..0c5dd193 100644 --- a/docs/source/development/testInfrastructure.rst +++ b/docs/source/development/testInfrastructure.rst @@ -128,13 +128,13 @@ Examples: The three available drafts are defined in the ``JsonSchemaDraft`` enum: -=========================== =========== -Constant Description -=========================== =========== -``JsonSchemaDraft::DRAFT_07`` JSON Schema Draft 7 (the baseline) -``JsonSchemaDraft::DRAFT_2019_09`` JSON Schema Draft 2019-09 -``JsonSchemaDraft::DRAFT_2020_12`` JSON Schema Draft 2020-12 -=========================== =========== +================================== ========================================== +Constant Description +================================== ========================================== +``JsonSchemaDraft::DRAFT_07`` JSON Schema Draft 7 (the baseline) +``JsonSchemaDraft::DRAFT_2019_09`` JSON Schema Draft 2019-09 +``JsonSchemaDraft::DRAFT_2020_12`` JSON Schema Draft 2020-12 +================================== ========================================== Data providers and multi-draft expansion ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/examples/scenarioBasedTesting.rst b/docs/source/examples/scenarioBasedTesting.rst index 461e0c5d..69d29f2c 100644 --- a/docs/source/examples/scenarioBasedTesting.rst +++ b/docs/source/examples/scenarioBasedTesting.rst @@ -168,7 +168,7 @@ We'll use the schema model generator to create a Scenario class with the followi Now we can add a scripts-section to our composer.json to create a build script which runs our **generateScenarioModels.php**: -.. code-block:: json +.. code-block:: none ... "scripts": { @@ -368,7 +368,7 @@ Yes, it was. But keep in mind: your entities are likely bigger, you may have man In a larger context you may want to structure your **scenario-schema** more user-orientated instead of representing the entities of your application one-to-one. Let's assume you extend your Petshop with subscriptions so a user can subscribe to get updates on various pets (eg. changes of the availability). Now you can go one way and add an entity *petSubscription* to the **scenario-schema** which links a pet to a user with the properties *user* and *pet* (just like a subscription entity in your code). But as we want simple scenarios we could also change the *pet* entity and add a list of subscribers to the entity in our **scenario-schema**: -.. code-block:: json +.. code-block:: none "pets": { "type": "array", @@ -388,7 +388,7 @@ In a larger context you may want to structure your **scenario-schema** more user In our **ScenarioBuilder** we extend the setupPets method to also persist our subscriptions. Now our **scenario** in a SubscriberTest can look like: -.. code-block:: json +.. code-block:: none ..., "pets": [ diff --git a/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst index 44717f2a..a8f0dd91 100644 --- a/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst +++ b/docs/source/generator/builtin/additionalPropertiesAccessorPostProcessor.rst @@ -34,26 +34,39 @@ Generated interface with the **AdditionalPropertiesAccessorPostProcessor**: .. code-block:: php - public function setExample(float $example): static; - public function getExample(): float; + public function setExample(string $example): static; + public function getExample(): ?string; public function meta(): Meta; - public function additionalProperties(): AdditionalPropertiesAccessor; + public function additionalProperties(): ExampleAdditionalProperties; -The ``additionalProperties()`` method returns an accessor object with the following interface: +Because the example schema constrains additional values to ``string``, the generator produces a +typed companion class ``ExampleAdditionalProperties`` whose signatures are narrowed to the +declared type: .. code-block:: php + /** @return string[] */ public function getAll(): array; - public function get(string $key): mixed; - public function set(string $key, mixed $value): void; + public function get(string $key): ?string; + public function set(string $key, string $value): static; public function remove(string $key): bool; .. note:: - The methods **set** and **remove** on the accessor are only available if the `immutable setting <../../gettingStarted.html#immutable-classes>`__ is set to false. + The methods **set** and **remove** on the accessor are only available if the `immutable setting <../../gettingStarted.html#immutable-classes>`__ is set to false. Immutable models return a read-only companion exposing only ``get`` and ``getAll``. + +When ``additionalProperties`` is ``true`` (or the subschema is untyped), no companion is +generated and the accessor is the bare production-library class +``AdditionalPropertiesAccessor``. Its signatures fall back to ``mixed`` while ``set`` keeps the +fluent ``static`` return: + +.. code-block:: php -When the ``additionalProperties`` keyword provides a schema that constrains the value type, a typed companion class ``{ModelName}AdditionalProperties`` is generated that narrows the return and parameter types of the accessor methods accordingly. + public function getAll(): array; + public function get(string $key): mixed; + public function set(string $key, mixed $value): static; + public function remove(string $key): bool; **getAll**: Returns all additional properties currently part of the model as key-value pairs. Properties defined in the schema (in this case *example*) are not included. Unlike ``meta()->rawInput()``, the values returned here are the processed values — if the schema defines an object schema for additional properties, an array of object instances is returned; if a `filter <../../nonStandardExtensions/filter.html>`__ is applied, the filtered (and for transforming filters, transformed) values are returned. diff --git a/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst b/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst new file mode 100644 index 00000000..741c151a --- /dev/null +++ b/docs/source/generator/builtin/unevaluatedPropertiesAccessorPostProcessor.rst @@ -0,0 +1,110 @@ +UnevaluatedPropertiesAccessorPostProcessor +========================================== + +.. code-block:: php + + $generator = new ModelGenerator(); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + +The **UnevaluatedPropertiesAccessorPostProcessor** adds methods to your model to work with +`unevaluated properties <../../complexTypes/object.html#unevaluated-properties>`__ (Draft 2019-09 +and later) on your objects. The post processor is only effective when the enclosing schema can +actually accumulate unevaluated keys — that is, when ``additionalProperties`` is absent or set to +``true`` at the same level. If ``additionalProperties`` is ``false`` or a ``{schema}``, every extra +key is already either rejected or claimed and validated by that keyword, so nothing ever reaches +the unevaluated bucket. In those cases the accessor emits no methods and no backing field; the +``unevaluatedProperties`` validator continues to run as a pure assertion. + +.. note:: + + If the `deny additional properties setting <../../gettingStarted.html#deny-additional-properties>`__ + is set to true the accessor is skipped for the same reason: every schema that does not define + ``additionalProperties`` behaves as if it had ``additionalProperties: false``. + +Added methods +~~~~~~~~~~~~~ + +.. code-block:: json + + { + "$id": "example", + "type": "object", + "properties": { + "example": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "string" + } + } + +Generated interface with the **UnevaluatedPropertiesAccessorPostProcessor**: + +.. code-block:: php + + public function setExample(string $example): static; + public function getExample(): ?string; + + public function unevaluatedProperties(): ExampleUnevaluatedProperties; + +Because the example schema constrains unevaluated values to ``string``, the generator produces a +typed companion class ``ExampleUnevaluatedProperties`` whose signatures are narrowed to the +declared type: + +.. code-block:: php + + /** @return string[] */ + public function getAll(): array; + public function get(string $key): ?string; + public function set(string $key, string $value): static; + public function remove(string $key): bool; + +.. note:: + + The methods **set** and **remove** on the accessor are only available if the + `immutable setting <../../gettingStarted.html#immutable-classes>`__ is set to false. Immutable + models return a read-only companion exposing only ``get`` and ``getAll``. + +When ``unevaluatedProperties`` is ``true`` (or the subschema is untyped), no companion is +generated and the accessor is the bare production-library class +``UnevaluatedPropertiesAccessor`` (or ``ImmutableUnevaluatedPropertiesAccessor`` for immutable +models). Its signatures fall back to ``mixed`` while ``set`` keeps the fluent ``static`` return: + +.. code-block:: php + + public function getAll(): array; + public function get(string $key): mixed; + public function set(string $key, mixed $value): static; + public function remove(string $key): bool; + +**getAll**: Returns all unevaluated properties currently held by the model as key-value pairs. +Properties defined in the schema (in this case *example*) and properties claimed by successful +composition branches are not included. Values are the processed values — for typed schemas, an +array of the target type; for a `filter <../../nonStandardExtensions/filter.html>`__ result, the +filtered (and for transforming filters, transformed) values. + +**get**: Returns the current value of a single unevaluated property. Returns null if the requested +property does not exist. Like ``getAll``, returns the processed value. + +**set**: Adds or updates an unevaluated property. Re-runs the enclosing schema's validation +against the new state, including composition re-evaluation, so a mutation that would move a key +into the coverage of another sibling applicator (or vice versa) is rejected up front. Throws +*RegularPropertyAsUnevaluatedPropertyException* if the key conflicts with a regularly-defined +schema property. + +**remove**: Removes an existing unevaluated property from the model. Returns true if the property +was removed, false if it did not exist. Re-runs the enclosing schema's validation against the +post-removal state — including composition re-evaluation — and throws a *ValidationException* if +the removal would produce an invalid model (for example dropping below a ``minProperties`` +constraint or flipping a composition branch that a remaining key depended on), leaving the model +unchanged. + +Serialization +~~~~~~~~~~~~~ + +When the **UnevaluatedPropertiesAccessorPostProcessor** is applied and +`serialization <../../gettingStarted.html#serialization-methods>`__ is enabled, the unevaluated +properties are merged into the serialization result. If unevaluated properties are processed via +a transforming filter, each value is serialized via the serialization method of the transforming +filter. diff --git a/docs/source/generator/postProcessor.rst b/docs/source/generator/postProcessor.rst index 6a045a05..54a94110 100644 --- a/docs/source/generator/postProcessor.rst +++ b/docs/source/generator/postProcessor.rst @@ -22,6 +22,7 @@ All added post processors will be executed after a schema was processed and befo builtin/populatePostProcessor builtin/additionalPropertiesAccessorPostProcessor builtin/patternPropertiesAccessorPostProcessor + builtin/unevaluatedPropertiesAccessorPostProcessor .. toctree:: :caption: Custom Extensions diff --git a/docs/source/generic/references.rst b/docs/source/generic/references.rst index bfc40149..0c44dbad 100644 --- a/docs/source/generic/references.rst +++ b/docs/source/generic/references.rst @@ -284,3 +284,14 @@ A property schema with ``$ref`` pointing to a string definition and a sibling `` Both constraints are enforced: the effective minimum length is ``5`` (the sibling tightens the ref's ``minLength: 1``). + +Property and item evaluation propagation +---------------------------------------- + +A ``$ref`` is a positive applicator. When an enclosing schema uses +`unevaluatedProperties <../complexTypes/object.html#unevaluated-properties>`__ or +`unevaluatedItems <../complexTypes/array.html#unevaluated-items>`__ (Draft 2019-09 and later), +the resolved schema's evaluated set contributes to the enclosing schema's evaluated set — +exactly as an inline branch of the same shape would. Self-referential ``$ref`` chains are +handled without infinite recursion: the generator terminates the walk when it revisits a +schema it has already processed. diff --git a/docs/source/gettingStarted.rst b/docs/source/gettingStarted.rst index 44cab982..3b971d40 100644 --- a/docs/source/gettingStarted.rst +++ b/docs/source/gettingStarted.rst @@ -28,7 +28,7 @@ The first parameter of the *generateModels* method must be a class implementing =========================== =========== Provider Description =========================== =========== -RecursiveDirectoryProvider Fetches all *.json files from the given source directory. Each file must contain a JSON Schema object definition on the top level +RecursiveDirectoryProvider Fetches all ``*.json`` files from the given source directory. Each file must contain a JSON Schema object definition on the top level OpenAPIv3Provider Fetches all objects defined in the #/components/schemas section of an Open API v3 spec file =========================== =========== diff --git a/src/Draft/AutoDetectionDraft.php b/src/Draft/AutoDetectionDraft.php index b841e9f3..e08b54e0 100644 --- a/src/Draft/AutoDetectionDraft.php +++ b/src/Draft/AutoDetectionDraft.php @@ -6,36 +6,84 @@ use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +/** + * Resolves the JSON Schema draft to apply to a document from its `$schema` URI. + * + * The `$schema` keyword is a document-level declaration: only the document root (or an embedded + * resource root) carries it, and it fixes the dialect for every subschema beneath it. + * `JsonSchema::getSchemaUri()` propagates the effective URI down through every node via + * clone/navigate(), so this class only has to look at that single accessor per (sub)schema — no + * caching of its own is needed. + * + * When no recognised `$schema` URI is declared anywhere in a document, the current default + * dialect (Draft 2020-12) applies. Support for additional drafts is added by extending + * self::DRAFT_BY_IDENTIFIER. + */ class AutoDetectionDraft implements DraftFactoryInterface { - /** URI variants (with and without trailing '#', http and https) identifying a draft 2019-09 schema */ - private const array DRAFT_2019_09_SCHEMA_URIS = [ - 'https://json-schema.org/draft/2019-09/schema', - 'https://json-schema.org/draft/2019-09/schema#', - 'http://json-schema.org/draft/2019-09/schema', - 'http://json-schema.org/draft/2019-09/schema#', + /** + * Draft keyed by its `$schema` identifier — the URI reduced to `/schema`. The four + * canonical variants of each URI (http/https, with/without a trailing '#') all normalise to + * this single identifier via normalizeSchemaUri(), so each draft needs only one entry here. + * + * @var array> + */ + private const array DRAFT_BY_IDENTIFIER = [ + 'draft-07/schema' => Draft_07::class, + 'draft/2019-09/schema' => Draft_2019_09::class, + 'draft/2020-12/schema' => Draft_2020_12::class, ]; - /** @var DraftInterface[] Keyed by draft class name; reused across schemas */ + /** Draft applied when a document declares no recognised `$schema` URI. */ + private const string DEFAULT_DRAFT = Draft_2020_12::class; + + /** @var array, DraftInterface> Keyed by draft class name; reused across schemas */ private array $draftInstances = []; public function getDraftForSchema(JsonSchema $jsonSchema): DraftInterface { - // getSchemaUri() reflects the $schema declared by the node's document root, not the - // current node's own JSON -- $schema only ever appears on a document root, so a - // property-level or navigated node would otherwise always read null here and silently - // fall back to Draft_07 regardless of what the document declared (issue #186). + // getSchemaUri() reflects the $schema declared by the node's nearest ancestor (or its + // own), not just the current node's own JSON -- $schema only ever appears on a document + // root or an embedded resource root, so reading $jsonSchema->getJson()['$schema'] directly + // would return null for every other node and silently fall back to the default draft + // regardless of what the document declared (issue #186). JsonSchema propagates this value + // through clone/navigate(), so a nested resource root's own $schema correctly overrides it + // for that resource's descendants without leaking into its siblings -- no per-file caching + // needed here, and none that could cache the wrong dialect across independently-dialected + // embedded resources in the same file. $schemaUri = $jsonSchema->getSchemaUri(); - // Detect draft 2019-09 by its declared $schema URI. Every other case -- - // an absent $schema keyword, the draft-07 URI, or any unrecognised URI -- - // falls back to Draft_07, preserving the previous unconditional behaviour. - // Additional drafts will be detected here when support for them is added - // (e.g. draft-04, draft 2020-12). - if (in_array($schemaUri, self::DRAFT_2019_09_SCHEMA_URIS, true)) { - return $this->draftInstances[Draft_2019_09::class] ??= new Draft_2019_09(); + if ($schemaUri !== null) { + $draftClass = self::DRAFT_BY_IDENTIFIER[$this->normalizeSchemaUri($schemaUri)] ?? null; + + if ($draftClass !== null) { + return $this->draft($draftClass); + } } - return $this->draftInstances[Draft_07::class] ??= new Draft_07(); + // Fall back to the default dialect when the document declares no recognised $schema + // anywhere — this also covers an unrecognised $schema URI. + return $this->draft(self::DEFAULT_DRAFT); + } + + /** + * Reduces a `$schema` URI to its draft identifier by dropping the json-schema.org host in + * either scheme and any trailing '#', so all four canonical variants collapse to one key. + * A non-matching URI is returned unchanged (minus a trailing '#') and simply misses the map. + */ + private function normalizeSchemaUri(string $schemaUri): string + { + return rtrim( + str_replace(['https://json-schema.org/', 'http://json-schema.org/'], '', $schemaUri), + '#', + ); + } + + /** + * @param class-string $draftClass + */ + private function draft(string $draftClass): DraftInterface + { + return $this->draftInstances[$draftClass] ??= new $draftClass(); } } diff --git a/src/Draft/Draft_2019_09.php b/src/Draft/Draft_2019_09.php index 85523d3a..a1326400 100644 --- a/src/Draft/Draft_2019_09.php +++ b/src/Draft/Draft_2019_09.php @@ -6,6 +6,8 @@ use PHPModelGenerator\Draft\Producer\RefResolver; use PHPModelGenerator\Model\Validator\Factory\Arrays\ContainsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Arrays\UnevaluatedItemsValidatorFactory; +use PHPModelGenerator\Model\Validator\Factory\Object\UnevaluatedPropertiesValidatorFactory; class Draft_2019_09 extends Draft_07 { @@ -18,7 +20,11 @@ public function getDefinition(): DraftBuilder $builder->addProducer('$ref', new RefResolver()); $builder->getType('array') - ->addValidator('contains', new ContainsValidatorFactory()); + ->addValidator('contains', new ContainsValidatorFactory()) + ->addValidator('unevaluatedItems', new UnevaluatedItemsValidatorFactory()); + + $builder->getType('object') + ->addValidator('unevaluatedProperties', new UnevaluatedPropertiesValidatorFactory()); return $builder; } diff --git a/src/Exception/UnsupportedSchemaFeatureException.php b/src/Exception/UnsupportedSchemaFeatureException.php new file mode 100644 index 00000000..ec79a10e --- /dev/null +++ b/src/Exception/UnsupportedSchemaFeatureException.php @@ -0,0 +1,15 @@ +jsonSchema; } + /** + * Return the wrapped property whose validators back this branch. Iterating its + * validators directly returns the source validator instances, whereas + * `getOrderedValidators()` on the decorator returns fresh `withProperty(...)` clones on + * every call. Mutations targeted at clones are invisible at render time; mutations + * targeted at the wrapped property's source validators propagate. + */ + public function getWrappedProperty(): PropertyInterface + { + return $this->definitionsCollection->offsetGet(self::PROPERTY_KEY); + } + + /** + * Returns the property names declared in this branch's `properties` keyword. + * + * Used by the composition post processor to harvest names that must invalidate the + * setter-side validation cache when those properties change. + * + * @return string[] + */ + public function getBranchDeclaredPropertyNames(): array + { + return array_keys($this->jsonSchema->getJson()['properties'] ?? []); + } + + /** + * Returns the declared property names as a PHP array literal ready for direct template + * embedding. var_export emits a syntactically-valid PHP literal regardless of which + * characters the names contain (quotes, backslashes, multibyte sequences). + */ + public function getBranchDeclaredPropertyNamesPhpLiteral(): string + { + return var_export($this->getBranchDeclaredPropertyNames(), true); + } + + /** + * Returns the patternProperties regexes as a PHP array literal ready for direct template + * embedding. Each pattern is wrapped with `/` delimiters and any embedded `/` is escaped so + * the result can be passed straight to `preg_match`. + */ + public function getBranchPatternPropertyPatternsPhpLiteral(): string + { + return RenderHelper::varExportPcrePatterns( + array_keys($this->jsonSchema->getJson()['patternProperties'] ?? []), + ); + } + + /** + * Returns true if this branch's JSON schema explicitly declares an `additionalProperties` + * value that is not `false` (i.e., `true` or a schema object). + * + * Absent `additionalProperties` returns false: omitting the keyword does not contribute + * annotation results that unevaluatedProperties checks. When this method returns true, + * a successful branch is treated as evaluating every model key. + */ + public function branchHasNonFalseAdditionalProperties(): bool + { + $branchJson = $this->jsonSchema->getJson(); + + return isset($branchJson['additionalProperties']) + && $branchJson['additionalProperties'] !== false; + } + + /** + * Returns true when a successful branch is treated as evaluating every model key. That holds + * when the branch declares a non-false `additionalProperties` (claims every extra key) or an + * `unevaluatedProperties: true` (claims every key the branch did not otherwise evaluate). + * Either way the whole instance is covered, so the branch's evaluation slot becomes an + * all-keys-evaluated marker rather than an explicit name list or nested-instance query. + * + * `unevaluatedProperties: {schema}` is deliberately excluded: it claims only the remaining + * keys whose values validate against the subschema, which is instance-dependent and cannot be + * reduced to an all-keys marker at generation time. + */ + public function branchClaimsAllKeys(): bool + { + return $this->branchHasNonFalseAdditionalProperties() + || ($this->jsonSchema->getJson()['unevaluatedProperties'] ?? null) === true; + } + + /** + * True when a key the branch does not declare in `properties` can still flip the branch's + * outcome. + * + * The setter-side validation cache decides "nothing relevant changed" by intersecting the + * mutated keys with this branch's declared property names. That test is only sound while + * every keyword in the branch reacts to names it declares. A branch carrying + * `additionalProperties`, `patternProperties`, `minProperties`, ... is decided by keys it + * never names, so the intersection reports "no change" for a mutation that does change the + * outcome and the branch is wrongly skipped. + * + * `required` is deliberately absent from the list even though a required name need not + * appear in `properties`: the schema processor materialises every required name as a + * property of the branch's nested schema, so such a name is already part of the declared + * set the cache tests against. Adding `required` here would disable the cache for a large + * share of real schemas without closing any gap. + */ + public function branchEvaluationDependsOnUndeclaredKeys(): bool + { + $branchJson = $this->jsonSchema->getJson(); + + foreach (self::UNDECLARED_KEY_SENSITIVE_KEYWORDS as $keyword) { + if (array_key_exists($keyword, $branchJson)) { + return true; + } + } + + return false; + } + + /** + * True when the branch declares `items` as a schema object (not a tuple list, not a + * boolean). A schema-form `items` claims every index in the validated array. + */ + public function branchHasItemsSchema(): bool + { + $items = $this->jsonSchema->getJson()['items'] ?? null; + + return is_array($items) && $items !== [] && !array_is_list($items); + } + + /** + * Count of items in a tuple-form `items` array. Returns 0 when items is absent, boolean, + * or schema-form. The tuple claims indices 0..count-1 when the branch succeeds. + */ + public function getBranchTupleItemsCount(): int + { + $items = $this->jsonSchema->getJson()['items'] ?? null; + + if (!is_array($items) || $items === [] || !array_is_list($items)) { + return 0; + } + + return count($items); + } + + /** + * True when the branch declares `additionalItems` as anything other than `false`. The + * `false` value rejects tail indices; any other value (true or schema object) accepts + * them and contributes them to the evaluated set. + */ + public function branchHasNonFalseAdditionalItems(): bool + { + $branchJson = $this->jsonSchema->getJson(); + + return array_key_exists('additionalItems', $branchJson) + && $branchJson['additionalItems'] !== false; + } + + /** + * True when the branch declares the `contains` keyword. The actual matched indices are + * collected at runtime by the contains validator into a local accumulator; the composition + * template unions that accumulator into the branch's evaluated set on success. + */ + public function branchHasContains(): bool + { + return array_key_exists('contains', $this->jsonSchema->getJson()); + } + + /** + * True when the branch carries at least one array-side applicator (`items`, + * `additionalItems`, `contains`) and no object-side applicators (`properties`, + * `additionalProperties`, `patternProperties`). Drives whether the composition template + * writes the branch's slot into `_compositionAnnotated`. + */ + public function branchIsArrayKind(): bool + { + $branchJson = $this->jsonSchema->getJson(); + + $hasArrayApplicator = array_key_exists('items', $branchJson) + || array_key_exists('additionalItems', $branchJson) + || array_key_exists('contains', $branchJson); + + $hasObjectApplicator = array_key_exists('properties', $branchJson) + || array_key_exists('additionalProperties', $branchJson) + || array_key_exists('patternProperties', $branchJson); + + return $hasArrayApplicator && !$hasObjectApplicator; + } + + /** + * Slot keys of composition validators nested directly inside this branch's own JSON (the + * branch is itself `{allOf: [...]}`, `{oneOf: [...]}`, etc.) that also carry evaluation + * tracking. An array-typed branch never gets its own nested `Schema` (unlike an + * object-typed one, which always routes through `processSchema()`), so a composition + * nested inside it renders as a further validator call on the same wrapped property + * rather than inside a separate class — its claimed indices are reachable only by reading + * back `_compositionAnnotated[]` at runtime, not by recursing into a nested + * `Schema`'s own composed properties the way an object-typed branch's nesting would allow. + * The composition template unions each returned slot's tracked indices into this branch's + * own evaluated set. + * + * @return string[] + */ + public function getNestedCompositionSlotKeys(): array + { + $slotKeys = []; + + foreach ($this->getWrappedProperty()->getOrderedValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator && $validator->getSlotKey() !== null) { + $slotKeys[] = $validator->getSlotKey(); + } + } + + return $slotKeys; + } + + /** + * True when the composition template must build an evaluated-index set for this branch — + * either from the branch's own direct array applicators ({@see branchIsArrayKind()}) or + * from a composition nested inside the branch ({@see getNestedCompositionSlotKeys()}). + * Combines both into one boolean because the template engine's conditional expressions do + * not support parenthesised boolean grouping. + */ + public function needsEvaluatedIndexAggregation(): bool + { + return $this->branchIsArrayKind() || $this->getNestedCompositionSlotKeys() !== []; + } + /** * @inheritdoc * diff --git a/src/Model/Schema.php b/src/Model/Schema.php index 0b6f8c22..55bc5bd7 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -46,6 +46,8 @@ class Schema * before adding properties to the model */ protected $baseValidators = []; + /** @var PropertyValidatorInterface[] Validators that run after all composition validators */ + private array $postCompositionValidators = []; /** @var string[] */ protected $usedClasses = []; /** @var SchemaNamespaceTransferDecorator[] */ @@ -65,6 +67,20 @@ class Schema /** @var string[] Maps normalized attribute → raw property name; used to detect property-vs-property collisions */ private array $attributeIndex = []; + /** + * @var array Internal model fields whose pre-populate value must be restored + * if populate() rolls back. Post processors register their backing + * collection fields here; Populate.phptpl iterates the list. + */ + private array $rollbackProperties = []; + + /** + * @var array Internal accessor-cache fields that must be cleared before + * populate() runs so a stale cached accessor instance isn't reused + * after the underlying storage changes. + */ + private array $accessorCacheProperties = []; + private PropertyMerger $propertyMerger; /** @@ -295,6 +311,21 @@ public function addBaseValidator(PropertyValidatorInterface $baseValidator): sel return $this; } + public function addPostCompositionValidator(PropertyValidatorInterface $postCompositionValidator): self + { + $this->postCompositionValidators[] = $postCompositionValidator; + + return $this; + } + + /** + * @return PropertyValidatorInterface[] + */ + public function getPostCompositionValidators(): array + { + return $this->postCompositionValidators; + } + public function getSchemaDictionary(): SchemaDefinitionDictionary { return $this->schemaDefinitionDictionary; @@ -411,6 +442,44 @@ public function isInitialClass(): bool return $this->initialClass; } + /** + * Register an internal model field whose pre-populate value must be restored on rollback. + * Idempotent — registering the same field twice is a no-op. Order of registration is + * preserved by returning array_keys; the order is the order rollback is iterated. + */ + public function addRollbackProperty(string $internalPropertyName): self + { + $this->rollbackProperties[$internalPropertyName] = true; + + return $this; + } + + /** + * @return string[] + */ + public function getRollbackProperties(): array + { + return array_keys($this->rollbackProperties); + } + + /** + * Register an internal accessor-cache field that must be reset before populate() runs. + */ + public function addAccessorCacheProperty(string $internalPropertyName): self + { + $this->accessorCacheProperties[$internalPropertyName] = true; + + return $this; + } + + /** + * @return string[] + */ + public function getAccessorCacheProperties(): array + { + return array_keys($this->accessorCacheProperties); + } + public function getPropertyMerger(): PropertyMerger { return $this->propertyMerger; diff --git a/src/Model/SchemaDefinition/JsonSchema.php b/src/Model/SchemaDefinition/JsonSchema.php index cbfa8464..cb7a9d08 100644 --- a/src/Model/SchemaDefinition/JsonSchema.php +++ b/src/Model/SchemaDefinition/JsonSchema.php @@ -6,6 +6,7 @@ use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Utils\ArrayHash; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; class JsonSchema { @@ -148,7 +149,7 @@ public function navigate(string | int $pointer): JsonSchema foreach (explode('/', $trimmed) as $pathSegment) { $jsonSchema->pointer .= "/$pathSegment"; - $decodedPathSegment = self::decodePointer($pathSegment); + $decodedPathSegment = JsonSchemaUtil::decodePointer($pathSegment); if (!array_key_exists($decodedPathSegment, $jsonSchema->json)) { throw new SchemaException("Unresolved path segment $pathSegment in file $this->file", $jsonSchema); @@ -190,14 +191,4 @@ public function getPointer(): string { return $this->pointer; } - - public static function encodePointer(string | int $pointer): string - { - return str_replace(['~', '/'], ['~0', '~1'], (string) $pointer); - } - - public static function decodePointer(string | int $pointer): string - { - return str_replace(['~1', '~0'], ['/', '~'], (string) $pointer); - } } diff --git a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php index 66508b36..6e9dfb7d 100644 --- a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php +++ b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php @@ -8,6 +8,7 @@ use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; /** * Class SchemaDefinitionDictionary @@ -47,7 +48,7 @@ public function setUpDefinitionDictionary(SchemaProcessor $schemaProcessor, Sche $this->addDefinition( $key, new SchemaDefinition( - $schema->getJsonSchema()->navigate(JsonSchema::encodePointer($key)), + $schema->getJsonSchema()->navigate(JsonSchemaUtil::encodePointer($key)), $schemaProcessor, $schema, ), @@ -80,7 +81,7 @@ protected function fetchDefinitionsById( } $this->fetchDefinitionsById( - $jsonSchema->navigate(JsonSchema::encodePointer($key)), + $jsonSchema->navigate(JsonSchemaUtil::encodePointer($key)), $schemaProcessor, $schema, ); diff --git a/src/Model/Validator/AbstractComposedPropertyValidator.php b/src/Model/Validator/AbstractComposedPropertyValidator.php index aa0f8e36..ebcdad98 100644 --- a/src/Model/Validator/AbstractComposedPropertyValidator.php +++ b/src/Model/Validator/AbstractComposedPropertyValidator.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Model\Validator; use PHPModelGenerator\Model\Property\CompositionPropertyDecorator; +use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory; use PHPModelGenerator\SchemaProcessor\PostProcessor\RenderedMethod; use PHPModelGenerator\Utils\RenderHelper; @@ -21,6 +22,10 @@ abstract class AbstractComposedPropertyValidator extends ExtractedMethodValidato protected $composedProperties; protected string $modifiedValuesMethod = ''; + private bool $evaluationTrackingEnabled = false; + + private ?string $slotKey = null; + public function getCompositionProcessor(): string { return $this->compositionProcessor; @@ -34,9 +39,54 @@ public function getComposedProperties(): array return $this->composedProperties; } + /** + * When true, this validator's rendered output will emit the _compositionEvaluations + * cache field and per-branch slot writes needed for unevaluatedProperties tracking. + */ + public function enableEvaluationTracking(): void + { + $this->evaluationTrackingEnabled = true; + } + + public function hasEvaluationTrackingEnabled(): bool + { + return $this->evaluationTrackingEnabled; + } + + /** + * Identifier under which this validator's per-call result (the union of indices claimed + * by successful array-side branches) is cached on the model instance. When set, the + * composition template writes the result wholesale to `$this->_compositionAnnotated[$slotKey]` + * at end-of-IIFE: `[]` on whole-composition failure, the union otherwise. Null when the + * validator is not part of an array-side tracking chain — the template then takes its + * pre-existing object-side path against `$this->_compositionEvaluations[$validatorIndex]` + * instead. + */ + public function setSlotKey(string $slotKey): void + { + $this->slotKey = $slotKey; + $this->templateValues['slotKey'] = $slotKey; + } + + public function getSlotKey(): ?string + { + return $this->slotKey; + } + + /** + * Returns true when this validator implements `not` composition semantics. + * + * When true, composition templates unconditionally roll back _compositionEvaluations + * after the not-branch runs so that any annotations it wrote cannot leak to the parent. + */ + public function isNotComposition(): bool + { + return $this->compositionProcessor === NotValidatorFactory::class; + } + protected function initModifiedValuesMethod(): void { - $this->modifiedValuesMethod = '_getModifiedValues_' . substr(md5(spl_object_hash($this)), 0, 5); + $this->modifiedValuesMethod = '_getModifiedValues_' . substr(md5((string) spl_object_id($this)), 0, 5); } /** diff --git a/src/Model/Validator/AbstractUnevaluatedItemsValidator.php b/src/Model/Validator/AbstractUnevaluatedItemsValidator.php new file mode 100644 index 00000000..2eb866ed --- /dev/null +++ b/src/Model/Validator/AbstractUnevaluatedItemsValidator.php @@ -0,0 +1,97 @@ +`-form (`UnevaluatedItemsValidator`). + * + * Both emit a template that calls `$this->collectUnevaluatedIndices(...)` from + * `CompositionEvaluationTrait` with the array property's name and a list of slot keys + * identifying the sibling composition validators whose annotations should contribute. + * The slot-key list is resolved at `getCheck()` time because the owning property's + * composition validators receive their slot keys during the post-processor pass that runs + * after the validator is constructed. + */ +abstract class AbstractUnevaluatedItemsValidator extends PropertyTemplateValidator +{ + private readonly PropertyInterface $parentProperty; + + /** + * @param array $extraTemplateValues Subclass-specific template values merged + * on top of the shared set. + */ + public function __construct( + PropertyInterface $property, + string $templatePath, + string $exceptionClass, + array $exceptionParams, + array $extraTemplateValues = [], + ) { + $this->parentProperty = $property; + + [$siblingTupleItemsCount, $siblingCoversTail] = $this->siblingItemsCoverage( + $property->getJsonSchema()->getJson(), + ); + + parent::__construct( + $property, + $templatePath, + $extraTemplateValues + [ + 'arrayPropertyName' => $property->getName(), + 'compositionSlotKeys' => '[]', + 'siblingTupleItemsCount' => $siblingTupleItemsCount, + 'siblingCoversTail' => $siblingCoversTail ? 'true' : 'false', + ], + $exceptionClass, + $exceptionParams, + ); + } + + /** + * Positional index coverage from a sibling `items` tuple and `additionalItems` on the same + * property: a tuple `items` evaluates indices [0, count); a non-false `additionalItems` then + * evaluates every index past the tuple. Every other `items` shape is suppressed as dead code + * by UnevaluatedItemsValidatorFactory, so it contributes no coverage here. + * + * @return array{0: int, 1: bool} [tuple length, whether additionalItems covers the tail] + */ + private function siblingItemsCoverage(array $json): array + { + $items = $json['items'] ?? null; + + if (!is_array($items) || $items === [] || !array_is_list($items)) { + return [0, false]; + } + + $coversTail = array_key_exists('additionalItems', $json) && $json['additionalItems'] !== false; + + return [count($items), $coversTail]; + } + + public function getCheck(): string + { + $slotKeys = []; + + foreach ($this->parentProperty->getOrderedValidators() as $validator) { + if (!$validator instanceof AbstractComposedPropertyValidator) { + continue; + } + + $slotKey = $validator->getSlotKey(); + + if ($slotKey !== null) { + $slotKeys[] = $slotKey; + } + } + + $this->templateValues['compositionSlotKeys'] = RenderHelper::varExportArray($slotKeys); + + return parent::getCheck(); + } +} diff --git a/src/Model/Validator/AbstractUnevaluatedPropertiesValidator.php b/src/Model/Validator/AbstractUnevaluatedPropertiesValidator.php new file mode 100644 index 00000000..9e84330b --- /dev/null +++ b/src/Model/Validator/AbstractUnevaluatedPropertiesValidator.php @@ -0,0 +1,70 @@ +`-form (`UnevaluatedPropertiesValidator`). + * + * Both emit a template that calls `$this->collectUnevaluatedKeys(...)` from + * `CompositionEvaluationTrait` with the same three argument shapes: the local declared property + * names, the local PCRE-ready patternProperties, and the composition-validator key list. The + * last is resolved at render time because post-composition validators register on the schema + * before `ComposedValueProcessor` finishes; subclasses cannot know the final list at + * construction time. + */ +abstract class AbstractUnevaluatedPropertiesValidator extends PropertyTemplateValidator +{ + /** + * @param array $extraTemplateValues Subclass-specific template values merged + * on top of the shared set. + */ + public function __construct( + protected readonly Schema $compositionScope, + JsonSchema $propertiesStructure, + string $templatePath, + string $exceptionClass, + array $exceptionParams, + array $extraTemplateValues = [], + ?string $propertyName = null, + ) { + $json = $propertiesStructure->getJson(); + + parent::__construct( + new Property($propertyName ?? $compositionScope->getClassName(), null, $propertiesStructure), + $templatePath, + $extraTemplateValues + [ + 'declaredPropertyNames' => RenderHelper::varExportArray( + array_keys($json['properties'] ?? []), + ), + 'pcrePatterns' => RenderHelper::varExportPcrePatterns( + array_keys($json['patternProperties'] ?? []), + ), + ], + $exceptionClass, + $exceptionParams, + ); + } + + /** + * @inheritDoc + */ + public function getCheck(): string + { + // Resolved here — not in the constructor — because post-composition validators register + // on the schema after this validator's constructor returns. By the time getCheck() runs + // the base-validator list is final. + $this->templateValues['compositionValidatorKeys'] = RenderHelper::varExportArray( + $this->compositionScope->getCompositionValidatorKeys(), + ); + + return parent::getCheck(); + } +} diff --git a/src/Model/Validator/AdditionalPropertiesValidator.php b/src/Model/Validator/AdditionalPropertiesValidator.php index f556696a..63f6e51b 100644 --- a/src/Model/Validator/AdditionalPropertiesValidator.php +++ b/src/Model/Validator/AdditionalPropertiesValidator.php @@ -67,7 +67,9 @@ public function __construct( 'additionalProperties' => RenderHelper::varExportArray( array_keys($propertiesStructure->getJson()[static::PROPERTIES_KEY] ?? []), ), - 'patternProperties' => $patternProperties ? RenderHelper::varExportArray($patternProperties) : null, + 'patternProperties' => $patternProperties + ? RenderHelper::varExportPcrePatterns($patternProperties) + : null, 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), // by default don't collect additional property data diff --git a/src/Model/Validator/ArrayContainsValidator.php b/src/Model/Validator/ArrayContainsValidator.php new file mode 100644 index 00000000..1e538599 --- /dev/null +++ b/src/Model/Validator/ArrayContainsValidator.php @@ -0,0 +1,77 @@ + $nestedProperty, + 'schema' => $schema, + 'countMatches' => $countMatches, + 'allowNoMatch' => $allowNoMatch, + 'trackBranchMatches' => false, + 'trackEvaluatedItems' => false, + 'trackEvaluatedItemsProperty' => '', + 'viewHelper' => new RenderHelper($generatorConfiguration), + 'generatorConfiguration' => $generatorConfiguration, + ], + ContainsException::class, + ); + } + + /** + * Enable the per-index match map: the rendered IIFE captures `$branchContainsMatches` + * by reference from its surrounding scope and writes `true` at each matched index. The + * composition template in a tracked branch sets up the accumulator and unions it into + * the branch's evaluated index set on success. + */ + public function setTrackBranchMatches(bool $trackBranchMatches): void + { + $this->templateValues['trackBranchMatches'] = $trackBranchMatches; + } + + /** + * Enable direct-sibling index crediting: the rendered IIFE writes each matched index into + * `$this->_evaluatedItemIndices[]` so a sibling unevaluatedItems validator on the + * same property sees the matched indices as evaluated. Used when the `contains` keyword sits + * directly on an array property (not inside a composition branch). The property name is the + * validator's own bound property — the same name the sibling unevaluatedItems check keys on. + */ + public function setTrackEvaluatedItems(): void + { + $this->templateValues['trackEvaluatedItems'] = true; + $this->templateValues['trackEvaluatedItemsProperty'] = $this->property->getName(); + } + + public function getValidatorSetUp(): string + { + return $this->templateValues['countMatches'] ? ' + $containsMatches = 0; + ' : ''; + } +} diff --git a/src/Model/Validator/ArrayTupleValidator.php b/src/Model/Validator/ArrayTupleValidator.php index 8c89ad3c..99e7b999 100644 --- a/src/Model/Validator/ArrayTupleValidator.php +++ b/src/Model/Validator/ArrayTupleValidator.php @@ -82,6 +82,18 @@ public function getCheck(): string return parent::getCheck(); } + /** + * The properties used to validate each tuple index, in index order. Exposed so post + * processors (e.g. TransformingFilterOutputTypePostProcessor) can recurse into them — + * mirrors ArrayItemValidator::getNestedProperty() for the tuple-form equivalent. + * + * @return PropertyInterface[] + */ + public function getTupleProperties(): array + { + return $this->tupleProperties; + } + /** * Initialize all variables which are required to execute a property names validator */ diff --git a/src/Model/Validator/ComposedPropertyValidator.php b/src/Model/Validator/ComposedPropertyValidator.php index f09aa6c3..277cbd08 100644 --- a/src/Model/Validator/ComposedPropertyValidator.php +++ b/src/Model/Validator/ComposedPropertyValidator.php @@ -85,6 +85,10 @@ public function __construct( */ public function getCheck(): string { + // Make this validator instance available to the template so the unevaluatedProperties + // tracking guards (hasEvaluationTrackingEnabled, isNotComposition) can be evaluated. + $this->templateValues['compositionValidator'] = $this; + $this->setupBranchDefaultHelpers(); return parent::getCheck(); @@ -128,7 +132,7 @@ public function createSubsetValidator(array $branchIndices, string $methodSuffix // Regenerate the modifiedValuesMethod name so the subset validator's helper // method is distinct from the original's. $subsetValidator->modifiedValuesMethod = - '_getModifiedValues_' . substr(md5(spl_object_hash($subsetValidator)), 0, 5); + '_getModifiedValues_' . substr(md5((string) spl_object_id($subsetValidator)), 0, 5); $subsetValidator->composedProperties = $filteredProperties; $subsetValidator->templateValues = array_merge($this->templateValues, [ diff --git a/src/Model/Validator/ConditionalPropertyValidator.php b/src/Model/Validator/ConditionalPropertyValidator.php index a3afad08..a42ac107 100644 --- a/src/Model/Validator/ConditionalPropertyValidator.php +++ b/src/Model/Validator/ConditionalPropertyValidator.php @@ -96,6 +96,10 @@ public function getElseBranch(): ?CompositionPropertyDecorator */ public function getCheck(): string { + // Late-bind `compositionValidator` so template guards see the flags set on the current + // clone rather than on the pre-`withJsonPointer()` original. + $this->templateValues['compositionValidator'] = $this; + $this->setupBranchDefaultHelpers(); $thenProperty = $this->templateValues['thenProperty'] ?? null; diff --git a/src/Model/Validator/ExtractedMethodValidator.php b/src/Model/Validator/ExtractedMethodValidator.php index c147c776..21f7fdb7 100644 --- a/src/Model/Validator/ExtractedMethodValidator.php +++ b/src/Model/Validator/ExtractedMethodValidator.php @@ -31,7 +31,7 @@ public function __construct( '_validate%s_%s_%s', str_replace(' ', '', ucfirst($property->getAttribute())), str_replace('Validator', '', substr(strrchr(static::class, '\\'), 1)), - md5(json_encode($property->getJsonSchema()->getJson())), + md5(json_encode($property->getJsonSchema()->getJson()) . (string) spl_object_id($this)), ); parent::__construct($property, $template, $templateValues, $exceptionClass, $exceptionParams); diff --git a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php index 7415e2d0..3c447258 100644 --- a/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php +++ b/src/Model/Validator/Factory/Arrays/ContainsValidatorFactory.php @@ -11,12 +11,11 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Model\Validator\ArrayContainsValidator; use PHPModelGenerator\Model\Validator\Factory\AbstractValidatorFactory; -use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; use PHPModelGenerator\Model\Validator\PropertyValidator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; -use PHPModelGenerator\Utils\RenderHelper; class ContainsValidatorFactory extends AbstractValidatorFactory { @@ -73,26 +72,14 @@ public function modify( (array_key_exists('minContains', $json) || array_key_exists('maxContains', $json)); $property->addValidator( - new class ( + new ArrayContainsValidator( $property, - DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'ArrayContains.phptpl', - [ - 'property' => $nestedProperty, - 'schema' => $schema, - 'countMatches' => $countMatches, - 'allowNoMatch' => ($json['minContains'] ?? 1) === 0, - 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), - 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), - ], - ContainsException::class, - ) extends PropertyTemplateValidator { - public function getValidatorSetUp(): string - { - return $this->templateValues['countMatches'] ? ' - $containsMatches = 0; - ' : ''; - } - }, + $nestedProperty, + $schema, + $schemaProcessor->getGeneratorConfiguration(), + $countMatches, + ($json['minContains'] ?? 1) === 0, + ), ); if (!$countMatches) { diff --git a/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php new file mode 100644 index 00000000..13560b78 --- /dev/null +++ b/src/Model/Validator/Factory/Arrays/UnevaluatedItemsValidatorFactory.php @@ -0,0 +1,163 @@ +getJson(); + + // `unevaluatedItems: true` is the spec default — every index is allowed. Absent keyword + // is treated the same way. + if (!array_key_exists($this->key, $json) || $json[$this->key] === true) { + return; + } + + $unevaluatedItems = $json[$this->key]; + + if (!is_bool($unevaluatedItems) && !is_array($unevaluatedItems)) { + throw new SchemaException( + sprintf( + "Invalid unevaluatedItems %s for property '%s' in file %s", + str_replace("\n", '', var_export($unevaluatedItems, true)), + $property->getName(), + $propertySchema->getFile(), + ), + ); + } + + if ($this->isDeadCode($schemaProcessor, $schema, $property, $json)) { + return; + } + + $unevaluatedPointer = $propertySchema->getPointer() . '/' . $this->key; + + if ($unevaluatedItems === false) { + $property->addValidator( + (new NoUnevaluatedItemsValidator($property))->withJsonPointer($unevaluatedPointer), + self::VALIDATOR_PRIORITY, + ); + + return; + } + + $property->addValidator( + (new UnevaluatedItemsValidator($schemaProcessor, $schema, $property, $propertySchema)) + ->withJsonPointer($unevaluatedPointer), + self::VALIDATOR_PRIORITY, + ); + } + + /** + * Three sibling-shape combinations make the unevaluatedItems keyword unreachable: the + * array is forced empty by `items: false`, every index is already claimed by an + * `items: {schema}`, or the tuple length is fully covered with `additionalItems: false`. + * Each one is spec-legal and emits a generation-time warning instead of a SchemaException + * — the developer's intent is intact but the keyword cannot contribute. + */ + private function isDeadCode( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + array $json, + ): bool { + if (!array_key_exists('items', $json)) { + return false; + } + + $items = $json['items']; + + if ($items === false) { + $this->warn( + $schemaProcessor, + $schema, + $property, + "sibling items: false rejects every index, leaving no unevaluated items", + ); + + return true; + } + + if ($items === true) { + $this->warn( + $schemaProcessor, + $schema, + $property, + "sibling items: true already claims every index", + ); + + return true; + } + + $isTupleItems = is_array($items) && $items !== [] && array_is_list($items); + + if ($isTupleItems) { + if (($json['additionalItems'] ?? null) === false) { + $this->warn( + $schemaProcessor, + $schema, + $property, + "sibling additionalItems: false rejects every tail index past the tuple", + ); + + return true; + } + + return false; + } + + // Schema-form items claims every index per the spec; nothing is left over for the + // unevaluatedItems keyword to validate. + if (is_array($items)) { + $this->warn( + $schemaProcessor, + $schema, + $property, + "sibling items: {schema} already validates every index", + ); + + return true; + } + + return false; + } + + private function warn( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + string $reason, + ): void { + $schemaProcessor->getGeneratorConfiguration()->getLogger()->warning( + 'unevaluatedItems on {class}::{property} is dead code — {reason}', + ['class' => $schema->getClassName(), 'property' => $property->getName(), 'reason' => $reason], + ); + } +} diff --git a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php index b82ea814..d4e09afb 100644 --- a/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AbstractCompositionValidatorFactory.php @@ -341,7 +341,13 @@ protected function checkForFilterInBranches( /** * Build composition sub-properties for the current keyword's branches. * - * @param bool $merged Whether to suppress CompositionTypeHintDecorators for object branches. + * @param bool $merged Whether to suppress + * CompositionTypeHintDecorators for + * object branches. + * @param array $injectedTypeBranchIndices Branch indices whose 'type' was + * inherited rather than author-declared + * (see inheritPropertyType()) - excluded + * from the vacuous-branch check below. * * @return CompositionPropertyDecorator[] * @@ -353,6 +359,7 @@ protected function getCompositionProperties( PropertyInterface $property, JsonSchema $propertySchema, bool $merged, + array $injectedTypeBranchIndices = [], ): array { $propertyFactory = new PropertyFactory(); $compositionProperties = []; @@ -425,6 +432,12 @@ protected function getCompositionProperties( if ($compositionProperty->isResolved()) { $resolvedBranchJson = $compositionProperty->getJsonSchema()->getJson(); + // An inherited 'type' isn't an author-declared constraint - strip it so + // vacuousness is judged on what the branch actually declares. + if (isset($injectedTypeBranchIndices[$index])) { + unset($resolvedBranchJson['type']); + } + $this->warnIfVacuousBranch($schemaProcessor, $property, $index + 1, $resolvedBranchJson, $draft); } @@ -529,22 +542,32 @@ protected function createAlwaysTrueBranchProperty( /** * Inherit a parent-level type into composition branches that declare no type. + * + * @return array{0: JsonSchema, 1: array} The (possibly type-injected) schema, and + * the zero-based indices of branches whose 'type' this call injected rather than + * found already declared - consumed by getCompositionProperties()'s vacuous-branch + * check. Populated for 'not' too (index 0, once the caller wraps its single schema + * into an array). Always empty for 'if', which never reaches + * getCompositionProperties(). */ protected function inheritPropertyType( SchemaProcessor $schemaProcessor, PropertyInterface $property, JsonSchema $propertySchema, - ): JsonSchema { + ): array { + $injectedTypeBranchIndices = []; + $json = $propertySchema->getJson(); if (!isset($json['type'])) { - return $propertySchema; + return [$propertySchema, $injectedTypeBranchIndices]; } switch ($this->key) { case 'not': if (!isset($json[$this->key]['type'])) { $json[$this->key]['type'] = $json['type']; + $injectedTypeBranchIndices[0] = true; if ($json['type'] === 'object') { $this->warnIfInjectedObjectTypeConflictsWithEnumOrConst( @@ -557,11 +580,15 @@ protected function inheritPropertyType( } break; case 'if': - return $this->inheritIfPropertyType($schemaProcessor, $property, $propertySchema->withJson($json)); + return [ + $this->inheritIfPropertyType($schemaProcessor, $property, $propertySchema->withJson($json)), + $injectedTypeBranchIndices, + ]; default: foreach ($json[$this->key] as $index => &$composedElement) { if (!is_bool($composedElement) && !isset($composedElement['type'])) { $composedElement['type'] = $json['type']; + $injectedTypeBranchIndices[$index] = true; if ($json['type'] === 'object') { $this->warnIfInjectedObjectTypeConflictsWithEnumOrConst( @@ -575,7 +602,7 @@ protected function inheritPropertyType( } } - return $propertySchema->withJson($json); + return [$propertySchema->withJson($json), $injectedTypeBranchIndices]; } /** diff --git a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php index ba120894..3dda402e 100644 --- a/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AllOfValidatorFactory.php @@ -38,7 +38,11 @@ public function modify( } $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema); + [$propertySchema, $injectedTypeBranchIndices] = $this->inheritPropertyType( + $schemaProcessor, + $property, + $propertySchema, + ); $this->checkForFilterInBranches($property, $propertySchema); $wrappedSchema = $propertySchema->withJson([ @@ -53,6 +57,7 @@ public function modify( $property, $wrappedSchema, true, + $injectedTypeBranchIndices, ); $resolvedCompositions = 0; diff --git a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php index 18e4512f..b6cff559 100644 --- a/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/AnyOfValidatorFactory.php @@ -39,7 +39,11 @@ public function modify( } $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema); + [$propertySchema, $injectedTypeBranchIndices] = $this->inheritPropertyType( + $schemaProcessor, + $property, + $propertySchema, + ); $this->checkForFilterInBranches($property, $propertySchema); $onlyForDefinedValues = !($property instanceof BaseProperty) @@ -58,6 +62,7 @@ public function modify( $property, $wrappedSchema, true, + $injectedTypeBranchIndices, ); $resolvedCompositions = 0; diff --git a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php index a5e89715..babef878 100644 --- a/src/Model/Validator/Factory/Composition/IfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/IfValidatorFactory.php @@ -62,7 +62,7 @@ public function modify( // that sub-schemas that inherit 'object' are correctly recognised as object-typed. // Object-typed sub-schemas create nested schemas whose properties are processed // independently and are not subject to ComposedItem $value reset. - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema->withJson($json)); + [$propertySchema] = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema->withJson($json)); $json = $propertySchema->getJson(); // Check for filter keywords in if/then/else sub-schemas after type inheritance. diff --git a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php index 2a84cd3e..6ab84463 100644 --- a/src/Model/Validator/Factory/Composition/NotValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/NotValidatorFactory.php @@ -50,7 +50,11 @@ public function modify( // Inherit the parent type into the not branch before wrapping in array. // inheritPropertyType for 'not' treats $json['not'] as a single schema object, // so it must run before we wrap it in an array for iteration. - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema); + [$propertySchema, $injectedTypeBranchIndices] = $this->inheritPropertyType( + $schemaProcessor, + $property, + $propertySchema, + ); // Check for filter keywords after type inheritance so that branches that inherit // 'object' from the parent are correctly treated as object-typed (their properties // are processed as a nested schema and are not subject to ComposedItem $value reset). @@ -76,6 +80,7 @@ public function modify( $property, $wrappedSchema, false, + $injectedTypeBranchIndices, ); $availableAmount = count($compositionProperties); diff --git a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php index fc3f8c3f..cce11105 100644 --- a/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php +++ b/src/Model/Validator/Factory/Composition/OneOfValidatorFactory.php @@ -39,7 +39,11 @@ public function modify( } $this->warnIfEmpty($schemaProcessor, $property, $propertySchema); - $propertySchema = $this->inheritPropertyType($schemaProcessor, $property, $propertySchema); + [$propertySchema, $injectedTypeBranchIndices] = $this->inheritPropertyType( + $schemaProcessor, + $property, + $propertySchema, + ); $this->checkForFilterInBranches($property, $propertySchema); $onlyForDefinedValues = !($property instanceof BaseProperty) @@ -58,6 +62,7 @@ public function modify( $property, $wrappedSchema, false, + $injectedTypeBranchIndices, ); $resolvedCompositions = 0; diff --git a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php index b7bebbd4..41c0e465 100644 --- a/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/PatternPropertiesValidatorFactory.php @@ -12,6 +12,7 @@ use PHPModelGenerator\Model\Validator\ForbiddenPatternPropertiesValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; class PatternPropertiesValidatorFactory extends AbstractValidatorFactory { @@ -51,7 +52,8 @@ public function modify( $schema->getClassName(), $propertySchema, ))->withJsonPointer( - $propertySchema->getPointer() . '/' . $this->key . '/' . JsonSchema::encodePointer($pattern), + $propertySchema->getPointer() . '/' . $this->key + . '/' . JsonSchemaUtil::encodePointer($pattern), ), ); continue; @@ -62,9 +64,9 @@ public function modify( $schemaProcessor, $schema, $pattern, - $propertySchema->navigate("$this->key/" . JsonSchema::encodePointer($pattern)), + $propertySchema->navigate("$this->key/" . JsonSchemaUtil::encodePointer($pattern)), ))->withJsonPointer( - $propertySchema->getPointer() . '/' . $this->key . '/' . JsonSchema::encodePointer($pattern), + $propertySchema->getPointer() . '/' . $this->key . '/' . JsonSchemaUtil::encodePointer($pattern), ), ); } diff --git a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php index b2a38032..b8cbe8b0 100644 --- a/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/PropertiesValidatorFactory.php @@ -14,6 +14,7 @@ use PHPModelGenerator\Model\Validator\PropertyValidator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; class PropertiesValidatorFactory extends AbstractValidatorFactory { @@ -68,7 +69,7 @@ public function modify( ))->withJsonPointer( $propertySchema->getPointer() . '/properties/' - . JsonSchema::encodePointer((string) $propertyName), + . JsonSchemaUtil::encodePointer((string) $propertyName), ), ); continue; @@ -84,12 +85,12 @@ public function modify( ->withPointer( $propertySchema->getPointer() . '/' . $this->key . '/' - . JsonSchema::encodePointer($propertyName) + . JsonSchemaUtil::encodePointer($propertyName) ) ->withJson([]); } else { $nestedPropertySchema = $propertySchema - ->navigate("$this->key/" . JsonSchema::encodePointer($propertyName)) + ->navigate("$this->key/" . JsonSchemaUtil::encodePointer($propertyName)) ->withJson( $dependencies !== null ? $propertyStructure + ['_dependencies' => $dependencies] @@ -109,7 +110,7 @@ public function modify( $this->addDependencyValidator( $nestedProperty, $schema->getJsonSchema()->navigate( - 'dependencies/' . JsonSchema::encodePointer((string) $propertyName), + 'dependencies/' . JsonSchemaUtil::encodePointer((string) $propertyName), ), $schemaProcessor, $schema, diff --git a/src/Model/Validator/Factory/Object/RequiredValidatorFactory.php b/src/Model/Validator/Factory/Object/RequiredValidatorFactory.php index 8b3af5c3..62a81f92 100644 --- a/src/Model/Validator/Factory/Object/RequiredValidatorFactory.php +++ b/src/Model/Validator/Factory/Object/RequiredValidatorFactory.php @@ -12,6 +12,7 @@ use PHPModelGenerator\Model\Validator\RequiredPropertyValidator; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; /** * Attaches a RequiredPropertyValidator to every property named in the object schema's 'required' @@ -64,7 +65,7 @@ private function createUndeclaredProperty( // withPointer() to advance the pointer without descending into JSON content. $nestedPropertySchema = $propertySchema ->withPointer( - $propertySchema->getPointer() . '/properties/' . JsonSchema::encodePointer($propertyName), + $propertySchema->getPointer() . '/properties/' . JsonSchemaUtil::encodePointer($propertyName), ) ->withJson([]); @@ -81,7 +82,7 @@ private function createUndeclaredProperty( if ($dependencies !== null) { $this->addDependencyValidator( $nestedProperty, - $schema->getJsonSchema()->navigate('dependencies/' . JsonSchema::encodePointer($propertyName)), + $schema->getJsonSchema()->navigate('dependencies/' . JsonSchemaUtil::encodePointer($propertyName)), $schemaProcessor, $schema, ); diff --git a/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php new file mode 100644 index 00000000..b7380382 --- /dev/null +++ b/src/Model/Validator/Factory/Object/UnevaluatedPropertiesValidatorFactory.php @@ -0,0 +1,116 @@ +getJson(); + + // `unevaluatedProperties: true` is the spec default — every unevaluated key is allowed, + // so no validator is needed. Absent keyword is treated the same way. + if (!isset($json[$this->key]) || $json[$this->key] === true) { + return; + } + + $unevaluatedProperties = $json[$this->key]; + + if (!is_bool($unevaluatedProperties) && !is_array($unevaluatedProperties)) { + throw new SchemaException( + sprintf( + "Invalid unevaluatedProperties %s for property '%s' in file %s", + str_replace("\n", '', var_export($unevaluatedProperties, true)), + $schema->getClassName(), + $propertySchema->getFile(), + ), + ); + } + + // A sibling `additionalProperties` (or the effective `false` produced by the + // `denyAdditionalProperties()` generator flag) short-circuits the unevaluated bucket: + // - `true` — every extra flows to the model unchecked, but the accumulator does + // not credit those keys, so unevaluatedProperties would still fire. + // Emitting it defeats the intent of `additionalProperties: true` + // (accept every extra), so we suppress and warn. + // - `{schema}` — every extra is claimed by additionalProperties, leaving the + // unevaluated set permanently empty. + // - `false` — every extra is rejected by additionalProperties before the + // post-composition phase, so the unevaluated validator never sees + // any keys. + $deadCodeReason = $this->deadCodeReason($schemaProcessor, $json); + if ($deadCodeReason !== null) { + $schemaProcessor->getGeneratorConfiguration()->getLogger()->warning( + 'unevaluatedProperties on {class} is dead code — {reason}', + ['class' => $schema->getClassName(), 'reason' => $deadCodeReason], + ); + + return; + } + + $unevaluatedPointer = $propertySchema->getPointer() . '/' . $this->key; + + if ($unevaluatedProperties === false) { + $schema->addPostCompositionValidator( + (new NoUnevaluatedPropertiesValidator($schema, $propertySchema)) + ->withJsonPointer($unevaluatedPointer), + ); + + return; + } + + $schema->addPostCompositionValidator( + (new UnevaluatedPropertiesValidator($schemaProcessor, $schema, $propertySchema)) + ->withJsonPointer($unevaluatedPointer), + ); + } + + /** + * Returns the human-readable reason unevaluatedProperties is dead code, or null when the + * validator must still be emitted. Consolidates the four sibling shapes that leave the + * unevaluated bucket permanently empty at this schema level. + */ + private function deadCodeReason(SchemaProcessor $schemaProcessor, array $json): ?string + { + if (array_key_exists('additionalProperties', $json)) { + $additionalProperties = $json['additionalProperties']; + + if ($additionalProperties === true) { + return 'sibling additionalProperties: true accepts every extra without crediting' + . ' the unevaluated accumulator'; + } + + if ($additionalProperties === false) { + return 'sibling additionalProperties: false rejects every extra before the' + . ' unevaluated phase runs'; + } + + return 'sibling additionalProperties: {schema} already validates every extra key'; + } + + if ($schemaProcessor->getGeneratorConfiguration()->denyAdditionalProperties()) { + return 'denyAdditionalProperties() flips missing additionalProperties to false,' + . ' rejecting every extra before the unevaluated phase runs'; + } + + return null; + } +} diff --git a/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php index ef61cb65..ddb82c43 100644 --- a/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php +++ b/src/Model/Validator/Factory/SimpleBaseValidatorFactory.php @@ -9,6 +9,7 @@ use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\AbstractPropertyValidator; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; abstract class SimpleBaseValidatorFactory extends SimplePropertyValidatorFactory { @@ -26,7 +27,7 @@ public function modify( if ($validator instanceof AbstractPropertyValidator) { $validator = $validator->withJsonPointer( - $propertySchema->getPointer() . '/' . JsonSchema::encodePointer($this->key), + $propertySchema->getPointer() . '/' . JsonSchemaUtil::encodePointer($this->key), ); } diff --git a/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php index 35d7c60c..ecc8311a 100644 --- a/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php +++ b/src/Model/Validator/Factory/SimplePropertyValidatorFactory.php @@ -11,6 +11,7 @@ use PHPModelGenerator\Model\Validator\AbstractPropertyValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; abstract class SimplePropertyValidatorFactory extends AbstractValidatorFactory { @@ -28,7 +29,7 @@ public function modify( if ($validator instanceof AbstractPropertyValidator) { $validator = $validator->withJsonPointer( - $propertySchema->getPointer() . '/' . JsonSchema::encodePointer($this->key), + $propertySchema->getPointer() . '/' . JsonSchemaUtil::encodePointer($this->key), ); } diff --git a/src/Model/Validator/ForbiddenPatternPropertiesValidator.php b/src/Model/Validator/ForbiddenPatternPropertiesValidator.php index e8725660..8f050cd2 100644 --- a/src/Model/Validator/ForbiddenPatternPropertiesValidator.php +++ b/src/Model/Validator/ForbiddenPatternPropertiesValidator.php @@ -6,8 +6,8 @@ use PHPModelGenerator\Exception\Object\InvalidPatternPropertiesException; use PHPModelGenerator\Model\Property\Property; -use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; /** * Validator for patternProperties where the pattern schema is `false`. @@ -33,7 +33,7 @@ public function __construct(private readonly string $pattern, string $className, $this->patternPointer = $propertySchema->getPointer() . '/patternProperties/' - . JsonSchema::encodePointer($this->pattern); + . JsonSchemaUtil::encodePointer($this->pattern); } public function getPattern(): string diff --git a/src/Model/Validator/MultiTypeCheckValidator.php b/src/Model/Validator/MultiTypeCheckValidator.php index 669d8046..eb7305f7 100644 --- a/src/Model/Validator/MultiTypeCheckValidator.php +++ b/src/Model/Validator/MultiTypeCheckValidator.php @@ -7,20 +7,24 @@ use PHPModelGenerator\Exception\Generic\InvalidTypeException; use PHPModelGenerator\Model\Property\PropertyInterface; -/** - * Class MultiTypeCheckValidator - * - * @package PHPModelGenerator\Model\Validator - */ class MultiTypeCheckValidator extends PropertyValidator implements TypeCheckInterface { /** - * MultiTypeCheckValidator constructor. - * * @param string[] $types + * @param bool $treatObjectAsUninstantiatedShape True when "object" candidacy + * must be checked against a raw, + * not-yet-instantiated value - the + * property's own composition + * validator owns instantiation + * instead of ObjectInstantiationDecorator. + * See PropertyFactory::createMultiTypeProperty(). */ - public function __construct(protected array $types, PropertyInterface $property, bool $allowImplicitNull) - { + public function __construct( + protected array $types, + PropertyInterface $property, + bool $allowImplicitNull, + bool $treatObjectAsUninstantiatedShape = false, + ) { // if null is explicitly allowed we don't need an implicit null pass through if (in_array('null', $this->types)) { $allowImplicitNull = false; @@ -32,7 +36,11 @@ public function __construct(protected array $types, PropertyInterface $property, ' && ', array_map( static fn(string $allowedType): string => - ReflectionTypeCheckValidator::fromType($allowedType, $property)->getCheck(), + ReflectionTypeCheckValidator::fromType( + $allowedType, + $property, + $allowedType === 'object' && $treatObjectAsUninstantiatedShape, + )->getCheck(), $this->types, ) ) . ($allowImplicitNull ? ' && $value !== null' : ''), diff --git a/src/Model/Validator/NoUnevaluatedItemsValidator.php b/src/Model/Validator/NoUnevaluatedItemsValidator.php new file mode 100644 index 00000000..6a36bf98 --- /dev/null +++ b/src/Model/Validator/NoUnevaluatedItemsValidator.php @@ -0,0 +1,30 @@ +isResolved = true; + + parent::__construct( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'NoUnevaluatedItems.phptpl', + UnevaluatedItemsException::class, + ['&$unevaluatedItems'], + ); + } +} diff --git a/src/Model/Validator/NoUnevaluatedPropertiesValidator.php b/src/Model/Validator/NoUnevaluatedPropertiesValidator.php new file mode 100644 index 00000000..9b48d7de --- /dev/null +++ b/src/Model/Validator/NoUnevaluatedPropertiesValidator.php @@ -0,0 +1,31 @@ +isResolved = true; + + parent::__construct( + $compositionScope, + $propertiesStructure, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'NoUnevaluatedProperties.phptpl', + UnevaluatedPropertiesException::class, + ['&$unevaluatedProperties'], + ); + } +} diff --git a/src/Model/Validator/ReflectionTypeCheckValidator.php b/src/Model/Validator/ReflectionTypeCheckValidator.php index 315d62cb..2ad03ee6 100644 --- a/src/Model/Validator/ReflectionTypeCheckValidator.php +++ b/src/Model/Validator/ReflectionTypeCheckValidator.php @@ -7,26 +7,22 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Utils\TypeCheck; -/** - * Class ReflectionTypeCheckValidator - * - * @package PHPModelGenerator\Model\Validator - */ class ReflectionTypeCheckValidator extends PropertyValidator { public static function fromType( string $type, PropertyInterface $property, + bool $treatObjectAsUninstantiatedShape = false, ): self { - return new self($type, $property); + return new self($type, $property, $treatObjectAsUninstantiatedShape); } - /** - * ReflectionTypeCheckValidator constructor. - */ - public function __construct(string $name, PropertyInterface $property) - { - $typeCheck = TypeCheck::buildNegatedJsonSchemaTypeCheck($name); + public function __construct( + string $name, + PropertyInterface $property, + bool $treatObjectAsUninstantiatedShape = false, + ) { + $typeCheck = TypeCheck::buildNegatedJsonSchemaTypeCheck($name, $treatObjectAsUninstantiatedShape); parent::__construct($property, $typeCheck, ''); } diff --git a/src/Model/Validator/UnevaluatedItemsValidator.php b/src/Model/Validator/UnevaluatedItemsValidator.php new file mode 100644 index 00000000..a65977ac --- /dev/null +++ b/src/Model/Validator/UnevaluatedItemsValidator.php @@ -0,0 +1,87 @@ +`. + * + * Mirrors UnevaluatedPropertiesValidator on the array side: each index of the array not + * claimed by a sibling positive applicator must validate against the unevaluatedItems + * subschema. + */ +class UnevaluatedItemsValidator extends AbstractUnevaluatedItemsValidator +{ + private const NESTED_PROPERTY_NAME = 'unevaluated item'; + + private readonly PropertyInterface $validationProperty; + + /** + * @throws SchemaException + */ + public function __construct( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertiesStructure, + ) { + $this->validationProperty = (new PropertyFactory())->create( + $schemaProcessor, + $schema, + self::NESTED_PROPERTY_NAME, + $propertiesStructure->navigate('unevaluatedItems'), + true, + ); + + $this->validationProperty->onResolve(function (): void { + $this->resolve(); + }); + + parent::__construct( + $property, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'UnevaluatedItems.phptpl', + InvalidUnevaluatedItemsException::class, + ['&$invalidItems'], + [ + 'schema' => $schema, + 'validationProperty' => $this->validationProperty, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + ], + ); + } + + /** + * @inheritDoc + */ + public function getCheck(): string + { + $this->removeRequiredPropertyValidator($this->validationProperty); + + return parent::getCheck(); + } + + public function getValidationProperty(): PropertyInterface + { + return $this->validationProperty; + } + + /** + * Initialize the per-call error map captured by reference into the IIFE so the + * generated exception receives the populated list. + */ + public function getValidatorSetUp(): string + { + return '$invalidItems = [];'; + } +} diff --git a/src/Model/Validator/UnevaluatedPropertiesValidator.php b/src/Model/Validator/UnevaluatedPropertiesValidator.php new file mode 100644 index 00000000..6c200964 --- /dev/null +++ b/src/Model/Validator/UnevaluatedPropertiesValidator.php @@ -0,0 +1,99 @@ +`. + * + * Mirrors AdditionalPropertiesValidator, except the iterated key set is the model keys + * left over after subtracting the evaluated set (local properties/patternProperties plus the + * contributions of sibling composition branches). + */ +class UnevaluatedPropertiesValidator extends AbstractUnevaluatedPropertiesValidator +{ + protected const PROPERTY_NAME = 'unevaluated property'; + + private readonly PropertyInterface $validationProperty; + private bool $collectUnevaluatedProperties = false; + + /** + * @throws SchemaException + */ + public function __construct( + SchemaProcessor $schemaProcessor, + Schema $compositionScope, + JsonSchema $propertiesStructure, + ?string $propertyName = null, + ) { + $this->validationProperty = (new PropertyFactory())->create( + $schemaProcessor, + $compositionScope, + static::PROPERTY_NAME, + $propertiesStructure->navigate('unevaluatedProperties'), + true, + ); + + $this->validationProperty->onResolve(function (): void { + $this->resolve(); + }); + + parent::__construct( + $compositionScope, + $propertiesStructure, + DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'UnevaluatedProperties.phptpl', + InvalidUnevaluatedPropertiesException::class, + ['&$invalidProperties'], + [ + 'schema' => $compositionScope, + 'validationProperty' => $this->validationProperty, + 'generatorConfiguration' => $schemaProcessor->getGeneratorConfiguration(), + 'viewHelper' => new RenderHelper($schemaProcessor->getGeneratorConfiguration()), + // by default the unevaluated keys validate but are not collected on the model + 'collectUnevaluatedProperties' => &$this->collectUnevaluatedProperties, + ], + $propertyName, + ); + } + + /** + * @inheritDoc + */ + public function getCheck(): string + { + $this->removeRequiredPropertyValidator($this->validationProperty); + + return parent::getCheck(); + } + + public function setCollectUnevaluatedProperties(bool $collectUnevaluatedProperties): void + { + $this->collectUnevaluatedProperties = $collectUnevaluatedProperties; + } + + public function getValidationProperty(): PropertyInterface + { + return $this->validationProperty; + } + + /** + * Initialize all variables which are required to execute an unevaluated properties validator. + */ + public function getValidatorSetUp(): string + { + return ' + $properties = $value; + $invalidProperties = []; + '; + } +} diff --git a/src/ModelGenerator.php b/src/ModelGenerator.php index 02c47ab2..f191798a 100644 --- a/src/ModelGenerator.php +++ b/src/ModelGenerator.php @@ -16,7 +16,8 @@ ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor, PatternPropertiesPostProcessor, SerializationPostProcessor, - TransformingFilterOutputTypePostProcessor + TransformingFilterOutputTypePostProcessor, + UnevaluatedPropertiesPostProcessor }; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\SchemaProcessor\RenderQueue; @@ -45,6 +46,7 @@ public function __construct(protected GeneratorConfiguration $generatorConfigura // add internal post processors which must always be executed $this ->addPostProcessor(new CompositionValidationPostProcessor()) + ->addPostProcessor(new UnevaluatedPropertiesPostProcessor()) ->addPostProcessor(new AdditionalPropertiesPostProcessor()) ->addPostProcessor(new PatternPropertiesPostProcessor()) ->addPostProcessor(new ExtendObjectPropertiesMatchingPatternPropertiesPostProcessor()) diff --git a/src/PropertyProcessor/Decorator/TypeHint/CompositionTypeHintDecorator.php b/src/PropertyProcessor/Decorator/TypeHint/CompositionTypeHintDecorator.php index 4ab566e6..0379d3c7 100644 --- a/src/PropertyProcessor/Decorator/TypeHint/CompositionTypeHintDecorator.php +++ b/src/PropertyProcessor/Decorator/TypeHint/CompositionTypeHintDecorator.php @@ -6,13 +6,10 @@ use PHPModelGenerator\Model\Property\PropertyInterface; -/** - * Class CompositionTypeHintDecorator - * - * @package PHPModelGenerator\PropertyProcessor\Decorator\Property - */ class CompositionTypeHintDecorator implements TypeHintDecoratorInterface { + private int $recursionDepth = 0; + public function __construct(protected PropertyInterface $nestedProperty) {} @@ -21,7 +18,20 @@ public function __construct(protected PropertyInterface $nestedProperty) */ public function decorate(string $input, bool $outputType = false): string { - return (new TypeHintDecorator(explode('|', $this->nestedProperty->getTypeHint($outputType)))) + // A self-referencing composition branch (e.g. {allOf: [{$ref: "#"}]}) wraps a property + // whose own type-hint decorators include this very instance again - without a re-entry + // guard, getTypeHint() would recurse indefinitely. On re-entry, skip this decorator + // class for the nested call instead of applying it again; ArrayTypeHintDecorator uses + // the same pattern for the analogous array-composition cycle. + if (++$this->recursionDepth > 1) { + return $this->nestedProperty->getTypeHint($outputType, [self::class]); + } + + $result = (new TypeHintDecorator(explode('|', $this->nestedProperty->getTypeHint($outputType)))) ->decorate($input, $outputType); + + $this->recursionDepth--; + + return $result; } } diff --git a/src/PropertyProcessor/Filter/FilterProcessor.php b/src/PropertyProcessor/Filter/FilterProcessor.php index 13cc1cbb..f13cc5c1 100644 --- a/src/PropertyProcessor/Filter/FilterProcessor.php +++ b/src/PropertyProcessor/Filter/FilterProcessor.php @@ -21,7 +21,6 @@ use PHPModelGenerator\Model\Validator\EnumValidator; use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Composition\AnyOfValidatorFactory; -use PHPModelGenerator\Model\Validator\Factory\Composition\IfValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Composition\NotValidatorFactory; use PHPModelGenerator\Model\Validator\Factory\Composition\OneOfValidatorFactory; use PHPModelGenerator\Model\Validator\FilterValidator; diff --git a/src/PropertyProcessor/PropertyFactory.php b/src/PropertyProcessor/PropertyFactory.php index 723d3f55..df012e72 100644 --- a/src/PropertyProcessor/PropertyFactory.php +++ b/src/PropertyProcessor/PropertyFactory.php @@ -11,6 +11,8 @@ use PHPModelGenerator\Attributes\Required; use PHPModelGenerator\Attributes\SchemaName; use PHPModelGenerator\Attributes\WriteOnlyProperty; +use PHPModelGenerator\Draft\Draft; +use PHPModelGenerator\Draft\Modifier\ModifierInterface; use PHPModelGenerator\Draft\Modifier\ObjectType\ObjectModifier; use PHPModelGenerator\Draft\Producer\ExclusiveProducer; use PHPModelGenerator\Draft\Producer\PropertyProducerInterface; @@ -27,6 +29,8 @@ use PHPModelGenerator\Model\Validator\Factory\Composition\AllOfValidatorFactory; use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator; use PHPModelGenerator\Model\Validator\TypeCheckInterface; +use PHPModelGenerator\PropertyProcessor\Decorator\Property\ObjectInstantiationDecorator; +use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyDecoratorInterface; use PHPModelGenerator\PropertyProcessor\Decorator\Property\PropertyTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\SchemaNamespaceTransferDecorator; use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\TypeHintDecorator; @@ -140,6 +144,14 @@ public function create( $isArrayItem, ), 'base' => $this->createBaseProperty($schemaProcessor, $schema, $propertyName, $propertySchema), + 'any' => $this->createUntypedProperty( + $schemaProcessor, + $schema, + $propertyName, + $propertySchema, + $required, + $isArrayItem, + ), default => $this->createTypedProperty( $schemaProcessor, $schema, @@ -206,6 +218,15 @@ private function rerouteAllOfObjectShape( * preserving the strict-spec pass-through of non-object values (this is why it is NOT * the ObjectAsserting path handled by rerouteAllOfObjectShape()). * + * Declines to reroute when the schema also carries a keyword that constrains some + * concrete non-object type (e.g. minLength on a string): forcing type: object here would + * hand the whole schema to createObjectProperty(), whose nested class only wires up + * object-type modifiers - a sibling minLength would be silently dropped instead of + * self-gating on string values the way createUntypedProperty()'s scalar applicators do. + * Falling through lets create() route it through createUntypedProperty() instead, which + * wires the identical object-describing self-gating via hasObjectApplicator() / + * wireUntypedObjectClass() while also applying every other type's self-gating applicators. + * * Returns null (declining to reroute) when the guard does not apply, in which case * create() falls through to the regular type dispatch. * @@ -224,6 +245,10 @@ private function rerouteBareObjectValidator( if ( array_intersect(array_keys($json), ['allOf', 'anyOf', 'oneOf', 'if', 'not', '$ref']) || $getObjectShapeResolver()->resolve($json) !== ObjectShape::ObjectDescribing + || $this->hasNonObjectTypeApplicator( + $schemaProcessor->getGeneratorConfiguration()->getBuiltDraft($propertySchema), + $json, + ) ) { return null; } @@ -790,7 +815,9 @@ private function createBaseProperty( } /** - * Handle scalar, array, and untyped properties: construct directly and run all Draft modifiers. + * Handle a scalar or array property: construct directly and run all Draft modifiers. Untyped + * ('any') properties are routed to createUntypedProperty instead, so $type is always a concrete + * JSON Schema type here. * * @throws SchemaException */ @@ -803,11 +830,10 @@ private function createTypedProperty( bool $required, bool $isArrayItem = false, ): PropertyInterface { - $phpType = $type !== 'any' ? TypeConverter::jsonSchemaToPHP($type) : null; $property = $this->buildProperty( $schemaProcessor, $propertyName, - $phpType !== null ? new PropertyType($phpType) : null, + new PropertyType(TypeConverter::jsonSchemaToPHP($type)), $propertySchema, $required, $isArrayItem, @@ -818,6 +844,186 @@ private function createTypedProperty( return $property; } + /** + * Handle an untyped property (`type` absent → resolves to 'any'). JSON Schema applicators are + * not gated on a type declaration, so a subschema declaring object/scalar/array applicators + * must apply them whenever the instance is of the relevant type while still accepting values + * of every other type — an untyped schema imposes no type constraint. + * + * Three layers are wired onto a single, permissive (nullable/mixed) property: + * - object applicators (properties, patternProperties, unevaluatedProperties, …) generate a + * nested class and attach gated instantiation + instanceof, WITHOUT stamping the nested type; + * - the universal 'any' modifiers (enum, const, composition, if, not, filter, default) run once; + * - scalar/array applicators (minLength, minItems, …) attach their self-gating validators. + * + * A bare `{}` (or a schema carrying only universal keywords) activates neither the object nor + * the scalar layer and behaves exactly like the previous untyped handling. + * + * @throws SchemaException + */ + private function createUntypedProperty( + SchemaProcessor $schemaProcessor, + Schema $schema, + string $propertyName, + JsonSchema $propertySchema, + bool $required, + bool $isArrayItem = false, + ): PropertyInterface { + $builtDraft = $schemaProcessor->getGeneratorConfiguration()->getBuiltDraft($propertySchema); + $property = $this->buildProperty( + $schemaProcessor, + $propertyName, + null, + $propertySchema, + $required, + $isArrayItem, + ); + + // Object applicators first so the instantiation decorator is registered before any filter + // decorators the universal modifiers add — mirrors the createObjectProperty ordering. + if ($this->hasObjectApplicator($builtDraft, $propertySchema->getJson())) { + $this->wireUntypedObjectClass($schemaProcessor, $schema, $property, $propertySchema, $propertyName); + } + + // Universal 'any' modifiers on the outer property. + $this->applyModifiers($schemaProcessor, $schema, $property, $propertySchema, anyOnly: true); + + // Scalar/array applicators (self-guarding on keyword presence, self-gating on runtime type). + $this->applyUntypedScalarModifiers($schemaProcessor, $schema, $property, $propertySchema, $builtDraft); + + return $property; + } + + /** + * Whether the given untyped schema carries at least one applicator keyword registered on the + * object type (properties, patternProperties, additionalProperties, unevaluatedProperties, + * minProperties, maxProperties, propertyNames, …). Only then is the nested-object class worth + * generating — a bare `{}` must not produce an empty class. + */ + private function hasObjectApplicator(Draft $builtDraft, array $json): bool + { + foreach (array_keys($json) as $keyword) { + if (in_array('object', $builtDraft->getTypesForKeyword($keyword), true)) { + return true; + } + } + + return false; + } + + /** + * Whether the given schema carries a keyword registered on some concrete type other than + * object (minLength, minItems, pattern, …). A keyword registered only on 'any' (enum, const, + * …) does not count: those need no per-type self-gating, so they are not a reason to prefer + * createUntypedProperty()'s self-gating dispatch over a plain object-describing reroute. + */ + private function hasNonObjectTypeApplicator(Draft $builtDraft, array $json): bool + { + foreach (array_keys($json) as $keyword) { + if (array_diff($builtDraft->getTypesForKeyword($keyword), ['any', 'object'])) { + return true; + } + } + + return false; + } + + /** + * Generate the nested object class for an untyped property that carries object applicators and + * wire gated instantiation onto the outer property, keeping the outer property permissive. + * + * @throws SchemaException + */ + private function wireUntypedObjectClass( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + string $propertyName, + ): void { + $className = $schemaProcessor->getGeneratorConfiguration()->getClassNameGenerator()->getClassName( + $propertyName, + $propertySchema, + false, + $schemaProcessor->getCurrentClassName(), + ); + + // Force `type: object` on the copy handed to processSchema so the nested class is generated; + // the outer property stays untyped. Property-level universal keywords target the outer + // property (handled by applyModifiers) and are stripped here, mirroring createObjectProperty. + $nestedJson = $propertySchema->getJson(); + unset($nestedJson['filter'], $nestedJson['enum'], $nestedJson['default']); + $nestedJson['type'] = 'object'; + + $nestedSchema = $schemaProcessor->processSchema( + $propertySchema->withJson($nestedJson), + $schemaProcessor->getCurrentClassPath(), + $className, + $schema->getSchemaDictionary(), + ); + + // Injecting `type: object` guarantees processSchema generates a class — its skip path only + // triggers for a non-object, non-composition, non-$ref root — so $nestedSchema is never null + // here. The guard mirrors createObjectProperty and degrades gracefully rather than fatally + // if a custom pipeline ever returns null. + if ($nestedSchema === null) { + return; + } + + $property->setNestedSchema($nestedSchema); + + // ObjectModifier attaches the array→Nested instantiation decorator and the instanceof guard; + // both self-gate at runtime (is_array / is_object) so a non-object value passes through + // untouched. It also stamps the nested-class type — required so InstanceOfValidator can name + // the class — which is immediately reset below to keep the getter permissive: the value may + // be the nested object OR any other type the untyped schema accepts, so the property is + // typed `Nested | mixed`, not `Nested`. TypeCheckModifier(object) is deliberately NOT wired: + // it would hard-reject non-object values and defeat "an untyped schema constrains nothing". + (new ObjectModifier())->modify($schemaProcessor, $schema, $property, $propertySchema); + + $property + ->setType(null) + ->addTypeHintDecorator(new TypeHintDecorator([$nestedSchema->getClassName(), 'mixed'])); + } + + /** + * Apply the concrete-type validator factories to an untyped property. Each factory self-guards + * on keyword presence and each emitted validator self-gates on the runtime type + * (e.g. `is_string($value) && …`), so running every concrete type's factories against an + * untyped value contributes a validator only for a keyword that is actually present and never + * constrains the value's type. + * + * The object type is skipped — its applicators are owned by the nested class wired in + * wireUntypedObjectClass — and so is 'any', whose modifiers already ran via applyModifiers. + * + * @throws SchemaException + */ + private function applyUntypedScalarModifiers( + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + Draft $builtDraft, + ): void { + foreach ($builtDraft->getTypes() as $typeName => $type) { + if ($typeName === 'any' || $typeName === 'object') { + continue; + } + + foreach ($type->getModifiers() as $modifier) { + // Only the keyword-keyed validator factories are safe to run untyped. The remaining + // modifiers are type-shaping and NOT keyword-gated: TypeCheckModifier would impose a + // type constraint, IntToFloatModifier would cast every int, NullModifier would force + // null — none may run on a property that accepts any type. + if (!$modifier instanceof AbstractValidatorFactory) { + continue; + } + + $this->runModifier($modifier, $schemaProcessor, $schema, $property, $propertySchema); + } + } + } + /** * Construct a Property with the common required/readOnly/writeOnly setup. * @@ -947,6 +1153,18 @@ private function createMultiTypeProperty( $subJson = $json; unset($subJson['default']); + // When the multi-type property itself carries schema-root composition, the object + // variant's sub-schema re-processes that same composition a second time as its own + // nested class's schema-root composition - duplicating the composition already attached + // once, correctly, to $property itself (finalizeMultiTypeProperty()). That one correct + // copy needs the raw, not-yet-instantiated value to do its own branch matching, so below + // we drop the object variant's own ObjectInstantiationDecorator and tell + // MultiTypeCheckValidator to recognize an un-instantiated JSON-object-shaped array too. + $hasSchemaLevelComposition = isset($json['oneOf']) + || isset($json['anyOf']) + || isset($json['allOf']) + || isset($json['if']); + foreach ($types as $type) { $this->checkType($type, $schema); @@ -980,6 +1198,8 @@ private function createMultiTypeProperty( $schema, $propertySchema, $totalSubCount, + $type, + $hasSchemaLevelComposition, &$collectedTypes, &$typeHints, &$resolvedSubCount, @@ -999,6 +1219,16 @@ private function createMultiTypeProperty( ); } + if ($type === 'object' && $hasSchemaLevelComposition) { + // Without this, $property would pre-instantiate the raw value via this + // variant's decorator before its own composition validator's instanceof + // checks ever run - see the $hasSchemaLevelComposition comment above. + $subProperty->filterDecorators( + static fn(PropertyDecoratorInterface $decorator): bool => + !($decorator instanceof ObjectInstantiationDecorator), + ); + } + if ($subProperty->getDecorators()) { $property->addDecorator(new PropertyTransferDecorator($subProperty)); } @@ -1016,6 +1246,7 @@ private function createMultiTypeProperty( $schemaProcessor, $schema, $propertySchema, + $hasSchemaLevelComposition, ); }); } @@ -1059,6 +1290,9 @@ private function createSubTypeProperty( * * @param string[] $collectedTypes * @param string[] $typeHints + * @param bool $hasSchemaLevelComposition True when this property's own JSON carries + * oneOf/anyOf/allOf/if - see the matching flag + * in createMultiTypeProperty(). * * @throws SchemaException */ @@ -1069,6 +1303,7 @@ private function finalizeMultiTypeProperty( SchemaProcessor $schemaProcessor, Schema $schema, JsonSchema $propertySchema, + bool $hasSchemaLevelComposition = false, ): void { $hasNull = in_array('null', $collectedTypes, true); $nonNullTypes = array_values(array_filter( @@ -1080,8 +1315,12 @@ private function finalizeMultiTypeProperty( && !$property->isRequired(); $property->addValidator( - (new MultiTypeCheckValidator($collectedTypes, $property, $allowImplicitNull)) - ->withJsonPointer($propertySchema->getPointer() . '/type'), + (new MultiTypeCheckValidator( + $collectedTypes, + $property, + $allowImplicitNull, + $hasSchemaLevelComposition, + ))->withJsonPointer($propertySchema->getPointer() . '/type'), 2, ); @@ -1158,21 +1397,33 @@ private function applyModifiers( } foreach ($coveredType->getModifiers() as $modifier) { - $countBefore = count($property->getValidators()); - $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); - - // Tag every validator that was just added by this modifier with its schema - // keyword so FilterProcessor can later classify each validator as - // input-space or output-space relative to a transforming filter. - // This must cover all Draft-registered validators — not only those known - // to interact with filters today — because a custom Draft may register - // any validator factory under any type, and the classification must work - // without enumerating individual keywords. - if ($modifier instanceof AbstractValidatorFactory && ($modifierKey = $modifier->getKey()) !== null) { - foreach (array_slice($property->getValidators(), $countBefore) as $validatorWrapper) { - $validatorWrapper->setSourceKey($modifierKey); - } - } + $this->runModifier($modifier, $schemaProcessor, $schema, $property, $propertySchema); + } + } + } + + /** + * Run a single Draft modifier and tag every validator it just added with its schema keyword so + * FilterProcessor can later classify each validator as input-space or output-space relative to + * a transforming filter. This must cover all Draft-registered validators — not only those known + * to interact with filters today — because a custom Draft may register any validator factory + * under any type, and the classification must work without enumerating individual keywords. + * + * @throws SchemaException + */ + private function runModifier( + ModifierInterface $modifier, + SchemaProcessor $schemaProcessor, + Schema $schema, + PropertyInterface $property, + JsonSchema $propertySchema, + ): void { + $countBefore = count($property->getValidators()); + $modifier->modify($schemaProcessor, $schema, $property, $propertySchema); + + if ($modifier instanceof AbstractValidatorFactory && ($modifierKey = $modifier->getKey()) !== null) { + foreach (array_slice($property->getValidators(), $countBefore) as $validatorWrapper) { + $validatorWrapper->setSourceKey($modifierKey); } } } diff --git a/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php b/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php index bbdf21c0..7d013e25 100644 --- a/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/AdditionalPropertiesAccessorPostProcessor.php @@ -6,7 +6,6 @@ use PHPModelGenerator\Accessor\AdditionalPropertiesAccessor; use PHPModelGenerator\Accessor\ImmutableAdditionalPropertiesAccessor; -use PHPModelGenerator\Attributes\JsonPointer; use PHPModelGenerator\Exception\FileSystemException; use PHPModelGenerator\Exception\Object\MinPropertiesException; use PHPModelGenerator\Exception\Object\RegularPropertyAsAdditionalPropertyException; @@ -21,6 +20,7 @@ use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver; use PHPModelGenerator\SchemaProcessor\PostProcessor\Internal\AdditionalPropertiesPostProcessor; use PHPModelGenerator\SchemaProcessor\PostProcessor\Internal\SerializationPostProcessor; +use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil; use PHPModelGenerator\Utils\RenderHelper; use ReflectionClass; @@ -87,6 +87,7 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu ->setDefaultValue('null', true) ->setInternal(true), ); + $schema->addAccessorCacheProperty('_additionalPropertiesAccessor'); $this->addAccessorMethod($schema, $generatorConfiguration, $hasCompanion); @@ -163,7 +164,7 @@ private function addSetAdditionalPropertyMethod( $objectPropertyPointers = array_combine( array_map(static fn(PropertyInterface $property): string => $property->getName(), $nonInternalProperties), array_map( - static fn(PropertyInterface $property): string => self::resolvePrimaryJsonPointer($property), + static fn(PropertyInterface $property): string => JsonSchemaUtil::resolvePrimaryJsonPointer($property), $nonInternalProperties, ), ); @@ -190,31 +191,6 @@ private function addSetAdditionalPropertyMethod( ); } - /** - * Resolve the pointer to report when a property name collides with a regular property. - * - * A property merged from multiple composition branches (e.g. declared in both the root - * properties block and an allOf branch) carries one #[JsonPointer] attribute per defining - * location, synthesized by PropertyAttributeSynthesizer. Reading that attribute data ties - * this pointer to the same single source of truth used for the generated #[JsonPointer] - * attributes, instead of independently recomputing it from - * $property->getJsonSchema()->getPointer() — a second computation that would silently - * diverge from the attribute data if PropertyMerger's choice of JsonSchema ever changes. - * - * RegularPropertyAsAdditionalPropertyException carries a single pointer, so when a property - * has multiple declaration sites only the first (root-preferred) one is reported — knowing - * any one true location is sufficient to explain the name collision. - */ - private static function resolvePrimaryJsonPointer(PropertyInterface $property): string - { - foreach ($property->getAttributes() as $attribute) { - if ($attribute->getFqcn() === JsonPointer::class) { - return (string) $attribute->getArguments()[0]; - } - } - - return $property->getJsonSchema()->getPointer(); - } /** * @throws SchemaException diff --git a/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php index 373a3ad4..fb4eb3a1 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/AdditionalPropertiesPostProcessor.php @@ -65,6 +65,7 @@ public function addAdditionalPropertiesCollectionProperty(Schema $schema): void } $schema->addProperty($additionalPropertiesCollectionProperty); + $schema->addRollbackProperty('_additionalProperties'); $json = $schema->getJsonSchema()->getJson(); if (!isset($json['additionalProperties']) || $json['additionalProperties'] === true) { @@ -101,7 +102,7 @@ public function __construct(Schema $schema) ), [ 'patternProperties' => $patternProperties - ? RenderHelper::varExportArray($patternProperties) + ? RenderHelper::varExportPcrePatterns($patternProperties) : null, 'additionalProperties' => RenderHelper::varExportArray( array_keys($schema->getJsonSchema()->getJson()['properties'] ?? []), diff --git a/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php index f184beab..73596b57 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/CompositionValidationPostProcessor.php @@ -17,14 +17,8 @@ use PHPModelGenerator\Utils\RenderHelper; /** - * Class CompositionValidationPostProcessor - * - * The CompositionValidationPostProcessor adds methods to models which require composition validations on object level - * to validate the compositions. - * - * Additionally extends setter methods to also validate compositions if the updated property is part of a composition - * - * @package PHPModelGenerator\SchemaProcessor\PostProcessor\Internal + * Adds methods to models that require composition validations at object level, and extends setter + * methods to re-run composition validation when an updated property is part of a composition. */ class CompositionValidationPostProcessor extends PostProcessor { @@ -41,7 +35,18 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu $this->addValidationMethods($schema, $generatorConfiguration, $compositionValidatorKeys); // if the generator is immutable no validation on value updates are required - if ($generatorConfiguration->isImmutable() || empty($validatorPropertyMap)) { + if ($generatorConfiguration->isImmutable()) { + return; + } + + // The composition template caches each branch's outcome in _propertyValidationState for + // every mutable base composition — even one whose branches declare no properties (e.g. + // allOf: [{minProperties: 3}]) and thus produce an empty map. Declare the field whenever + // the model is mutable and carries a composition so those writes never create a dynamic + // property, which PHP 8.4 deprecates. + $this->addPropertyValidationStateField($schema, $validatorPropertyMap); + + if (empty($validatorPropertyMap)) { return; } @@ -62,42 +67,91 @@ private function generateValidatorPropertyMap(Schema $schema): array continue; } + $dependsOnUndeclaredKeys = false; + foreach ($validator->getComposedProperties() as $composedProperty) { + // A branch keyword decided by the shape or count of the instance's keys + // (additionalProperties, patternProperties, min/maxProperties, ...) can flip its + // outcome when a key the branch does not declare changes. No declared-name list + // describes which mutations affect it, so every setter must re-run the whole + // composition — see the mapping to all schema properties below. + if ($composedProperty->branchEvaluationDependsOnUndeclaredKeys()) { + $dependsOnUndeclaredKeys = true; + } + + // A schema-level composition branch usually has a nested schema at this point + // (inheritPropertyType() forces branches to adopt the parent's object type, so + // PropertyFactory routes them through createObjectProperty()), but not always: a + // self-referencing or mutually-recursive $ref branch can resolve to a placeholder + // with no nested schema of its own. Skip mapping declared properties for such a + // branch — branchEvaluationDependsOnUndeclaredKeys() above already ensures a + // key-sensitive branch still gets full setter coverage regardless. if ($composedProperty->getNestedSchema() === null) { continue; } foreach ($composedProperty->getNestedSchema()->getProperties() as $property) { - if (!isset($validatorPropertyMap[$property->getName()])) { - $validatorPropertyMap[$property->getName()] = []; - } + $this->mapPropertyToValidator($validatorPropertyMap, $property->getName(), $validatorIndex); + } + } + + if (!$dependsOnUndeclaredKeys) { + continue; + } - $validatorPropertyMap[$property->getName()][] = $validatorIndex; + // A key-sensitive branch reacts to keys no branch declares, so mapping the validator + // only to branch-declared names would leave a plain setter for a sibling property + // (declared on the outer schema but on no branch) without a revalidation call — that + // setter could then commit a value the branch rejects. Map the validator to every + // declared property so all of them revalidate the composition. + foreach ($schema->getProperties() as $property) { + if (!$property->isInternal()) { + $this->mapPropertyToValidator($validatorPropertyMap, $property->getName(), $validatorIndex); } } } - if (!empty($validatorPropertyMap)) { - $schema->addProperty( - (new Property( - 'propertyValidationState', - new PropertyType('array'), - new JsonSchema(__FILE__, []), - 'Track the internal validation state of composed validations', - )) - ->setInternal(true) - ->setDefaultValue( - array_fill_keys( - array_unique( - array_merge(...array_values($validatorPropertyMap)), - ), - [], - ) - ), - ); + return $validatorPropertyMap; + } + + /** + * Append $validatorIndex to the property's entry in the map, creating the entry when absent. + * Duplicate indexes are tolerated: every consumer dedups (the setter hook via array_unique, + * the field default via array_unique). + */ + private function mapPropertyToValidator( + array &$validatorPropertyMap, + string $propertyName, + int $validatorIndex, + ): void { + if (!isset($validatorPropertyMap[$propertyName])) { + $validatorPropertyMap[$propertyName] = []; } - return $validatorPropertyMap; + $validatorPropertyMap[$propertyName][] = $validatorIndex; + } + + /** + * Declare the _propertyValidationState cache field. The default seeds one empty slot per + * composition validator index that appears in the map; the composition template auto-vivifies + * any further index it writes, so an empty default (empty map) is valid too. + */ + private function addPropertyValidationStateField(Schema $schema, array $validatorPropertyMap): void + { + $seededValidatorIndexes = $validatorPropertyMap === [] + ? [] + : array_unique(array_merge(...array_values($validatorPropertyMap))); + + $schema->addProperty( + (new Property( + 'propertyValidationState', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + 'Track the internal validation state of composed validations', + )) + ->setInternal(true) + ->setDefaultValue(array_fill_keys($seededValidatorIndexes, [])), + ); } /** diff --git a/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php index ddb9e697..0a554ed1 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/PatternPropertiesPostProcessor.php @@ -90,6 +90,7 @@ private function addPatternPropertiesCollectionProperty( ->setDefaultValue(array_fill_keys($patternHashes, [])) ->setInternal(true), ); + $schema->addRollbackProperty('_patternProperties'); } /** diff --git a/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php index 356e1e7a..e753d95a 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/SerializationPostProcessor.php @@ -14,8 +14,12 @@ use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator; +use PHPModelGenerator\Model\Validator\ArrayItemValidator; +use PHPModelGenerator\Model\Validator\ArrayTupleValidator; use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedItemsValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver; use PHPModelGenerator\SchemaProcessor\Hook\SerializationHookInterface; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; @@ -49,6 +53,10 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu if (isset($json['additionalProperties']) && $json['additionalProperties'] !== false) { $this->addAdditionalPropertiesTransformingFilterSerializer($schema, $generatorConfiguration); } + + if (isset($json['unevaluatedProperties']) && $json['unevaluatedProperties'] !== false) { + $this->addUnevaluatedPropertiesTransformingFilterSerializer($schema, $generatorConfiguration); + } } /** @@ -60,8 +68,31 @@ private function addSerializeFunctionsForTransformingFilters( GeneratorConfiguration $generatorConfiguration, ): void { foreach ($schema->getProperties() as $property) { - foreach ($property->getValidators() as $validator) { - $validator = $validator->getValidator(); + $arrayItemValidator = null; + $arrayTupleValidator = null; + $unevaluatedItemsValidator = null; + + foreach ($property->getValidators() as $propertyValidator) { + $validator = $propertyValidator->getValidator(); + + // Array items live on a separate property from the array itself (schema-form, + // tuple-form, and unevaluatedItems each keep their own nested/tuple/validation + // property), so a transforming filter declared on an item is otherwise invisible + // to this pass — mirrors the same recursion TransformingFilterOutputTypePostProcessor + // performs for the same reason. Collected here rather than acted on immediately: + // tuple-form items and unevaluatedItems can legitimately coexist on the same + // property (a tuple covering fixed indices, unevaluatedItems covering the + // overflow — the primary intended use of the keyword alongside a tuple), and both + // must be combined into a single generated _serialize{Property}() method — two + // independent addMethod() calls with the same method name would silently + // overwrite each other (Schema::addMethod() is a plain array write). + if ($validator instanceof ArrayItemValidator) { + $arrayItemValidator = $validator; + } elseif ($validator instanceof ArrayTupleValidator) { + $arrayTupleValidator = $validator; + } elseif ($validator instanceof UnevaluatedItemsValidator) { + $unevaluatedItemsValidator = $validator; + } if ( $validator instanceof FilterValidator && @@ -88,6 +119,26 @@ private function addSerializeFunctionsForTransformingFilters( ); } } + + if ($arrayItemValidator !== null) { + // Schema-form items claims every index, which makes a sibling unevaluatedItems + // dead code at the factory level (UnevaluatedItemsValidatorFactory::isDeadCode()) + // — the two never coexist on the same property, so this stays a standalone case. + $this->addArrayItemsTransformingFilterSerializer( + $schema, + $generatorConfiguration, + $property, + $arrayItemValidator->getNestedProperty(), + ); + } elseif ($arrayTupleValidator !== null || $unevaluatedItemsValidator !== null) { + $this->addIndexedItemsTransformingFilterSerializer( + $schema, + $generatorConfiguration, + $property, + $arrayTupleValidator, + $unevaluatedItemsValidator, + ); + } } foreach ($schema->getBaseValidators() as $validator) { @@ -126,6 +177,133 @@ private function addSerializeFunctionsForTransformingFilters( } } + /** + * Returns the first FilterValidator wrapping a transforming filter among the given + * property's own validators, or null when none exists. + */ + private function findTransformingFilterValidator(PropertyInterface $property): ?FilterValidator + { + foreach ($property->getValidators() as $propertyValidator) { + $validator = $propertyValidator->getValidator(); + + if ( + $validator instanceof FilterValidator + && $validator->getFilter() instanceof TransformingFilterInterface + ) { + return $validator; + } + } + + return null; + } + + /** + * Schema-form items: every index shares the same subschema, so a single filter (if any) + * applies uniformly across the whole array. + */ + private function addArrayItemsTransformingFilterSerializer( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $arrayProperty, + PropertyInterface $nestedProperty, + ): void { + $filterValidator = $this->findTransformingFilterValidator($nestedProperty); + if ($filterValidator === null) { + return; + } + + [$serializerClass, $serializerMethod] = $filterValidator->getFilter()->getSerializer(); + + $schema->addMethod( + "_serialize{$arrayProperty->getAttribute()}", + new RenderedMethod( + $schema, + $generatorConfiguration, + join(DIRECTORY_SEPARATOR, ['Serialization', 'ArrayItemsTransformingFilterSerializer.phptpl']), + [ + 'property' => $arrayProperty, + 'serializerClass' => $serializerClass, + 'serializerMethod' => $serializerMethod, + 'serializerOptions' => var_export($filterValidator->getFilterOptions(), true), + ], + ), + ); + } + + /** + * Tuple-form items (static, per-index) and unevaluatedItems (dynamic, runtime-tracked via + * `_evaluatedItemIndices` — unlike tuple indices, which indices it evaluated can't be + * resolved to a static list at generation time) combined into a single generated method, + * since both can legitimately apply to the same property at once. + */ + private function addIndexedItemsTransformingFilterSerializer( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $arrayProperty, + ?ArrayTupleValidator $arrayTupleValidator, + ?UnevaluatedItemsValidator $unevaluatedItemsValidator, + ): void { + $indexSerializers = []; + $staticIndices = []; + + if ($arrayTupleValidator !== null) { + foreach ($arrayTupleValidator->getTupleProperties() as $index => $tupleProperty) { + $staticIndices[] = $index; + + $filterValidator = $this->findTransformingFilterValidator($tupleProperty); + if ($filterValidator === null) { + continue; + } + + [$serializerClass, $serializerMethod] = $filterValidator->getFilter()->getSerializer(); + + $indexSerializers[] = [ + 'index' => $index, + 'serializerClass' => $serializerClass, + 'serializerMethod' => $serializerMethod, + 'serializerOptions' => var_export($filterValidator->getFilterOptions(), true), + ]; + } + } + + $dynamicSerializerClass = null; + $dynamicSerializerMethod = null; + $dynamicSerializerOptions = null; + + if ($unevaluatedItemsValidator !== null) { + $filterValidator = $this->findTransformingFilterValidator( + $unevaluatedItemsValidator->getValidationProperty(), + ); + + if ($filterValidator !== null) { + [$dynamicSerializerClass, $dynamicSerializerMethod] = $filterValidator->getFilter()->getSerializer(); + $dynamicSerializerOptions = var_export($filterValidator->getFilterOptions(), true); + } + } + + if ($indexSerializers === [] && $dynamicSerializerClass === null) { + return; + } + + $schema->addMethod( + "_serialize{$arrayProperty->getAttribute()}", + new RenderedMethod( + $schema, + $generatorConfiguration, + join(DIRECTORY_SEPARATOR, ['Serialization', 'IndexedItemsTransformingFilterSerializer.phptpl']), + [ + 'property' => $arrayProperty, + 'indexSerializers' => $indexSerializers, + 'staticIndices' => $staticIndices, + 'arrayPropertyName' => $arrayProperty->getName(), + 'dynamicSerializerClass' => $dynamicSerializerClass, + 'dynamicSerializerMethod' => $dynamicSerializerMethod, + 'dynamicSerializerOptions' => $dynamicSerializerOptions, + ], + ), + ); + } + private function addSerializationHookMethod(Schema $schema, GeneratorConfiguration $generatorConfiguration): void { $schema->addMethod( @@ -200,6 +378,64 @@ public function addAdditionalPropertiesTransformingFilterSerializer( ); } + /** + * When unevaluated properties have a transforming filter, override + * _serializeUnevaluatedProperties on the model so the filter's serialize() runs before the + * values reach the generic serializer. Without this, transformed values (e.g. DateTime + * instances) reach the generic path unfiltered and drop to empty arrays in the output. + * + * The generic case (no transforming filter) is handled by + * SerializableTrait::_serializeUnevaluatedProperties. + */ + public function addUnevaluatedPropertiesTransformingFilterSerializer( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + ): void { + $validationProperty = null; + foreach ($schema->getPostCompositionValidators() as $validator) { + if (is_a($validator, UnevaluatedPropertiesValidator::class)) { + $validationProperty = $validator->getValidationProperty(); + break; + } + } + + $transformingFilterValidator = null; + $serializerClass = null; + $serializerMethod = null; + + if ($validationProperty) { + foreach ($validationProperty->getValidators() as $validator) { + $validator = $validator->getValidator(); + + if ( + $validator instanceof FilterValidator && + $validator->getFilter() instanceof TransformingFilterInterface + ) { + $transformingFilterValidator = $validator; + [$serializerClass, $serializerMethod] = $validator->getFilter()->getSerializer(); + } + } + } + + if (!$transformingFilterValidator) { + return; + } + + $schema->addMethod( + '_serializeUnevaluatedProperties', + new RenderedMethod( + $schema, + $generatorConfiguration, + 'Serialization/UnevaluatedPropertiesSerializer.phptpl', + [ + 'serializerClass' => $serializerClass, + 'serializerMethod' => $serializerMethod, + 'serializerOptions' => var_export($transformingFilterValidator->getFilterOptions(), true), + ], + ) + ); + } + private function addWriteOnlyExclusion( Schema $schema, GeneratorConfiguration $generatorConfiguration, diff --git a/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php index 29cd69c0..232369ea 100644 --- a/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/Internal/TransformingFilterOutputTypePostProcessor.php @@ -10,8 +10,12 @@ use PHPModelGenerator\Model\Property\PropertyType; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator; +use PHPModelGenerator\Model\Validator\ArrayItemValidator; +use PHPModelGenerator\Model\Validator\ArrayTupleValidator; use PHPModelGenerator\Model\Validator\FilterValidator; use PHPModelGenerator\Model\Validator\PatternPropertiesValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedItemsValidator; +use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator; use PHPModelGenerator\Utils\TypeCheck; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; use PHPModelGenerator\Utils\FilterReflection; @@ -31,6 +35,11 @@ * FilterProcessor does NOT call it because the TypeCheckValidator may not yet exist at * filter-processing time (composition case where the type comes from a sibling allOf branch). * + * Array items (schema-form `items`, tuple-form `items`, and `unevaluatedItems`) are widened the + * same way object properties are: each lives on its own validation property reachable only by + * recursing into ArrayItemValidator/ArrayTupleValidator/UnevaluatedItemsValidator, mirroring the + * recursion EnumPostProcessor already performs for the same reason. + * * Output type formula: * accepted = filter callable's first-parameter types ([] = accepts all) * bypass_names = base_names − non-null accepted ([] when accepted is empty) @@ -59,26 +68,78 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu $this->processProperty($validator->getValidationProperty(), $schema, $generatorConfiguration); } } + + // unevaluatedProperties runs in the post-composition phase, not as a base validator, + // so its validation property must be picked up separately. Without this, an already- + // transformed value fed to the accessor's set() shim would fail the subschema's + // type-check because the check would only accept the raw input type. + foreach ($schema->getPostCompositionValidators() as $validator) { + if ($validator instanceof UnevaluatedPropertiesValidator) { + $this->processProperty($validator->getValidationProperty(), $schema, $generatorConfiguration); + } + } } /** + * @param array $seenPropertyIds Object ids of properties already visited in the + * current recursive walk, threaded by reference so + * a `$ref` cycle can be detected across calls. + * * @throws ReflectionException */ private function processProperty( PropertyInterface $property, Schema $schema, GeneratorConfiguration $generatorConfiguration, + array &$seenPropertyIds = [], ): void { - // Find the FilterValidator whose filter implements TransformingFilterInterface. + // A `$ref` cycle (e.g. `{type: array, items: {$ref: "#"}}`) resolves to the identical + // property instance on every level of the cycle, so recursing into array-item + // validators below would otherwise revisit it indefinitely. Required, not defensive — + // confirmed via MultiTypePropertyTest::testValidRecursiveMultiType's + // RecursiveMultiTypeProperty.json fixture, which stack-overflows without this guard. + $propertyId = spl_object_id($property); + if (isset($seenPropertyIds[$propertyId])) { + return; + } + $seenPropertyIds[$propertyId] = true; + + // Find the FilterValidator whose filter implements TransformingFilterInterface, and + // recurse into any array-item validator's own validation property along the way — + // array items live on a separate property from the array itself (the same reason + // AdditionalProperties/PatternProperties/UnevaluatedProperties validation properties + // are picked up separately below), so they are otherwise invisible to this pass. + // Mirrors the recursion EnumPostProcessor already performs via + // ArrayItemValidator::getNestedProperty(). $transformingFilterValidator = null; foreach ($property->getValidators() as $propertyValidator) { $validator = $propertyValidator->getValidator(); + + if ($validator instanceof ArrayItemValidator) { + $this->processProperty( + $validator->getNestedProperty(), + $schema, + $generatorConfiguration, + $seenPropertyIds, + ); + } elseif ($validator instanceof ArrayTupleValidator) { + foreach ($validator->getTupleProperties() as $tupleProperty) { + $this->processProperty($tupleProperty, $schema, $generatorConfiguration, $seenPropertyIds); + } + } elseif ($validator instanceof UnevaluatedItemsValidator) { + $this->processProperty( + $validator->getValidationProperty(), + $schema, + $generatorConfiguration, + $seenPropertyIds, + ); + } + if ( $validator instanceof FilterValidator && $validator->getFilter() instanceof TransformingFilterInterface ) { $transformingFilterValidator = $validator; - break; } } diff --git a/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php new file mode 100644 index 00000000..81647eab --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php @@ -0,0 +1,767 @@ +_0`, `_1`, ... slot keys. Tracked on the + * instance so the recursive activation helpers do not need to thread a by-reference + * parameter through every call. + */ + private int $slotKeyCounter = 0; + + /** + * Object ids of composition validators already activated in the current property + * walk. Required (not defensive) — a self-referencing schema such as + * `{type: array, allOf: [{$ref: "#"}], unevaluatedItems: false}` produces a composition + * validator whose composed property's wrapped property carries the same composition + * validator instance. Without this short-circuit, `activateArrayComposition()` would + * recurse indefinitely. + * + * @var array + */ + private array $activatedCompositions = []; + + public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void + { + $seen = []; + + if (!$this->needsActivation($schema, $seen)) { + return; + } + + $this->assertNoUnsupportedDownPropagation($schema); + + // A schema with a non-false `unevaluatedProperties` validator tracks each key the + // validator successfully evaluates in `_evaluatedPropertyKeys`. That field is read + // by `_getEvaluatedProperties()` on nested branch classes so an enclosing + // `unevaluatedProperties` sees those keys as evaluated. + $this->addEvaluatedPropertyKeysField($schema); + + // The nested branch schemas may already be queued — adding the method here, before any + // render() runs, ensures every branch class carries _getEvaluatedProperties() regardless + // of whether the outer or inner schema was processed first. RenderQueue::execute runs + // process() over every job before render() begins. + $this->addGetEvaluatedPropertiesToNestedBranchSchemas($schema); + + // The trait carries collectUnevaluatedKeys(), which every generated unevaluatedProperties + // validator calls regardless of whether the schema also has composition validators. It + // must therefore be attached on every activation-triggering schema, not only on ones that + // declare allOf/anyOf/oneOf/if-then-else. + $schema->addTrait(CompositionEvaluationTrait::class); + + $this->activateSchemaLevelTracking($schema); + $this->activateArrayPropertyTracking($schema); + } + + /** + * Rejects a composition branch's `unevaluatedProperties`/`unevaluatedItems` when it cannot + * possibly be evaluated correctly: the branch renders as its own, separately-constructed + * class with no visibility into the enclosing schema's own declarations or a sibling + * branch's claims (only the reverse direction - branch claims propagating up - is + * implemented). Silently computing a wrong "unevaluated" set would either reject valid + * input or accept invalid input, so this fails loudly at generation time instead - see + * `.claude/topics/branch-unevaluated-down-propagation/` for the full design discussion, + * including why a general fix was rejected as either unsound (anyOf/oneOf: two sibling + * branches each gating on the other's claims can have no unique consistent answer at all, + * not just an expensive one to compute) or out of proportion to a pattern of unknown + * real-world frequency (allOf, where a fix is sound but non-trivial). + * + * `true` is exempt: it never rejects anything regardless of what the branch believes is + * evaluated, so the gap has no observable effect on validation outcomes. `not` is exempt + * because it already blocks annotations from crossing its boundary in both directions by + * design (see `not.rst`) - nothing about its own outcome depends on outside annotations. + */ + private function assertNoUnsupportedDownPropagation(Schema $schema): void + { + foreach ($schema->getBaseValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator) { + $this->checkBranchesForUnsupportedDownPropagation( + $validator, + $schema->getJsonSchema()->getJson(), + $schema->getClassName(), + ); + } + } + + foreach ($schema->getProperties() as $schemaProperty) { + foreach ($schemaProperty->getOrderedValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator) { + $this->checkBranchesForUnsupportedDownPropagation( + $validator, + $schemaProperty->getJsonSchema()->getJson(), + $schemaProperty->getName(), + ); + } + } + } + } + + /** + * @param array $enclosingJson The JSON of the schema/property the composition + * keyword itself sits on - not any one branch's. + */ + private function checkBranchesForUnsupportedDownPropagation( + AbstractComposedPropertyValidator $validator, + array $enclosingJson, + string $contextName, + ): void { + if (is_a($validator->getCompositionProcessor(), NotValidatorFactory::class, true)) { + return; + } + + $composedProperties = $validator->getComposedProperties(); + $branchJsonList = []; + $seen = []; + + foreach ($composedProperties as $index => $composedProperty) { + $branchJsonList[$index] = $this->collectBranchOwnJson($composedProperty, $seen); + } + + $enclosingHasObjectClaims = $this->declaresObjectClaimingKeyword($enclosingJson); + $enclosingHasArrayClaims = $this->declaresArrayClaimingKeyword($enclosingJson); + + foreach ($branchJsonList as $index => $branchJson) { + $somethingElseHasObjectClaims = $enclosingHasObjectClaims; + $somethingElseHasArrayClaims = $enclosingHasArrayClaims; + + foreach ($branchJsonList as $siblingIndex => $siblingJson) { + if ($siblingIndex === $index) { + continue; + } + + $somethingElseHasObjectClaims = + $somethingElseHasObjectClaims || $this->declaresObjectClaimingKeyword($siblingJson); + $somethingElseHasArrayClaims = + $somethingElseHasArrayClaims || $this->declaresArrayClaimingKeyword($siblingJson); + } + + $this->assertKeywordNotUnsupported( + $branchJson, + 'unevaluatedProperties', + $somethingElseHasObjectClaims, + $composedProperties[$index]->getBranchSchema(), + $contextName, + $index, + ); + $this->assertKeywordNotUnsupported( + $branchJson, + 'unevaluatedItems', + $somethingElseHasArrayClaims, + $composedProperties[$index]->getBranchSchema(), + $contextName, + $index, + ); + } + } + + /** + * A branch's own JSON, or - when it has no nested `Schema` of its own (true for every + * array-typed branch, and for a self/mutually-referencing `$ref` branch) - the JSON of + * whatever composition is nested directly inside it instead, recursing the same way + * `propertyHasBranchUnevaluatedItems()` does. `$seen` (keyed on file+pointer, not object + * identity - `getOrderedValidators()` returns fresh clones on every call) guards against the + * same self-referencing-schema cycle item 15 fixed there. + * + * @param array $seen + */ + private function collectBranchOwnJson(CompositionPropertyDecorator $composedProperty, array &$seen): array + { + $branchJson = $composedProperty->getBranchSchema()->getJson(); + + if ($composedProperty->getNestedSchema() !== null) { + return $branchJson; + } + + $wrappedProperty = $composedProperty->getWrappedProperty(); + $propertyKey = $wrappedProperty->getJsonSchema()->getFile() + . '#' . $wrappedProperty->getJsonSchema()->getPointer(); + + if (array_key_exists($propertyKey, $seen)) { + return $branchJson; + } + + $seen[$propertyKey] = true; + + foreach ($wrappedProperty->getOrderedValidators() as $nestedValidator) { + if (!$nestedValidator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($nestedValidator->getComposedProperties() as $nestedComposedProperty) { + $branchJson = array_merge($branchJson, $this->collectBranchOwnJson($nestedComposedProperty, $seen)); + } + } + + return $branchJson; + } + + private function declaresObjectClaimingKeyword(array $json): bool + { + return array_key_exists('properties', $json) + || array_key_exists('patternProperties', $json) + || array_key_exists('additionalProperties', $json); + } + + private function declaresArrayClaimingKeyword(array $json): bool + { + return array_key_exists('items', $json) + || array_key_exists('additionalItems', $json) + || array_key_exists('contains', $json); + } + + private function assertKeywordNotUnsupported( + array $branchJson, + string $keyword, + bool $somethingElseClaims, + JsonSchema $branchSchema, + string $contextName, + int $branchIndex, + ): void { + if ( + !array_key_exists($keyword, $branchJson) + || $branchJson[$keyword] === true + || !$somethingElseClaims + ) { + return; + } + + throw new UnsupportedSchemaFeatureException( + sprintf( + "Branch #%d of the composition for '%s' declares '%s', which cannot yet see " + . 'property names or indices declared by the enclosing schema or a sibling ' + . "branch - remove '%s' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it', + $branchIndex + 1, + $contextName, + $keyword, + $keyword, + ), + $branchSchema, + ); + } + + /** + * Enables evaluation tracking on every schema-level composition validator and declares + * the `_compositionEvaluations` cache field if any composition was activated. + * + * Each activated branch writes its success bit and the property names it claimed into + * `_compositionEvaluations[$validatorIndex][$componentIndex]`. The unevaluatedProperties + * validator reads those slots via the trait's `collectUnevaluatedKeys`. Schemas without + * any composition skip the field declaration — the trait's reads use `?? []`, so the + * absence is safe. + */ + private function activateSchemaLevelTracking(Schema $schema): void + { + $activated = false; + foreach ($schema->getBaseValidators() as $baseValidator) { + if ($baseValidator instanceof AbstractComposedPropertyValidator) { + $baseValidator->enableEvaluationTracking(); + $activated = true; + } + } + + if (!$activated) { + return; + } + + $schema->addProperty( + (new Property( + 'compositionEvaluations', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + } + + /** + * Walks array properties carrying `unevaluatedItems`, activates evaluation tracking on + * any composition validator attached to such a property, and declares the two transient + * array-side fields when their respective write sites are reachable: + * - `_compositionAnnotated` — only when at least one property-level composition was + * activated. The composition template wholesale-overwrites the property's slot at + * the end of every chain run. + * - `_evaluatedItemIndices` — whenever at least one array property carries + * `unevaluatedItems`. The unevaluatedItems template writes a `[propertyName => + * [index => true]]` entry after each successful per-index validation. + */ + private function activateArrayPropertyTracking(Schema $schema): void + { + $evaluatedItemIndicesNeeded = false; + $compositionActivated = false; + + foreach ($schema->getProperties() as $schemaProperty) { + // The unevaluatedItems factory is registered on the `array` type only, so a + // property whose type cannot hold an array never produces an unevaluatedItems + // validator at runtime. Activating compositions in that case would write + // `_compositionAnnotated` slots that nobody reads — wasted state. + $typeNames = $schemaProperty->getType()?->getNames() ?? []; + if ($typeNames !== [] && !in_array('array', $typeNames, true)) { + continue; + } + + $propertyJson = $schemaProperty->getJsonSchema()->getJson(); + $hasDirectUnevaluatedItems = array_key_exists('unevaluatedItems', $propertyJson); + // A composition branch may carry unevaluatedItems even when the array property itself + // does not (e.g. allOf: [{unevaluatedItems: {schema}}]). The branch's validator still + // needs the _evaluatedItemIndices field declared on the class. + $hasBranchUnevaluatedItems = $this->propertyHasBranchUnevaluatedItems($schemaProperty); + + if (!$hasDirectUnevaluatedItems && !$hasBranchUnevaluatedItems) { + continue; + } + + $evaluatedItemIndicesNeeded = true; + + // Composition activation and sibling crediting are only relevant when the property + // itself carries an unevaluatedItems check that reads those annotations. A branch-only + // unevaluatedItems validates within its own branch and needs no outer crediting. + if (!$hasDirectUnevaluatedItems) { + continue; + } + + // A direct-sibling `contains` credits its matched indices to the property's evaluated + // set so the sibling unevaluatedItems check sees them as evaluated. + $this->enableDirectSiblingContainsTracking($schemaProperty); + + $this->slotKeyCounter = 0; + $this->activatedCompositions = []; + + foreach ($schemaProperty->getOrderedValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator) { + $this->activateArrayComposition($validator, $schemaProperty); + $compositionActivated = true; + } + } + } + + if ($compositionActivated) { + // Transient bridge between property-level array compositions and a sibling + // unevaluatedItems validator. Wholesale-overwritten per chain run; never registered + // with the rollback registry, never snapshotted across setter calls. + $schema->addProperty( + (new Property( + 'compositionAnnotated', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + } + + if ($evaluatedItemIndicesNeeded) { + // Per-array-property index map of indices the property's UnevaluatedItems validator + // successfully evaluated, shaped as [propertyName => [index => true]]. Inner and outer + // UnevaluatedItems validators share the same instance field — there are no nested + // array classes to cross. Transient: every chain run overwrites it; never registered + // with the rollback registry, never snapshotted across setter calls. + $schema->addProperty( + (new Property( + 'evaluatedItemIndices', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + } + } + + /** + * True when any composition validator directly on the property carries a branch declaring + * `unevaluatedItems` — either directly in the branch's own JSON, or nested one or more + * levels deeper inside a composition the branch itself carries. Such a branch's validator + * writes and reads `_evaluatedItemIndices`, so the field must be declared even though the + * array property itself has no unevaluatedItems. + * + * An array-typed branch never gets its own nested `Schema` (unlike an object-typed branch, + * which always routes through `processSchema()`), so a further composition keyword nested + * inside the branch's own JSON — e.g. the branch is itself `{oneOf: [{unevaluatedItems: + * ...}]}` — has no `Schema` object for `getNestedSchema()` to recurse into. Recursing + * through the branch's wrapped property's own validators instead mirrors how + * `activateValidatorsInBranch()` already walks this exact structure for the activation step + * itself. + * + * A self-referencing array composition (e.g. `{type: array, allOf: [{$ref: "#"}]}`) makes + * the wrapped property this recurses into the same property again, indefinitely — required + * cycle protection, not defensive, the same class of cycle `needsActivation()` guards + * against for schemas. Keyed on file+pointer rather than object identity because + * `getOrderedValidators()` returns fresh clones on every call (see + * `activateValidatorsInBranch()`'s own comment on this), so the wrapped `PropertyInterface` + * instance is not guaranteed stable across recursive calls even for the same schema location. + */ + private function propertyHasBranchUnevaluatedItems(PropertyInterface $property, array &$seen = []): bool + { + $propertyKey = $property->getJsonSchema()->getFile() . '#' . $property->getJsonSchema()->getPointer(); + + if (array_key_exists($propertyKey, $seen)) { + return $seen[$propertyKey]; + } + + $seen[$propertyKey] = false; + + foreach ($property->getOrderedValidators() as $validator) { + if (!$validator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($validator->getComposedProperties() as $composedProperty) { + if (array_key_exists('unevaluatedItems', $composedProperty->getBranchSchema()->getJson())) { + return $seen[$propertyKey] = true; + } + + if ( + $composedProperty->getNestedSchema() === null + && $this->propertyHasBranchUnevaluatedItems($composedProperty->getWrappedProperty(), $seen) + ) { + return $seen[$propertyKey] = true; + } + } + } + + return false; + } + + /** + * Enable direct-sibling index crediting on any `contains` validator sitting directly on the + * property, so a sibling unevaluatedItems check sees the matched indices as evaluated. + */ + private function enableDirectSiblingContainsTracking(PropertyInterface $property): void + { + foreach ($property->getOrderedValidators() as $validator) { + if ($validator instanceof ArrayContainsValidator) { + $validator->setTrackEvaluatedItems(); + } + } + } + + /** + * Enable evaluation tracking on a property-level composition validator on an array + * property and recurse into its branches. The slot-key counter on the post-processor + * instance is shared across the recursion so an outer composition and a nested one + * inside one of its branches receive monotonically increasing keys (e.g. `tags_0` for + * the outer allOf, `tags_1` for an inner oneOf). Within each branch, any ArrayContains + * validator gets its trackBranchMatches flag set so the contains template exports per- + * index match results to the surrounding composition body. + * + * The instance-level `$activatedCompositions` set guards against `$ref`-induced cycles + * in the composition graph; activating the same validator twice would double-emit slot + * writes and confuse the rebuild. + */ + private function activateArrayComposition( + AbstractComposedPropertyValidator $compositionValidator, + PropertyInterface $parentProperty, + ): void { + $compositionId = spl_object_id($compositionValidator); + if (isset($this->activatedCompositions[$compositionId])) { + return; + } + $this->activatedCompositions[$compositionId] = true; + + $compositionValidator->enableEvaluationTracking(); + $compositionValidator->setSlotKey($parentProperty->getName() . '_' . $this->slotKeyCounter++); + + foreach ($compositionValidator->getComposedProperties() as $composedProperty) { + $this->activateValidatorsInBranch($composedProperty, $parentProperty); + } + } + + private function activateValidatorsInBranch( + CompositionPropertyDecorator $composedProperty, + PropertyInterface $parentProperty, + ): void { + // Iterate the wrapped property's validators directly. The decorator's + // getOrderedValidators() returns fresh withProperty() clones every call, so a + // mutation on those clones would be invisible at render time. + foreach ($composedProperty->getWrappedProperty()->getOrderedValidators() as $validator) { + if ($validator instanceof AbstractComposedPropertyValidator) { + $this->activateArrayComposition($validator, $parentProperty); + continue; + } + + if ($validator instanceof ArrayContainsValidator) { + $validator->setTrackBranchMatches(true); + } + } + } + + /** + * Adds the `_evaluatedPropertyKeys` collection field to a schema that carries the + * schema-form `UnevaluatedPropertiesValidator`. Nested branch schemas reach this code + * through their own `process()` call (RenderQueue calls each job's processors), so no + * recursion across branches is required here. + */ + private function addEvaluatedPropertyKeysField(Schema $schema): void + { + foreach ($schema->getPostCompositionValidators() as $postCompositionValidator) { + if ($postCompositionValidator instanceof UnevaluatedPropertiesValidator) { + $schema->addProperty( + (new Property( + 'evaluatedPropertyKeys', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + )) + ->setInternal(true) + ->setDefaultValue([]), + ); + + return; + } + } + } + + /** + * For each composition branch that produces a nested object class, adds an internal + * `_getEvaluatedProperties()` method the enclosing schema's unevaluatedProperties + * validator queries to learn which keys the nested class evaluated. + * + * The method name uses an underscore prefix so it cannot collide with a user-declared + * schema property called `evaluatedProperties` (whose generated getter would be + * `getEvaluatedProperties()` without the underscore) and so its internal-only role is + * visible at the call site. + */ + private function addGetEvaluatedPropertiesToNestedBranchSchemas(Schema $schema): void + { + foreach ($schema->getBaseValidators() as $baseValidator) { + if (!$baseValidator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($baseValidator->getComposedProperties() as $composedProperty) { + $nestedSchema = $composedProperty->getNestedSchema(); + + if ($nestedSchema === null || $nestedSchema->hasMethod('_getEvaluatedProperties')) { + continue; + } + + $nestedSchema->addMethod( + '_getEvaluatedProperties', + $this->buildGetEvaluatedPropertiesMethod($nestedSchema), + ); + } + } + } + + /** + * Builds a MethodInterface that emits `_getEvaluatedProperties()` for the given nested + * schema. The method returns the union of declared property names present in the instance's + * raw model data, the keys matched by the schema's own `patternProperties`, and the keys + * recorded in `_evaluatedPropertyKeys` (populated by the nested schema's own + * unevaluatedProperties validator). Together these are the keys the branch evaluated, so an + * enclosing unevaluatedProperties check must credit them. + */ + private function buildGetEvaluatedPropertiesMethod(Schema $nestedSchema): MethodInterface + { + return new class ($nestedSchema) implements MethodInterface { + public function __construct(private readonly Schema $nestedSchema) + { + } + + public function getCode(): string + { + $declaredPropertyNames = array_values(array_map( + static fn(PropertyInterface $property): string => $property->getName(), + array_filter( + $this->nestedSchema->getProperties(), + static fn(PropertyInterface $property): bool => !$property->isInternal(), + ), + )); + + return sprintf( + ' + #[Internal] + public function _getEvaluatedProperties(): array + { + $evaluated = []; + foreach (%s as $propName) { + if (array_key_exists($propName, $this->_rawModelDataInput)) { + $evaluated[$propName] = true; + } + } + // Keys matched by this schema\'s own patternProperties (with passing + // values) are evaluated too, so an enclosing schema must see them. Only the + // keys matter (the result is array_keys($evaluated)), so union the maps. + if (property_exists($this, "_patternProperties")) { + foreach ($this->_patternProperties as $patternMatches) { + $evaluated += $patternMatches; + } + } + // Keys evaluated by this schema\'s own unevaluatedProperties: {schema} + // validator are tracked so an enclosing schema can see them. + if (property_exists($this, "_evaluatedPropertyKeys")) { + $evaluated += $this->_evaluatedPropertyKeys; + } + return array_keys($evaluated); + }', + var_export($declaredPropertyNames, true), + ); + } + }; + } + + /** + * Returns true when the schema or any reachable subschema contains unevaluatedProperties + * or unevaluatedItems, meaning composition validators on this schema must emit the + * _compositionEvaluations cache. + * + * Uses a $seen map keyed by file+pointer to break reference cycles. + */ + private function needsActivation(Schema $schema, array &$seen): bool + { + $schemaKey = $schema->getJsonSchema()->getFile() . '#' . $schema->getJsonSchema()->getPointer(); + + if (array_key_exists($schemaKey, $seen)) { + return $seen[$schemaKey]; + } + + // Mark false initially so cycles terminate without infinite recursion. + $seen[$schemaKey] = false; + + $json = $schema->getJsonSchema()->getJson(); + + if (array_key_exists('unevaluatedProperties', $json) || array_key_exists('unevaluatedItems', $json)) { + return $seen[$schemaKey] = true; + } + + // Check each schema-level composition: both the branch-level JSON and any nested schema. + if ($this->compositionValidatorsNeedActivation($schema->getBaseValidators(), $seen)) { + return $seen[$schemaKey] = true; + } + + // Check each property: its own JSON, its nested schema, and any composition sitting + // directly on it. Array properties never carry a nested schema — their unevaluatedItems + // lives in the property's own JSON or in a composition branch on the property (e.g. + // {tags: {type: array, allOf: [{unevaluatedItems: {schema}}]}}), so both must be checked. + foreach ($schema->getProperties() as $schemaProperty) { + $propertyJson = $schemaProperty->getJsonSchema()->getJson(); + + if ( + array_key_exists('unevaluatedProperties', $propertyJson) + || array_key_exists('unevaluatedItems', $propertyJson) + ) { + return $seen[$schemaKey] = true; + } + + $nestedSchema = $schemaProperty->getNestedSchema(); + + if ($nestedSchema !== null && $this->needsActivation($nestedSchema, $seen)) { + return $seen[$schemaKey] = true; + } + + if ($this->compositionValidatorsNeedActivation($schemaProperty->getOrderedValidators(), $seen)) { + return $seen[$schemaKey] = true; + } + } + + return $seen[$schemaKey] = false; + } + + /** + * True when any composition validator in the given list carries a branch that declares an + * unevaluated keyword — either in the branch-level JSON, in a nested schema the branch + * produces, or in a further composition nested inside the branch's own JSON. Shared by the + * schema-level (base validators) and property-level checks. + * + * An object-typed branch always gets its own nested `Schema` (routed through + * `processSchema()`), so a further composition nested inside it is reached by recursing + * into that `Schema` via `needsActivation()`. An array-typed branch never gets one, so a + * branch shaped like `{oneOf: [{unevaluatedItems: ...}]}` — a composition nested directly + * inside another branch, with no intervening object type — has no `Schema` object for that + * recursion to reach. Recursing through the branch's wrapped property's own validators + * instead mirrors how `activateValidatorsInBranch()` already walks this exact structure for + * the activation step itself. Without this, the branch's own nested validator still renders + * (schema processing is not gated by this detection), but the class it renders into never + * receives `CompositionEvaluationTrait` or the `_evaluatedItemIndices` field the rendered + * code calls into — a fatal `Error: Call to undefined method`, not a silent gap. + * + * @param iterable $validators + * @param array $seen + */ + private function compositionValidatorsNeedActivation(iterable $validators, array &$seen): bool + { + foreach ($validators as $validator) { + if (!$validator instanceof AbstractComposedPropertyValidator) { + continue; + } + + foreach ($validator->getComposedProperties() as $composedProperty) { + $branchJson = $composedProperty->getBranchSchema()->getJson(); + + if ( + array_key_exists('unevaluatedProperties', $branchJson) + || array_key_exists('unevaluatedItems', $branchJson) + ) { + return true; + } + + $nestedSchema = $composedProperty->getNestedSchema(); + + if ($nestedSchema !== null) { + if ($this->needsActivation($nestedSchema, $seen)) { + return true; + } + + continue; + } + + if ( + $this->compositionValidatorsNeedActivation( + $composedProperty->getWrappedProperty()->getOrderedValidators(), + $seen, + ) + ) { + return true; + } + } + } + + return false; + } +} diff --git a/src/SchemaProcessor/PostProcessor/PatternPropertiesAccessorPostProcessor.php b/src/SchemaProcessor/PostProcessor/PatternPropertiesAccessorPostProcessor.php index 8f3aab69..67baf278 100644 --- a/src/SchemaProcessor/PostProcessor/PatternPropertiesAccessorPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/PatternPropertiesAccessorPostProcessor.php @@ -53,6 +53,7 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu ->setDefaultValue('null', true) ->setInternal(true), ); + $schema->addAccessorCacheProperty('_patternPropertiesAccessor'); $this->addAccessorMethod($schema, $generatorConfiguration, $hasCompanion); diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl index fbbae2d2..28cff116 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl @@ -1,5 +1,5 @@ /** - *{% if minPropertyValidator %} @throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + *{% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %} @throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} */ private function _removeAdditionalProperty(string $key): bool { @@ -7,8 +7,8 @@ private function _removeAdditionalProperty(string $key): bool $inPatternProperties = false; if (isset($this->_patternProperties)) { - foreach ($this->_patternProperties as $patternHash => $_) { - if (isset($this->_patternProperties[$patternHash][$key])) { + foreach ($this->_patternProperties as $patternEntries) { + if (isset($patternEntries[$key])) { $inPatternProperties = true; break; } @@ -19,30 +19,79 @@ private function _removeAdditionalProperty(string $key): bool return false; } - {% if minPropertyValidator %} + {% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %} + // Snapshot every field a rejected removal must restore. Composition validators merge + // against _rawModelDataInput when they run, so the post-remove view has to live there + // — passing a candidate parameter alone gets re-merged. + $originalRawModelDataInput = $this->_rawModelDataInput; + + {% if schema.getCompositionValidatorKeys() %} + $originalCompositionEvaluations = $this->_compositionEvaluations ?? []; + $originalPropertyValidationState = $this->_propertyValidationState ?? []; + {% endif %} + {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% else %} + try { {% endif %} - if ({{ minPropertyValidator.getCheck() }}) { - {{ viewHelper.validationError(minPropertyValidator) }} - } + {% if minPropertyValidator %} + // minPropertyValidator's check expression compensates for the imminent removal + // (`count($this->_rawModelDataInput) - 1`), so it must run BEFORE the unset. + if ({{ minPropertyValidator.getCheck() }}) { + {{ viewHelper.validationError(minPropertyValidator) }} + } + {% endif %} + + unset($this->_rawModelDataInput[$key]); + + {% if schema.getCompositionValidatorKeys() %} + // Cross-state composition revalidation: removing a key may flip a branch + // (e.g. one whose required/minProperties/etc. depended on the key). Without + // this the cached _compositionEvaluations stay tied to the pre-removal + // state and the post-composition unevaluated check would miss orphans. + {% foreach schema.getCompositionValidatorKeys() as compositionValidatorKey %} + $this->_validateComposition_{{ compositionValidatorKey }}($this->_rawModelDataInput); + {% endforeach %} + {% endif %} + + {% if schema.getPostCompositionValidators() %} + $this->_executePostCompositionValidators($this->_rawModelDataInput); + {% endif %} {% if generatorConfiguration.collectErrors() %} if (count($this->_errorRegistry->getErrors())) { + $this->_rawModelDataInput = $originalRawModelDataInput; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $originalCompositionEvaluations; + $this->_propertyValidationState = $originalPropertyValidationState; + {% endif %} + throw $this->_errorRegistry; } + {% else %} + } catch (\Throwable $rejection) { + $this->_rawModelDataInput = $originalRawModelDataInput; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $originalCompositionEvaluations; + $this->_propertyValidationState = $originalPropertyValidationState; + {% endif %} + + throw $rejection; + } {% endif %} + {% else %} + unset($this->_rawModelDataInput[$key]); {% endif %} if ($inPatternProperties) { - foreach ($this->_patternProperties as $patternHash => $_) { - unset($this->_patternProperties[$patternHash][$key]); + foreach ($this->_patternProperties as &$patternEntries) { + unset($patternEntries[$key]); } + unset($patternEntries); } - unset($this->_rawModelDataInput[$key]); - if ($inAdditionalProperties) { unset($this->_additionalProperties[$key]); } diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl index ed052c76..8931f4c4 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl @@ -1,6 +1,6 @@ /** - *{% if hasObjectProperties %} @throws RegularPropertyAsAdditionalPropertyException{% endif %} - *{% if schema.getBaseValidators() %} @throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + *{% if hasObjectProperties %} @throws RegularPropertyAsAdditionalPropertyException + *{% endif %}{% if schema.getBaseValidators() or schema.getPostCompositionValidators() %} @throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} */ private function _setAdditionalProperty( string $key, @@ -18,21 +18,44 @@ private function _setAdditionalProperty( {% if validationProperty %}{{ schemaHookResolver.resolveSetterBeforeValidationHook(validationProperty) }}{% endif %} - {% if schema.getBaseValidators() %} + {% if schema.getBaseValidators() or schema.getPostCompositionValidators() %} + // Snapshot the _additionalProperties bucket so any base-validator write or post- + // composition rejection can be unwound. + $rollbackAdditionalProperties = $this->_additionalProperties; + {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% else %} + try { {% endif %} - $addedProperty = [$key => $value]; - $this->_executeBaseValidators($addedProperty); + {% if schema.getBaseValidators() %} + $addedProperty = [$key => $value]; + $this->_executeBaseValidators($addedProperty); + {% else %} + $this->_additionalProperties[$key] = $value; + {% endif %} + + {% if schema.getPostCompositionValidators() %} + // Cross-state revalidation: feed the candidate raw input view to the post- + // composition phase so unevaluatedProperties sees the would-be state. + $candidateRawModelDataInput = $this->_rawModelDataInput; + $candidateRawModelDataInput[$key] = $value; + $this->_executePostCompositionValidators($candidateRawModelDataInput); + {% endif %} {% if generatorConfiguration.collectErrors() %} if (count($this->_errorRegistry->getErrors())) { + $this->_additionalProperties = $rollbackAdditionalProperties; throw $this->_errorRegistry; } + {% else %} + } catch (\Throwable $rejection) { + $this->_additionalProperties = $rollbackAdditionalProperties; + throw $rejection; + } {% endif %} {% else %} - // No base validators means no pattern properties either — value goes directly to _additionalProperties. $this->_additionalProperties[$key] = $value; {% endif %} diff --git a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl index 808a28b1..6a836324 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/UpdateAdditionalProperties.phptpl @@ -2,7 +2,7 @@ foreach (array_diff(array_keys($value), {{ additionalProperties }}) as $propertyKey) { {% if patternProperties %} foreach ({{ patternProperties }} as $pattern) { - if (preg_match("/$pattern/", $propertyKey)) { + if (preg_match($pattern, $propertyKey)) { continue 2; } } diff --git a/src/SchemaProcessor/PostProcessor/Templates/Companion/UnevaluatedPropertiesCompanion.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Companion/UnevaluatedPropertiesCompanion.phptpl new file mode 100644 index 00000000..6628ce28 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/Companion/UnevaluatedPropertiesCompanion.phptpl @@ -0,0 +1,62 @@ +unevaluatedProperties[$key] ?? null; + } + + /** + * @return {{ getAllReturnAnnotation }} + */ + public function getAll(): array + { + return $this->unevaluatedProperties; + } + + {% if not immutable %} + /** + * @param string $key + * @param {{ setParameterAnnotation }} $value + */ + public function set(string $key, {{ setParameterType }} $value): static + { + ($this->setter)($key, $value); + return $this; + } + + public function remove(string $key): bool + { + return ($this->remover)($key); + } + {% endif %} +} + +// @codeCoverageIgnoreEnd diff --git a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl index 6420cd35..5bb57e7f 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl @@ -11,10 +11,12 @@ public function populate(array $modelData): self { // Reset accessor caches so any call after this populate reflects the final model state, - // whether populate succeeds or rolls back. - foreach (['_additionalPropertiesAccessor', '_patternPropertiesAccessor'] as $_accessorProperty) { - if (property_exists($this, $_accessorProperty)) { - $this->{$_accessorProperty} = null; + // whether populate succeeds or rolls back. The list is owned by Schema and populated by + // each accessor post processor that emits a cached accessor instance — see + // Schema::addAccessorCacheProperty(). + foreach ({{ viewHelper.varExportArray(schema.getAccessorCacheProperties()) }} as $accessorCacheField) { + if (property_exists($this, $accessorCacheField)) { + $this->{$accessorCacheField} = null; } } diff --git a/src/SchemaProcessor/PostProcessor/Templates/PopulateBody.phptpl b/src/SchemaProcessor/PostProcessor/Templates/PopulateBody.phptpl index fb8f2c0b..75811222 100644 --- a/src/SchemaProcessor/PostProcessor/Templates/PopulateBody.phptpl +++ b/src/SchemaProcessor/PostProcessor/Templates/PopulateBody.phptpl @@ -1,4 +1,6 @@ -foreach (['_additionalProperties', '_patternProperties'] as $property) { +// Each internal post processor that owns a backing collection field registers it here so +// its pre-populate value is restored on rollback — see Schema::addRollbackProperty(). +foreach ({{ viewHelper.varExportArray(schema.getRollbackProperties()) }} as $property) { if (isset($this->{$property})) { $rollbackValues[$property] = $this->{$property}; } @@ -21,3 +23,14 @@ foreach (['_additionalProperties', '_patternProperties'] as $property) { } {% endif %} {% endforeach %} + +{% if schema.getPostCompositionValidators() %} + // Cross-state validation: the per-property loop above only sees the populate delta, so + // violations that depend on the union of pre-existing state plus the delta (notably + // unevaluatedProperties claims that flip with a discriminator) need to be checked + // against the would-be merged state. The check runs without committing the merge — + // the rollback paths in the caller restore property fields if it fails, leaving the + // original _rawModelDataInput untouched on failure. + $candidateRawModelDataInput = array_merge($this->_rawModelDataInput, $modelData); + $this->_executePostCompositionValidators($candidateRawModelDataInput); +{% endif %} diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/ArrayItemsTransformingFilterSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/ArrayItemsTransformingFilterSerializer.phptpl new file mode 100644 index 00000000..ed823d63 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/ArrayItemsTransformingFilterSerializer.phptpl @@ -0,0 +1,12 @@ +/** + * serialize the property {{ property.getAttribute() }} (schema-form items, uniform filter) + */ +protected function _serialize{{ viewHelper.ucfirst(property.getAttribute()) }}(): array +{ + $serialized = []; + foreach ($this->{{ property.getAttribute(true) }} as $index => $value) { + $serialized[$index] = \{{ serializerClass }}::{{ serializerMethod }}($value, {{ serializerOptions }}); + } + + return $serialized; +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/IndexedItemsTransformingFilterSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/IndexedItemsTransformingFilterSerializer.phptpl new file mode 100644 index 00000000..c05660fb --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/IndexedItemsTransformingFilterSerializer.phptpl @@ -0,0 +1,36 @@ +/** + * serialize the property {{ property.getAttribute() }} — static tuple-index filters and/or a + * dynamic filter for indices unevaluatedItems credited at runtime, combined into a single method + * so the two never clobber each other's generated method name. + */ +protected function _serialize{{ viewHelper.ucfirst(property.getAttribute()) }}(): array +{ + $serialized = $this->{{ property.getAttribute(true) }}; + + {% foreach indexSerializers as indexSerializer %} + if (array_key_exists({{ indexSerializer.index }}, $serialized)) { + $serialized[{{ indexSerializer.index }}] = \{{ indexSerializer.serializerClass }}::{{ indexSerializer.serializerMethod }}( + $serialized[{{ indexSerializer.index }}], + {{ indexSerializer.serializerOptions }}, + ); + } + {% endforeach %} + + {% if dynamicSerializerClass %} + foreach (array_keys($this->_evaluatedItemIndices['{{ arrayPropertyName }}'] ?? []) as $index) { + {% if staticIndices %} + // Indices already handled by a static tuple filter above must not also run + // through the dynamic unevaluatedItems filter. + if (in_array($index, {{ viewHelper.varExportArray(staticIndices) }}, true)) { + continue; + } + {% endif %} + $serialized[$index] = \{{ dynamicSerializerClass }}::{{ dynamicSerializerMethod }}( + $serialized[$index], + {{ dynamicSerializerOptions }}, + ); + } + {% endif %} + + return $serialized; +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/Serialization/UnevaluatedPropertiesSerializer.phptpl b/src/SchemaProcessor/PostProcessor/Templates/Serialization/UnevaluatedPropertiesSerializer.phptpl new file mode 100644 index 00000000..1ce33162 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/Serialization/UnevaluatedPropertiesSerializer.phptpl @@ -0,0 +1,9 @@ +protected function _serializeUnevaluatedProperties(int $depth, array $except, bool $emptyObjectsAsStdClass): array +{ + $serializedValues = []; + foreach ($this->_unevaluatedProperties as $key => $value) { + $serializedValues[$key] = \{{ serializerClass }}::{{ serializerMethod }}($value, {{ serializerOptions }}); + } + + return (array) $this->_getSerializedValue($serializedValues, $depth, $except, $emptyObjectsAsStdClass); +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl new file mode 100644 index 00000000..1ad6651d --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl @@ -0,0 +1,85 @@ +/** + *{% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %} @throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %}{% endif %} + */ +private function _removeUnevaluatedProperty(string $key): bool +{ + if (!array_key_exists($key, $this->_unevaluatedProperties)) { + return false; + } + + {% if minPropertyValidator or schema.getCompositionValidatorKeys() or schema.getPostCompositionValidators() %} + // Snapshot every field a rejected removal must restore. The composition validators merge + // against _rawModelDataInput when they run, so the post-removal view has to live there — + // a candidate parameter alone would be re-merged with the still-present key. + $originalRawModelDataInput = $this->_rawModelDataInput; + $originalUnevaluatedProperties = $this->_unevaluatedProperties; + + {% if schema.getCompositionValidatorKeys() %} + $originalCompositionEvaluations = $this->_compositionEvaluations ?? []; + $originalPropertyValidationState = $this->_propertyValidationState ?? []; + {% endif %} + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% else %} + try { + {% endif %} + + {% if minPropertyValidator %} + // minPropertyValidator's check expression compensates for the imminent removal + // (`count($this->_rawModelDataInput) - 1`), so it must run BEFORE the unset. + if ({{ minPropertyValidator.getCheck() }}) { + {{ viewHelper.validationError(minPropertyValidator) }} + } + {% endif %} + + unset($this->_rawModelDataInput[$key]); + + {% if schema.getCompositionValidatorKeys() %} + // Cross-state composition revalidation: removing a key may flip a branch whose + // outcome depends on which keys are present (a branch minProperties, a required name, + // an additionalProperties claim). Without this the cached _compositionEvaluations + // stay tied to the pre-removal state and a now-invalid model would be accepted. + {% foreach schema.getCompositionValidatorKeys() as compositionValidatorKey %} + $this->_validateComposition_{{ compositionValidatorKey }}($this->_rawModelDataInput); + {% endforeach %} + {% endif %} + + {% if schema.getPostCompositionValidators() %} + // The unevaluatedProperties validator rebuilds _unevaluatedProperties from the + // post-removal state; removing a key can orphan another that a now-failed branch + // used to claim, which this phase rejects. + $this->_executePostCompositionValidators($this->_rawModelDataInput); + {% endif %} + + {% if generatorConfiguration.collectErrors() %} + if (count($this->_errorRegistry->getErrors())) { + $this->_rawModelDataInput = $originalRawModelDataInput; + $this->_unevaluatedProperties = $originalUnevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $originalCompositionEvaluations; + $this->_propertyValidationState = $originalPropertyValidationState; + {% endif %} + + throw $this->_errorRegistry; + } + {% else %} + } catch (\Throwable $rejection) { + $this->_rawModelDataInput = $originalRawModelDataInput; + $this->_unevaluatedProperties = $originalUnevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $originalCompositionEvaluations; + $this->_propertyValidationState = $originalPropertyValidationState; + {% endif %} + + throw $rejection; + } + {% endif %} + {% else %} + unset($this->_rawModelDataInput[$key]); + {% endif %} + + unset($this->_unevaluatedProperties[$key]); + + return true; +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl new file mode 100644 index 00000000..a1605275 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/SetUnevaluatedProperty.phptpl @@ -0,0 +1,100 @@ +/** + * {% if hasObjectProperties or hasPatternProperties %}@throws RegularPropertyAsUnevaluatedPropertyException + * {% endif %}@throws {% if generatorConfiguration.collectErrors() %}{{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}{% else %}ValidationException{% endif %} + */ +private function _setUnevaluatedProperty( + string $key, + {% if validationProperty %}{{ viewHelper.getType(validationProperty) }}{% else %}mixed{% endif %} $value +): void { + {% if hasObjectProperties %} + if (in_array($key, {{ objectProperties }})) { + throw new RegularPropertyAsUnevaluatedPropertyException( + $value, + $key, + {{ objectPropertyPointers }}[$key] ?? '', + self::class, + ); + } + {% endif %} + + {% if hasPatternProperties %} + foreach ({{ patternProperties }} as $regex => $pointer) { + if (preg_match($regex, $key)) { + throw new RegularPropertyAsUnevaluatedPropertyException($value, $key, $pointer, self::class); + } + } + {% endif %} + + if (isset($this->_unevaluatedProperties[$key]) && $this->_unevaluatedProperties[$key] === $value) { + return; + } + + {% if validationProperty %}{{ schemaHookResolver.resolveSetterBeforeValidationHook(validationProperty) }}{% endif %} + + // Snapshot the state a rejected call has to give back. In collect-errors mode a later phase + // can succeed and store its result while an earlier one has already registered an error, so + // the stores are unwound before the registry is thrown rather than left half-applied. + // + // _propertyValidationState is deliberately not snapshotted: the composition cache is only + // read for branches whose outcome no undeclared key can change, and those branches are the + // ones a dynamic key leaves untouched. Every entry this call can rewrite belongs to a + // key-sensitive branch, which never reads the cache back. + $rollbackUnevaluatedProperties = $this->_unevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + // A branch can flip from failing to succeeding once the new key is present (its + // minProperties is met, its required name arrives, ...). Leaving that slot behind after + // a rejection would credit the branch's claims to a state that never had them. + $rollbackCompositionEvaluations = $this->_compositionEvaluations; + {% endif %} + + // In collect-errors mode each call needs an isolated error registry — without resetting it, + // a previous setter that failed and was caught by the caller would leave stale errors that + // make every subsequent setter throw spuriously when its own validation actually succeeded. + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% else %} + try { + {% endif %} + + $addedProperty = [$key => $value]; + + {% if schema.getCompositionValidatorKeys() %} + // Cross-state composition revalidation: a branch keyword that reaches keys the branch + // does not declare — additionalProperties, patternProperties, unevaluatedProperties, + // propertyNames, min/maxProperties — decides whether the new key is admissible at all, + // and rejects it when its value violates the branch's claim. The compositions merge + // $addedProperty over _rawModelDataInput themselves, so the candidate state is visible + // to them without committing anything. + {% foreach schema.getCompositionValidatorKeys() as compositionValidatorKey %} + $this->_validateComposition_{{ compositionValidatorKey }}($addedProperty); + {% endforeach %} + {% endif %} + + // _executePostCompositionValidators runs the unevaluatedProperties validator against the + // candidate pair, which writes the value into $this->_unevaluatedProperties on success. + $this->_executePostCompositionValidators($addedProperty); + + {% if generatorConfiguration.collectErrors() %} + if (count($this->_errorRegistry->getErrors())) { + $this->_unevaluatedProperties = $rollbackUnevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $rollbackCompositionEvaluations; + {% endif %} + + throw $this->_errorRegistry; + } + {% else %} + } catch (\Throwable $rejection) { + $this->_unevaluatedProperties = $rollbackUnevaluatedProperties; + {% if schema.getCompositionValidatorKeys() %} + $this->_compositionEvaluations = $rollbackCompositionEvaluations; + {% endif %} + + throw $rejection; + } + {% endif %} + + $this->_rawModelDataInput[$key] = $value; + + {% if validationProperty %}{{ schemaHookResolver.resolveSetterAfterValidationHook(validationProperty) }}{% endif %} +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl new file mode 100644 index 00000000..c5f6f669 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl @@ -0,0 +1,10 @@ +public function unevaluatedProperties(): {{ accessorType }} +{ + return $this->_unevaluatedPropertiesAccessor ??= new {{ accessorType }}( + $this->_unevaluatedProperties, + {% if not immutable %} + $this->_setUnevaluatedProperty(...), + $this->_removeUnevaluatedProperty(...), + {% endif %} + ); +} diff --git a/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php b/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php new file mode 100644 index 00000000..20bc9d87 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php @@ -0,0 +1,471 @@ +` where `additionalProperties` does not already claim + * every extra key at the same level. + * + * Mirrors AdditionalPropertiesAccessorPostProcessor: emits a public getter that returns a + * cached accessor instance (either the bare production-library class for untyped schemas, or a + * generated companion class with narrowed types for typed schemas), plus private mutator shims + * `_setUnevaluatedProperty` / `_removeUnevaluatedProperty` invoked by the accessor's closures. + * + * Emission policy: + * - additionalProperties absent (denyAdditionalProperties off) / additionalProperties: true → emit + * - additionalProperties: false → skip + * - additionalProperties: {schema} → skip + * In the skip cases, additionalProperties already either rejects every extra key or claims and + * validates it, so _unevaluatedProperties would always be empty — exposing an accessor would + * be misleading. Skipping is total: no backing field, no accessor method, no shims, no + * companion, no setCollectUnevaluatedProperties(true) call. The validator continues to run as + * a pure assertion. + */ +class UnevaluatedPropertiesAccessorPostProcessor extends PostProcessor +{ + use CompanionGeneratorTrait; + + /** + * Add the unevaluatedProperties() accessor method to the provided schema. + * + * @throws SchemaException + */ + public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void + { + if (!$this->shouldEmitAccessor($schema, $generatorConfiguration)) { + return; + } + + $validator = $this->locateUnevaluatedValidator($schema); + if ($validator === null) { + return; + } + + $validator->setCollectUnevaluatedProperties(true); + $validationProperty = $validator->getValidationProperty(); + + $this->addBackingField($schema, $validationProperty); + $this->addAccessorCacheField($schema); + + $hasCompanion = $validationProperty->getType() !== null; + $isImmutable = $generatorConfiguration->isImmutable(); + + $this->addAccessorMethod($schema, $generatorConfiguration, $hasCompanion); + + if (!$isImmutable) { + $this->addSetUnevaluatedPropertyMethod($schema, $generatorConfiguration, $validationProperty); + $this->addRemoveUnevaluatedPropertyMethod($schema, $generatorConfiguration); + } + + if ($hasCompanion) { + $this->pendingCompanions[] = [ + 'schema' => $schema, + 'generatorConfiguration' => $generatorConfiguration, + 'validationProperty' => $validationProperty, + ]; + } + } + + /** + * Render the accessor iff unevaluatedProperties is declared as a non-false value AND + * additionalProperties at the same level does not already claim every extra. + */ + private function shouldEmitAccessor(Schema $schema, GeneratorConfiguration $generatorConfiguration): bool + { + $json = $schema->getJsonSchema()->getJson(); + + // unevaluatedProperties: false produces a NoUnevaluatedPropertiesValidator with no + // backing field — there is nothing to expose. + if (!array_key_exists('unevaluatedProperties', $json) || $json['unevaluatedProperties'] === false) { + return false; + } + + // additionalProperties: false / {schema} both leave _unevaluatedProperties permanently + // empty (the first rejects every extra, the second validates and claims every extra) — + // exposing an accessor with no possible contents would be misleading. + if (array_key_exists('additionalProperties', $json) && $json['additionalProperties'] !== true) { + return false; + } + + // denyAdditionalProperties() flips a missing additionalProperties to false → still dead. + if (!array_key_exists('additionalProperties', $json) && $generatorConfiguration->denyAdditionalProperties()) { + return false; + } + + return true; + } + + /** + * The factory attaches the UnevaluatedPropertiesValidator via + * Schema::addPostCompositionValidator() — not addBaseValidator() — because spec ordering + * requires the unevaluated check to run after every adjacent composition has had a chance + * to claim keys. The accessor post processor must look in the same bucket. + */ + private function locateUnevaluatedValidator(Schema $schema): ?UnevaluatedPropertiesValidator + { + foreach ($schema->getPostCompositionValidators() as $validator) { + if ($validator instanceof UnevaluatedPropertiesValidator) { + return $validator; + } + } + + return null; + } + + /** + * Recursively walks every composition branch on the schema and harvests the property + * names and pattern regexes those branches declare. A key matching either set must not be + * routed through the unevaluated accessor because it belongs to a composition contract + * (a typed inline branch member or a pattern-matched branch property), not to the + * unevaluated bucket. + * + * Walks composition validators registered on the schema's base validators and recurses + * through nested allOf/anyOf/oneOf/if-then-else by inspecting each branch's raw JSON. + * + * Each branch's schema contributes its own JSON pointer root; a `properties.foo` declaration + * inside `/allOf/0` therefore reports the pointer `/allOf/0/properties/foo`. When the same + * name (or pattern) appears in multiple branches, the first branch-order encounter wins so + * the emitted map has stable content across regenerations. + * + * @return array{0: array, 1: array} + * [propertyName => pointer, patternRegex => pointer] + */ + private function harvestCompositionPropertyNames(Schema $schema): array + { + $propertyPointers = []; + $patternPointers = []; + + foreach ($schema->getBaseValidators() as $baseValidator) { + if ($baseValidator instanceof AbstractComposedPropertyValidator) { + foreach ($baseValidator->getComposedProperties() as $branch) { + $branchSchema = $branch->getBranchSchema(); + $this->collectBranchPropertyNames( + $branchSchema->getJson(), + $branchSchema->getPointer(), + $propertyPointers, + $patternPointers, + ); + } + } + } + + return [$propertyPointers, $patternPointers]; + } + + /** + * Walks a single branch's raw JSON schema and appends its `properties` keys and + * `patternProperties` regexes to the accumulators, along with the pointer at which each + * declaration lives. Recurses into nested composition keywords because a branch may itself + * contain allOf/anyOf/oneOf whose sub-branches declare further properties. + * + * @param array $propertyPointers accumulator: name => JSON pointer + * @param array $patternPointers accumulator: regex => JSON pointer + */ + private function collectBranchPropertyNames( + array $branchJson, + string $branchPointer, + array &$propertyPointers, + array &$patternPointers, + ): void { + foreach (array_keys($branchJson['properties'] ?? []) as $name) { + $name = (string) $name; + $propertyPointers[$name] ??= $branchPointer . '/properties/' . JsonSchemaUtil::encodePointer($name); + } + + foreach (array_keys($branchJson['patternProperties'] ?? []) as $pattern) { + $pattern = (string) $pattern; + $patternPointers[$pattern] ??= + $branchPointer . '/patternProperties/' . JsonSchemaUtil::encodePointer($pattern); + } + + foreach (['allOf', 'anyOf', 'oneOf'] as $compositionKey) { + foreach ($branchJson[$compositionKey] ?? [] as $index => $nestedBranchJson) { + if (is_array($nestedBranchJson)) { + $this->collectBranchPropertyNames( + $nestedBranchJson, + $branchPointer . '/' . $compositionKey . '/' . $index, + $propertyPointers, + $patternPointers, + ); + } + } + } + + foreach (['if', 'then', 'else'] as $conditionalKey) { + if (isset($branchJson[$conditionalKey]) && is_array($branchJson[$conditionalKey])) { + $this->collectBranchPropertyNames( + $branchJson[$conditionalKey], + $branchPointer . '/' . $conditionalKey, + $propertyPointers, + $patternPointers, + ); + } + } + } + + /** + * @throws SchemaException + */ + private function addBackingField(Schema $schema, PropertyInterface $validationProperty): void + { + $backingField = (new Property( + 'unevaluatedProperties', + new PropertyType('array'), + new JsonSchema(__FILE__, []), + 'Collect all unevaluated properties provided to the schema', + )) + ->setDefaultValue([]) + ->setInternal(true); + + if ($validationProperty->getType()) { + $backingField->addTypeHintDecorator(new ArrayTypeHintDecorator($validationProperty)); + } + + $schema->addProperty($backingField); + $schema->addRollbackProperty('_unevaluatedProperties'); + } + + /** + * @throws SchemaException + */ + private function addAccessorCacheField(Schema $schema): void + { + $schema->addProperty( + (new Property( + 'unevaluatedPropertiesAccessor', + null, + $schema->getJsonSchema(), + 'Cached accessor instance for unevaluated properties', + )) + ->setDefaultValue('null', true) + ->setInternal(true), + ); + $schema->addAccessorCacheProperty('_unevaluatedPropertiesAccessor'); + } + + private function addAccessorMethod( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + bool $hasCompanion, + ): void { + $isImmutable = $generatorConfiguration->isImmutable(); + + if (!$hasCompanion) { + $schema->addUsedClass($isImmutable + ? ImmutableUnevaluatedPropertiesAccessor::class + : UnevaluatedPropertiesAccessor::class); + } + + $accessorType = $hasCompanion + ? $schema->getClassName() . 'UnevaluatedProperties' + : (new ReflectionClass($isImmutable + ? ImmutableUnevaluatedPropertiesAccessor::class + : UnevaluatedPropertiesAccessor::class))->getShortName(); + + $schema->addMethod( + 'unevaluatedProperties', + new RenderedMethod( + $schema, + $generatorConfiguration, + 'UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl', + [ + 'accessorType' => $accessorType, + 'immutable' => $isImmutable, + ], + ), + ); + } + + private function addSetUnevaluatedPropertyMethod( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $validationProperty, + ): void { + $nonInternalProperties = array_filter( + $schema->getProperties(), + static fn(PropertyInterface $property): bool => !$property->isInternal(), + ); + + $directPropertyPointers = []; + foreach ($nonInternalProperties as $property) { + $directPropertyPointers[$property->getName()] = JsonSchemaUtil::resolvePrimaryJsonPointer($property); + } + + $schemaRootPointer = $schema->getJsonSchema()->getPointer(); + $directPatternPointers = []; + foreach (array_keys($schema->getJsonSchema()->getJson()['patternProperties'] ?? []) as $pattern) { + $pattern = (string) $pattern; + $directPatternPointers[$pattern] = + $schemaRootPointer . '/patternProperties/' . JsonSchemaUtil::encodePointer($pattern); + } + + // A composition branch's `properties` / `patternProperties` declarations contribute keys + // to the evaluated set at runtime (when the branch succeeds). Setting such a key via + // unevaluatedProperties()->set() would bypass the branch's own type/constraint + // validation, so the shim must reject those keys with the same exception used for + // directly-declared properties. Root-declared entries win over branch-declared entries + // with the same name/pattern, so the reported pointer for a name that lives at both + // levels points at the root. + [$compositionPropertyPointers, $compositionPatternPointers] = $this->harvestCompositionPropertyNames($schema); + + $objectPropertyPointers = $directPropertyPointers + $compositionPropertyPointers; + $patternPropertyPointers = $directPatternPointers + $compositionPatternPointers; + + $hasObjectProperties = $objectPropertyPointers !== []; + $hasPatternProperties = $patternPropertyPointers !== []; + + if ($hasObjectProperties || $hasPatternProperties) { + $schema->addUsedClass(RegularPropertyAsUnevaluatedPropertyException::class); + } + + $schema->addMethod( + '_setUnevaluatedProperty', + new RenderedMethod( + $schema, + $generatorConfiguration, + 'UnevaluatedProperties/SetUnevaluatedProperty.phptpl', + [ + 'validationProperty' => $validationProperty, + 'hasObjectProperties' => $hasObjectProperties, + 'objectProperties' => RenderHelper::varExportArray(array_keys($objectPropertyPointers)), + 'objectPropertyPointers' => RenderHelper::varExportArray($objectPropertyPointers), + 'hasPatternProperties' => $hasPatternProperties, + 'patternProperties' => $hasPatternProperties + ? RenderHelper::varExportPcrePatternMap($patternPropertyPointers) + : null, + 'schemaHookResolver' => new SchemaHookResolver($schema), + ], + ), + ); + } + + /** + * @throws SchemaException + */ + private function addRemoveUnevaluatedPropertyMethod( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + ): void { + $minPropertyValidator = null; + $json = $schema->getJsonSchema()->getJson(); + if (isset($json['minProperties'])) { + $minPropertyValidator = new PropertyValidator( + new Property($schema->getClassName(), null, $schema->getJsonSchema()), + sprintf( + '($updatedPropertiesCount = count($this->_rawModelDataInput) - 1) < %d', + $json['minProperties'], + ), + MinPropertiesException::class, + [$json['minProperties'], '&$updatedPropertiesCount'], + ); + } + + $schema->addMethod( + '_removeUnevaluatedProperty', + new RenderedMethod( + $schema, + $generatorConfiguration, + 'UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl', + ['minPropertyValidator' => $minPropertyValidator], + ), + ); + } + + /** + * @throws FileSystemException + */ + protected function renderCompanionFromEntry(array $entry): void + { + $this->renderCompanionClass( + $entry['schema'], + $entry['generatorConfiguration'], + $entry['validationProperty'], + ); + } + + /** + * @throws FileSystemException + */ + private function renderCompanionClass( + Schema $schema, + GeneratorConfiguration $generatorConfiguration, + PropertyInterface $validationProperty, + ): void { + $renderHelper = new RenderHelper($generatorConfiguration); + $isImmutable = $generatorConfiguration->isImmutable(); + $companionClassName = $schema->getClassName() . 'UnevaluatedProperties'; + + $namespace = $this->resolveCompanionNamespace($schema, $generatorConfiguration); + + // get() always returns null when the key is absent, so the companion's get() return + // type must allow null in addition to the schema-declared type. + $nullableProperty = (clone $validationProperty)->setType( + $validationProperty->getType(), + new PropertyType($validationProperty->getType(true)->getNames(), true), + ); + + // The companion is a stand-alone class that mirrors the production-library accessor's + // shape with narrowed types — it does not extend the base. This matches the pattern in + // AdditionalPropertiesCompanion.phptpl. The base remains the fallback when no typed + // companion is generated. + $use = RenderHelper::filterClassImports( + $isImmutable ? [] : ['Closure'], + $namespace, + ); + + $this->writeAndRequireCompanionFile( + $schema, + $companionClassName, + $namespace, + join(DIRECTORY_SEPARATOR, ['Companion', 'UnevaluatedPropertiesCompanion.phptpl']), + [ + 'namespace' => $namespace, + 'use' => $use, + 'companionClassName' => $companionClassName, + 'immutable' => $isImmutable, + 'getReturnType' => $renderHelper->getTypeHintAnnotation($nullableProperty, true), + 'getNullablePhpType' => $renderHelper->getType($nullableProperty, true), + 'getAllReturnAnnotation' => $this->buildGetAllReturnAnnotation( + $renderHelper->getTypeHintAnnotation($validationProperty, true), + ), + 'setParameterType' => $renderHelper->getType($validationProperty), + 'setParameterAnnotation' => $renderHelper->getTypeHintAnnotation($validationProperty), + ], + ); + } + + private function buildGetAllReturnAnnotation(string $typeAnnotation): string + { + // Wrap union types in parentheses so that e.g. 'DateTime|null' becomes '(DateTime|null)[]', + // not 'DateTime[]|null[]' — the latter means "an array of nulls" to static analysers. + if (str_contains($typeAnnotation, '|')) { + return '(' . $typeAnnotation . ')[]'; + } + + return $typeAnnotation . '[]'; + } +} diff --git a/src/SchemaProcessor/RenderQueue.php b/src/SchemaProcessor/RenderQueue.php index af365193..4f0c65b0 100644 --- a/src/SchemaProcessor/RenderQueue.php +++ b/src/SchemaProcessor/RenderQueue.php @@ -10,11 +10,6 @@ use PHPModelGenerator\Model\RenderJob; use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor; -/** - * Class RenderQueue - * - * @package PHPModelGenerator\SchemaProcessor - */ class RenderQueue { /** @var RenderJob[] */ @@ -44,8 +39,16 @@ public function execute(GeneratorConfiguration $generatorConfiguration, array $p $postProcessor->preProcess(); } + // Decouple post-processing from rendering so that a post processor on schema A may + // mutate schema B (e.g. attach a method to a nested composition-branch class) and have + // those mutations visible when B is rendered, regardless of queue order. Without this + // separation, a process()/render() loop interleaved per schema would render B before + // A's process() had a chance to touch it. foreach ($this->jobs as $job) { $job->executePostProcessors($postProcessors, $generatorConfiguration); + } + + foreach ($this->jobs as $job) { $job->render($generatorConfiguration); } diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index 74d93962..10dd72dc 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -706,7 +706,21 @@ public function transferComposedPropertiesToSchema(PropertyInterface $property, &$seenBranchPropertyNames, ): void { if (!$composedProperty->getNestedSchema()) { - if ($composedProperty->getType() !== null) { + // allOf requires every branch to hold simultaneously, so a typed branch + // with no nested schema (only an object-asserting branch gets one) can + // never be satisfied alongside this object context - a genuine + // contradiction (e.g. allOf: [{type: integer}] under an object schema). + // oneOf/anyOf/if-then-else need only ONE branch to match, so a + // differently-typed branch (e.g. the array branch of a oneOf reached while + // narrowing a `{type: [object, array]}` property to its object variant) + // is simply not reachable from here, not a contradiction. + $isConjunctiveComposition = is_a( + $validator->getCompositionProcessor(), + AllOfValidatorFactory::class, + true, + ); + + if ($isConjunctiveComposition && $composedProperty->getType() !== null) { throw new SchemaException( sprintf( "No nested schema for composed property %s in file %s found", @@ -718,10 +732,9 @@ public function transferComposedPropertiesToSchema(PropertyInterface $property, } // A branch with neither a nested schema nor an explicit type (e.g. a $ref - // to a definition carrying only annotation keywords such as example) - // matches any value and contributes no named properties to transfer - - // this is not a schema error, unlike a branch with an explicit type that - // still lacks a nested schema (a genuine type conflict, handled above). + // to a definition carrying only annotation keywords) matches any value and + // contributes no properties to transfer - not an error, unlike the + // conjunctive/typed conflict handled above. $this->finalizeComposedBranchResolution( in_array($composedProperty, $branchesForValidator, true), $totalBranches, diff --git a/src/Templates/Model.phptpl b/src/Templates/Model.phptpl index 14a20354..5d1f52eb 100644 --- a/src/Templates/Model.phptpl +++ b/src/Templates/Model.phptpl @@ -75,6 +75,10 @@ class {{ schema.getClassName() }}{% if schema.getInterfaces() %} implements {{ v $this->_executeBaseValidators($rawModelDataInput); {% endif %} + {% if schema.getPostCompositionValidators() %} + $this->_executePostCompositionValidators($rawModelDataInput); + {% endif %} + {% foreach schema.getProperties() as property %} {% if not property.isInternal() %} $this->_process{{ viewHelper.ucfirst(property.getAttribute()) }}($rawModelDataInput); @@ -93,6 +97,12 @@ class {{ schema.getClassName() }}{% if schema.getInterfaces() %} implements {{ v } {% if schema.getBaseValidators() %} + /** + * Runs property-level validators and composition validators against $modelData. Used + * for the constructor's full validation pass and for populate()'s per-property delta + * check. Schema-level validators that depend on the merged cross-property state live + * in _executePostCompositionValidators(). + */ protected function _executeBaseValidators(array &$modelData): void { $value = &$modelData; @@ -107,6 +117,24 @@ class {{ schema.getClassName() }}{% if schema.getInterfaces() %} implements {{ v } {% endif %} + {% if schema.getPostCompositionValidators() %} + /** + * Runs schema-level validators that depend on the merged state of every property — + * currently just unevaluatedProperties. The constructor calls this on the raw input + * after _executeBaseValidators; setters call it against a candidate state (raw input + * with the new value plugged in) before commit; populate() calls it against the + * would-be merged state before applying the merge. + */ + protected function _executePostCompositionValidators(array &$modelData): void + { + $value = &$modelData; + + {% foreach schema.getPostCompositionValidators() as validator %} + {{ viewHelper.renderValidator(validator, schema) }} + {% endforeach %} + } + {% endif %} + public function meta(): Meta { return $this->_metaAccessor ??= new Meta($this->_rawModelDataInput); @@ -168,6 +196,17 @@ class {{ schema.getClassName() }}{% if schema.getInterfaces() %} implements {{ v $value = $this->_validate{{ viewHelper.ucfirst(property.getAttribute()) }}($value, $modelData); + {% if schema.getPostCompositionValidators() %} + // Cross-state validation: build a candidate raw input with the new + // value plugged in so post-composition validators (notably + // unevaluatedProperties) see the would-be state instead of the still + // pre-mutation $this->_rawModelDataInput. Validates without committing — + // the commit below only runs after every validator passes. + $candidateRawModelDataInput = $this->_rawModelDataInput; + $candidateRawModelDataInput['{{ property.getName() }}'] = ${{ property.getAttribute(true) }}; + $this->_executePostCompositionValidators($candidateRawModelDataInput); + {% endif %} + {% if generatorConfiguration.collectErrors() %} if ($this->_errorRegistry->getErrors()) { throw $this->_errorRegistry; @@ -210,6 +249,13 @@ class {{ schema.getClassName() }}{% if schema.getInterfaces() %} implements {{ v */ protected function _validate{{ viewHelper.ucfirst(property.getAttribute()) }}($value, array $modelData) { + {% if viewHelper.hasUnevaluatedItemsValidator(property) %} + // Reset before any validator runs (including compositions, which may credit + // indices into this same slot during this pass) so a previous, unrelated + // validation pass's credits never leak into this one. + $this->_evaluatedItemIndices['{{ property.getName() }}'] = []; + {% endif %} + {% foreach property.getOrderedValidators() as validator %} {{ viewHelper.renderValidator(validator, schema, 2) }} {% endforeach %} diff --git a/src/Templates/Validator/AdditionalProperties.phptpl b/src/Templates/Validator/AdditionalProperties.phptpl index 464499fa..79958792 100644 --- a/src/Templates/Validator/AdditionalProperties.phptpl +++ b/src/Templates/Validator/AdditionalProperties.phptpl @@ -9,7 +9,7 @@ foreach (array_diff(array_keys($properties), {{ additionalProperties }}) as $propertyKey) { {% if patternProperties %} foreach ({{ patternProperties }} as $pattern) { - if (preg_match("/$pattern/", $property)) { + if (preg_match($pattern, $propertyKey)) { continue 2; } } @@ -37,11 +37,11 @@ {% if collectAdditionalProperties %} $this->_additionalProperties[$propertyKey] = $value; {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid additional properties isset($invalidProperties[$propertyKey]) - ? $invalidProperties[$propertyKey][] = $e - : $invalidProperties[$propertyKey] = [$e]; + ? $invalidProperties[$propertyKey][] = $exception + : $invalidProperties[$propertyKey] = [$exception]; } } diff --git a/src/Templates/Validator/ArrayContains.phptpl b/src/Templates/Validator/ArrayContains.phptpl index 91261edf..b6fb9480 100644 --- a/src/Templates/Validator/ArrayContains.phptpl +++ b/src/Templates/Validator/ArrayContains.phptpl @@ -1,4 +1,4 @@ -is_array($value) && (function (&$items){% if countMatches %} use (&$containsMatches){% endif %} { +is_array($value) && (function (&$items){% if countMatches or trackBranchMatches %} use ({% if countMatches %}&$containsMatches{% endif %}{% if countMatches and trackBranchMatches %}, {% endif %}{% if trackBranchMatches %}&$branchContainsMatches{% endif %}){% endif %} { if (empty($items)) { return {% if allowNoMatch %}false{% else %}true{% endif %}; } @@ -7,7 +7,11 @@ is_array($value) && (function (&$items){% if countMatches %} use (&$containsMatc $originalErrorRegistry = $this->_errorRegistry; {% endif %} - foreach ($items as &$value) { + {% if trackEvaluatedItems %} + $branchContainsMatches = []; + {% endif %} + + foreach ($items as $index => &$value) { try { {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); @@ -27,13 +31,18 @@ is_array($value) && (function (&$items){% if countMatches %} use (&$containsMatc $this->_errorRegistry = $originalErrorRegistry; {% endif %} + {% if trackBranchMatches or trackEvaluatedItems %} + $branchContainsMatches[$index] = true; + {% endif %} + {% if countMatches %} $containsMatches++; - {% else %} - // one matched item is enough to pass the contains check + {% endif %} + {% if not countMatches and not trackBranchMatches and not trackEvaluatedItems %} + // one matched item is enough; no need to keep iterating return false; {% endif %} - } catch (\Exception $e) { + } catch (\Exception) { continue; } } @@ -42,5 +51,20 @@ is_array($value) && (function (&$items){% if countMatches %} use (&$containsMatc $this->_errorRegistry = $originalErrorRegistry; {% endif %} - return {% if countMatches %}{% if allowNoMatch %}false{% else %}$containsMatches === 0{% endif %}{% else %}true{% endif %}; + {% if trackEvaluatedItems %} + // Credit every matched index to the property's evaluated-index set so a sibling + // unevaluatedItems validator sees those indices as evaluated. + $this->_evaluatedItemIndices['{{ trackEvaluatedItemsProperty }}'] = + ($this->_evaluatedItemIndices['{{ trackEvaluatedItemsProperty }}'] ?? []) + $branchContainsMatches; + {% endif %} + + {% if countMatches %} + return {% if allowNoMatch %}false{% else %}$containsMatches === 0{% endif %}; + {% else %} + {% if trackBranchMatches or trackEvaluatedItems %} + return empty($branchContainsMatches); + {% else %} + return true; + {% endif %} + {% endif %} })($value) \ No newline at end of file diff --git a/src/Templates/Validator/ArrayItem.phptpl b/src/Templates/Validator/ArrayItem.phptpl index 6c2f2181..fc871281 100644 --- a/src/Templates/Validator/ArrayItem.phptpl +++ b/src/Templates/Validator/ArrayItem.phptpl @@ -20,11 +20,11 @@ is_array($value) && (function (&$items) use (&$invalidItems{{ suffix }}, $modelD $invalidItems{{ suffix }}[$index] = $this->_errorRegistry->getErrors(); } {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid items isset($invalidItems{{ suffix }}[$index]) - ? $invalidItems{{ suffix }}[$index][] = $e - : $invalidItems{{ suffix }}[$index] = [$e]; + ? $invalidItems{{ suffix }}[$index][] = $exception + : $invalidItems{{ suffix }}[$index] = [$exception]; } } diff --git a/src/Templates/Validator/ArrayTuple.phptpl b/src/Templates/Validator/ArrayTuple.phptpl index c2c57ffa..d80260da 100644 --- a/src/Templates/Validator/ArrayTuple.phptpl +++ b/src/Templates/Validator/ArrayTuple.phptpl @@ -27,11 +27,11 @@ is_array($value) && (function (&$items) use (&$invalidTuples, $modelData) { $invalidTuples[$index] = $this->_errorRegistry->getErrors(); } {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid tuples isset($invalidTuples[$index]) - ? $invalidTuples[$index][] = $e - : $invalidTuples[$index] = [$e]; + ? $invalidTuples[$index][] = $exception + : $invalidTuples[$index] = [$exception]; } {% endforeach %} diff --git a/src/Templates/Validator/ComposedItem.phptpl b/src/Templates/Validator/ComposedItem.phptpl index 19566324..751cdf41 100644 --- a/src/Templates/Validator/ComposedItem.phptpl +++ b/src/Templates/Validator/ComposedItem.phptpl @@ -14,15 +14,32 @@ $proposedValue = null; $modifiedValues = []; + {% if compositionValidator.getSlotKey() %} + $slotKey = '{{ compositionValidator.getSlotKey() }}'; + $compositionEvaluatedIndices = []; + {% endif %} + {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} $originalPropertyValidationState = $this->_propertyValidationState ?? []; {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $originalCompositionEvaluations = $this->_compositionEvaluations ?? []; + {% endif %} + {% if generatorConfiguration.collectErrors() %} $originalErrorRegistry = $this->_errorRegistry; {% endif %} {% foreach compositionProperties as compositionProperty %} + {% if compositionValidator.isNotComposition() and compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $preNotBranchEvaluations = $this->_compositionEvaluations ?? []; + {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + $shouldTrack = true; + {% endif %} + try { {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} // check if the state of the validator is already known. @@ -30,15 +47,31 @@ if ( isset($validatorIndex) && isset($this->_propertyValidationState[$validatorIndex][$validatorComponentIndex]) && - !array_intersect( - array_keys($modifiedModelData), - [ - {% foreach compositionProperty.getAffectedObjectProperties() as affectedObjectProperty %} - '{{ affectedObjectProperty.getName() }}', - {% endforeach %} - ] - ) + {% if compositionProperty.branchEvaluationDependsOnUndeclaredKeys() %} + // Never cached: this branch carries a keyword decided by the shape or + // count of the instance's keys (additionalProperties, patternProperties, + // minProperties, ...), so a key it does not declare can still flip its + // outcome. A declared-name test cannot see such a key and would wrongly + // report "nothing relevant changed", skipping a branch that now fails. + false + {% else %} + !array_intersect( + array_keys($modifiedModelData), + [ + {% foreach compositionProperty.getAffectedObjectProperties() as affectedObjectProperty %} + '{{ affectedObjectProperty.getName() }}', + {% endforeach %} + ] + ) + {% endif %} ) { + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + // Cache hit: validators are not re-run, so $value still holds the + // pre-validator array rather than a nested-schema object. Skip the + // tracking write to preserve the slot value from the previous run. + $shouldTrack = false; + {% endif %} + {% if generatorConfiguration.collectErrors() %} $compositionErrorCollection[] = $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex]; {% endif %} @@ -52,20 +85,78 @@ throw new \Exception(); } } else { + {% if compositionValidator.getSlotKey() and compositionProperty.branchHasContains() %} + $branchContainsMatches = []; + {% endif %} + {{ viewHelper.indent(renderComposedItemBody(compositionProperty, true, hasModifiedValuesMethod, modifiedValuesMethod, postPropose), 3) }} } {% else %} + {% if compositionValidator.getSlotKey() and compositionProperty.branchHasContains() %} + $branchContainsMatches = []; + {% endif %} + {{ viewHelper.indent(renderComposedItemBody(compositionProperty, false, hasModifiedValuesMethod, modifiedValuesMethod, postPropose), 2) }} {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and compositionProperty.needsEvaluatedIndexAggregation() %} + if ($shouldTrack) { + {% if compositionProperty.branchHasItemsSchema() %} + $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); + {% endif %} + {% if not compositionProperty.branchHasItemsSchema() and compositionProperty.getBranchTupleItemsCount() %} + $compositionEvaluatedIndices += array_fill( + 0, + min({{ compositionProperty.getBranchTupleItemsCount() }}, count($originalModelData)), + true, + ); + {% endif %} + {% if not compositionProperty.branchHasItemsSchema() and compositionProperty.branchHasNonFalseAdditionalItems() %} + $compositionEvaluatedIndices += array_fill( + {{ compositionProperty.getBranchTupleItemsCount() }}, + max(0, count($originalModelData) - {{ compositionProperty.getBranchTupleItemsCount() }}), + true, + ); + {% endif %} + {% if compositionProperty.branchHasContains() %} + $compositionEvaluatedIndices += $branchContainsMatches; + {% endif %} + {% foreach compositionProperty.getNestedCompositionSlotKeys() as nestedCompositionSlotKey %} + // A composition nested directly inside this branch's own JSON (no + // intervening object type, so no nested Schema of its own) tracked its + // claimed indices under its own slot — union them into this branch's set. + $compositionEvaluatedIndices += $this->_compositionAnnotated['{{ nestedCompositionSlotKey }}'] ?? []; + {% endforeach %} + } + {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + if ($shouldTrack) { + {% if compositionProperty.branchClaimsAllKeys() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = true; + {% endif %} + {% if not compositionProperty.branchClaimsAllKeys() and compositionProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = is_object($value) ? $value : null; + {% endif %} + {% if not compositionProperty.branchClaimsAllKeys() and not compositionProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = $this->collectEvaluatedProperties( + $modelData, + {{ compositionProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ compositionProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); + {% endif %} + } + {% endif %} } catch (\Exception $e) { - {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) - and not generatorConfiguration.collectErrors() - %} + {% if viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) and not generatorConfiguration.collectErrors() %} if (isset($validatorIndex)) { $this->_propertyValidationState[$validatorIndex][$validatorComponentIndex] = false; } {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = null; + {% endif %} + {% if not generatorConfiguration.collectErrors() and not viewHelper.isMutableBaseValidator(generatorConfiguration, isBaseValidator) %} @@ -85,6 +176,13 @@ $succeededCompositionElements--; } + {% if compositionValidator.isNotComposition() and compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + // Restore composition evaluations so any annotations written by the not-branch body + // (including by nested UnevaluatedPropertiesValidators) cannot leak to the parent schema. + $this->_compositionEvaluations = $preNotBranchEvaluations; + $this->_compositionEvaluations[$validatorIndex][$validatorComponentIndex] = null; + {% endif %} + $value = $originalModelData; $validatorComponentIndex++; {% endforeach %} @@ -129,7 +227,13 @@ } } {% endif %} - $value = $proposedValue; + {# Array-side compositions (slotKey set) never produce a nested-instance + $proposedValue; writing $proposedValue back would replace the validated array + with null on whole-composition failure, suppressing a subsequent + unevaluatedItems check in collectErrors mode. #} + {% if not compositionValidator.getSlotKey() %} + $value = $proposedValue; + {% endif %} } else { $value = $originalModelData; } @@ -147,5 +251,15 @@ } {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() %} + {% if compositionValidator.getSlotKey() %} + $this->_compositionAnnotated[$slotKey] = $result ? [] : $compositionEvaluatedIndices; + {% else %} + if ($result) { + $this->_compositionEvaluations = $originalCompositionEvaluations; + } + {% endif %} + {% endif %} + return $result; })($value) diff --git a/src/Templates/Validator/ConditionalComposedItem.phptpl b/src/Templates/Validator/ConditionalComposedItem.phptpl index f4634953..69dd21de 100644 --- a/src/Templates/Validator/ConditionalComposedItem.phptpl +++ b/src/Templates/Validator/ConditionalComposedItem.phptpl @@ -5,14 +5,28 @@ &$modelData, &$ifException, &$thenException, - &$elseException + &$elseException, + &$validatorIndex ) { $originalModelData = $value; + + {% if compositionValidator.getSlotKey() %} + $slotKey = '{{ compositionValidator.getSlotKey() }}'; + $compositionEvaluatedIndices = []; + {% endif %} {% if generatorConfiguration.collectErrors() %} $originalErrorRegistry = $this->_errorRegistry; $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and ifProperty.getNestedSchema() %} + $capturedIfInstance = null; + {% endif %} + + {% if compositionValidator.getSlotKey() and ifProperty.branchHasContains() %} + $branchContainsMatches = []; + {% endif %} + try { {{ viewHelper.resolvePropertyDecorator(ifProperty, false, 2) }} @@ -25,17 +39,74 @@ throw $this->_errorRegistry; } {% endif %} - } catch (\Exception $e) { - $ifException = $e; + + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and ifProperty.getNestedSchema() %} + $capturedIfInstance = is_object($value) ? $value : null; + {% endif %} + } catch (\Exception $exception) { + $ifException = $exception; } + $value = $originalModelData; + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and ifProperty.branchIsArrayKind() %} + if (!$ifException) { + {% if ifProperty.branchHasItemsSchema() %} + $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); + {% endif %} + {% if not ifProperty.branchHasItemsSchema() and ifProperty.getBranchTupleItemsCount() %} + $compositionEvaluatedIndices += array_fill( + 0, + min({{ ifProperty.getBranchTupleItemsCount() }}, count($originalModelData)), + true, + ); + {% endif %} + {% if not ifProperty.branchHasItemsSchema() and ifProperty.branchHasNonFalseAdditionalItems() %} + $compositionEvaluatedIndices += array_fill( + {{ ifProperty.getBranchTupleItemsCount() }}, + max(0, count($originalModelData) - {{ ifProperty.getBranchTupleItemsCount() }}), + true, + ); + {% endif %} + {% if ifProperty.branchHasContains() %} + $compositionEvaluatedIndices += $branchContainsMatches; + {% endif %} + } + {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + if ($ifException) { + $this->_compositionEvaluations[$validatorIndex][0] = null; + } else { + {% if ifProperty.branchClaimsAllKeys() %} + $this->_compositionEvaluations[$validatorIndex][0] = true; + {% endif %} + {% if not ifProperty.branchClaimsAllKeys() and ifProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][0] = $capturedIfInstance; + {% endif %} + {% if not ifProperty.branchClaimsAllKeys() and not ifProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][0] = $this->collectEvaluatedProperties( + $modelData, + {{ ifProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ ifProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); + {% endif %} + } + {% endif %} + {% if generatorConfiguration.collectErrors() %} $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); {% endif %} if (!$ifException) { {% if thenProperty %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and thenProperty.getNestedSchema() %} + $capturedThenInstance = null; + {% endif %} + + {% if compositionValidator.getSlotKey() and thenProperty.branchHasContains() %} + $branchContainsMatches = []; + {% endif %} + try { {{ viewHelper.resolvePropertyDecorator(thenProperty, false, 3) }} @@ -48,12 +119,75 @@ throw $this->_errorRegistry; } {% endif %} - } catch (\Exception $e) { - $thenException = $e; + + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and thenProperty.getNestedSchema() %} + $capturedThenInstance = is_object($value) ? $value : null; + {% endif %} + } catch (\Exception $exception) { + $thenException = $exception; } + + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and thenProperty.branchIsArrayKind() %} + if (!$thenException) { + {% if thenProperty.branchHasItemsSchema() %} + $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); + {% endif %} + {% if not thenProperty.branchHasItemsSchema() and thenProperty.getBranchTupleItemsCount() %} + $compositionEvaluatedIndices += array_fill( + 0, + min({{ thenProperty.getBranchTupleItemsCount() }}, count($originalModelData)), + true, + ); + {% endif %} + {% if not thenProperty.branchHasItemsSchema() and thenProperty.branchHasNonFalseAdditionalItems() %} + $compositionEvaluatedIndices += array_fill( + {{ thenProperty.getBranchTupleItemsCount() }}, + max(0, count($originalModelData) - {{ thenProperty.getBranchTupleItemsCount() }}), + true, + ); + {% endif %} + {% if thenProperty.branchHasContains() %} + $compositionEvaluatedIndices += $branchContainsMatches; + {% endif %} + } + {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + if ($thenException) { + $this->_compositionEvaluations[$validatorIndex][1] = null; + } else { + {% if thenProperty.branchClaimsAllKeys() %} + $this->_compositionEvaluations[$validatorIndex][1] = true; + {% endif %} + {% if not thenProperty.branchClaimsAllKeys() and thenProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][1] = $capturedThenInstance; + {% endif %} + {% if not thenProperty.branchClaimsAllKeys() and not thenProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][1] = $this->collectEvaluatedProperties( + $modelData, + {{ thenProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ thenProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); + {% endif %} + } + {% endif %} + {% endif %} + {% if not thenProperty and compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $this->_compositionEvaluations[$validatorIndex][1] = null; + {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $this->_compositionEvaluations[$validatorIndex][2] = null; {% endif %} } else { {% if elseProperty %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and elseProperty.getNestedSchema() %} + $capturedElseInstance = null; + {% endif %} + + {% if compositionValidator.getSlotKey() and elseProperty.branchHasContains() %} + $branchContainsMatches = []; + {% endif %} + try { {{ viewHelper.resolvePropertyDecorator(elseProperty, false, 3) }} @@ -66,9 +200,64 @@ throw $this->_errorRegistry; } {% endif %} - } catch (\Exception $e) { - $elseException = $e; + + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() and elseProperty.getNestedSchema() %} + $capturedElseInstance = is_object($value) ? $value : null; + {% endif %} + } catch (\Exception $exception) { + $elseException = $exception; } + + {% if compositionValidator.hasEvaluationTrackingEnabled() and compositionValidator.getSlotKey() and elseProperty.branchIsArrayKind() %} + if (!$elseException) { + {% if elseProperty.branchHasItemsSchema() %} + $compositionEvaluatedIndices += array_fill_keys(array_keys($originalModelData), true); + {% endif %} + {% if not elseProperty.branchHasItemsSchema() and elseProperty.getBranchTupleItemsCount() %} + $compositionEvaluatedIndices += array_fill( + 0, + min({{ elseProperty.getBranchTupleItemsCount() }}, count($originalModelData)), + true, + ); + {% endif %} + {% if not elseProperty.branchHasItemsSchema() and elseProperty.branchHasNonFalseAdditionalItems() %} + $compositionEvaluatedIndices += array_fill( + {{ elseProperty.getBranchTupleItemsCount() }}, + max(0, count($originalModelData) - {{ elseProperty.getBranchTupleItemsCount() }}), + true, + ); + {% endif %} + {% if elseProperty.branchHasContains() %} + $compositionEvaluatedIndices += $branchContainsMatches; + {% endif %} + } + {% endif %} + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + if ($elseException) { + $this->_compositionEvaluations[$validatorIndex][2] = null; + } else { + {% if elseProperty.branchClaimsAllKeys() %} + $this->_compositionEvaluations[$validatorIndex][2] = true; + {% endif %} + {% if not elseProperty.branchClaimsAllKeys() and elseProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][2] = $capturedElseInstance; + {% endif %} + {% if not elseProperty.branchClaimsAllKeys() and not elseProperty.getNestedSchema() %} + $this->_compositionEvaluations[$validatorIndex][2] = $this->collectEvaluatedProperties( + $modelData, + {{ elseProperty.getBranchDeclaredPropertyNamesPhpLiteral() }}, + {{ elseProperty.getBranchPatternPropertyPatternsPhpLiteral() }}, + ); + {% endif %} + } + {% endif %} + {% endif %} + {% if not elseProperty and compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $this->_compositionEvaluations[$validatorIndex][2] = null; + {% endif %} + + {% if compositionValidator.hasEvaluationTrackingEnabled() and not compositionValidator.getSlotKey() %} + $this->_compositionEvaluations[$validatorIndex][1] = null; {% endif %} } @@ -104,6 +293,8 @@ } {% endif %} + $conditionalResult = $thenException || $elseException; + {# A failed then/else branch may have left $value as the leftover result of a nested object instantiation attempt (an exception object, when the decorator's instantiation try/catch @@ -114,9 +305,13 @@ needs the branch's transformed value, and a successful branch's instantiated object is the correct resulting value for a property whose shape is defined entirely by that branch. #} - if ($thenException || $elseException) { + if ($conditionalResult) { $value = $originalModelData; } - return $thenException || $elseException; + {% if compositionValidator.getSlotKey() %} + $this->_compositionAnnotated[$slotKey] = $conditionalResult ? [] : $compositionEvaluatedIndices; + {% endif %} + + return $conditionalResult; })($value) diff --git a/src/Templates/Validator/NoUnevaluatedItems.phptpl b/src/Templates/Validator/NoUnevaluatedItems.phptpl new file mode 100644 index 00000000..496981fc --- /dev/null +++ b/src/Templates/Validator/NoUnevaluatedItems.phptpl @@ -0,0 +1,7 @@ +is_array($value) && ($unevaluatedItems = $this->collectUnevaluatedIndices( + $value, + '{{ arrayPropertyName }}', + {{ compositionSlotKeys }}, + {{ siblingTupleItemsCount }}, + {{ siblingCoversTail }}, +)) diff --git a/src/Templates/Validator/NoUnevaluatedProperties.phptpl b/src/Templates/Validator/NoUnevaluatedProperties.phptpl new file mode 100644 index 00000000..52865a2e --- /dev/null +++ b/src/Templates/Validator/NoUnevaluatedProperties.phptpl @@ -0,0 +1,6 @@ +$unevaluatedProperties = $this->collectUnevaluatedKeys( + $modelData, + {{ declaredPropertyNames }}, + {{ pcrePatterns }}, + {{ compositionValidatorKeys }}, +) diff --git a/src/Templates/Validator/PatternProperties.phptpl b/src/Templates/Validator/PatternProperties.phptpl index f2e8d71c..0af3c2e9 100644 --- a/src/Templates/Validator/PatternProperties.phptpl +++ b/src/Templates/Validator/PatternProperties.phptpl @@ -31,11 +31,11 @@ if (!isset($this->_patternPropertiesMap[$propertyKey])) { $this->_patternProperties['{{ patternHash }}'][$propertyKey] = $value; } - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid pattern properties isset($invalidProperties[$propertyKey]) - ? $invalidProperties[$propertyKey][] = $e - : $invalidProperties[$propertyKey] = [$e]; + ? $invalidProperties[$propertyKey][] = $exception + : $invalidProperties[$propertyKey] = [$exception]; } } diff --git a/src/Templates/Validator/PropertyNames.phptpl b/src/Templates/Validator/PropertyNames.phptpl index 44809d44..0d8987e0 100644 --- a/src/Templates/Validator/PropertyNames.phptpl +++ b/src/Templates/Validator/PropertyNames.phptpl @@ -21,11 +21,11 @@ $invalidProperties[$value] = $this->_errorRegistry->getErrors(); } {% endif %} - } catch (\Exception $e) { + } catch (\Exception $exception) { // collect all errors concerning invalid property names isset($invalidProperties[$value]) - ? $invalidProperties[$value][] = $e - : $invalidProperties[$value] = [$e]; + ? $invalidProperties[$value][] = $exception + : $invalidProperties[$value] = [$exception]; } } diff --git a/src/Templates/Validator/UnevaluatedItems.phptpl b/src/Templates/Validator/UnevaluatedItems.phptpl new file mode 100644 index 00000000..0111ca63 --- /dev/null +++ b/src/Templates/Validator/UnevaluatedItems.phptpl @@ -0,0 +1,55 @@ +is_array($value) && (function (&$items) use (&$invalidItems, $modelData): bool { + {% if generatorConfiguration.collectErrors() %} + $originalErrorRegistry = $this->_errorRegistry; + {% endif %} + $rollbackEvaluatedItemIndices = $this->_evaluatedItemIndices; + + $unevaluatedIndices = $this->collectUnevaluatedIndices( + $items, + '{{ arrayPropertyName }}', + {{ compositionSlotKeys }}, + {{ siblingTupleItemsCount }}, + {{ siblingCoversTail }}, + ); + + foreach ($unevaluatedIndices as $index) { + try { + $value = &$items[$index]; + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% endif %} + + {{ viewHelper.resolvePropertyDecorator(validationProperty) }} + + {% foreach validationProperty.getOrderedValidators() as validator %} + {{ viewHelper.renderValidator(validator, schema) }} + {% endforeach %} + + {% if generatorConfiguration.collectErrors() %} + if ($this->_errorRegistry->getErrors()) { + $invalidItems[$index] = $this->_errorRegistry->getErrors(); + continue; + } + {% endif %} + + // The index validated against the unevaluatedItems subschema — record it so an + // enclosing schema's unevaluatedItems accumulator can credit it. + $this->_evaluatedItemIndices['{{ arrayPropertyName }}'][$index] = true; + } catch (\Exception $exception) { + isset($invalidItems[$index]) + ? $invalidItems[$index][] = $exception + : $invalidItems[$index] = [$exception]; + } + } + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = $originalErrorRegistry; + {% endif %} + + if (!empty($invalidItems)) { + $this->_evaluatedItemIndices = $rollbackEvaluatedItemIndices; + } + + return !empty($invalidItems); +})($value) diff --git a/src/Templates/Validator/UnevaluatedProperties.phptpl b/src/Templates/Validator/UnevaluatedProperties.phptpl new file mode 100644 index 00000000..8ee09cb9 --- /dev/null +++ b/src/Templates/Validator/UnevaluatedProperties.phptpl @@ -0,0 +1,65 @@ +(function () use ($properties, &$invalidProperties, $modelData): bool { + {% if generatorConfiguration.collectErrors() %} + $originalErrorRegistry = $this->_errorRegistry; + {% endif %} + {% if collectUnevaluatedProperties %} + $rollbackUnevaluatedProperties = $this->_unevaluatedProperties; + {% endif %} + $rollbackEvaluatedPropertyKeys = $this->_evaluatedPropertyKeys; + + $unevaluatedKeys = $this->collectUnevaluatedKeys( + $modelData, + {{ declaredPropertyNames }}, + {{ pcrePatterns }}, + {{ compositionValidatorKeys }}, + ); + + foreach ($unevaluatedKeys as $propertyKey) { + try { + $value = $properties[$propertyKey]; + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = new {{ viewHelper.getSimpleClassName(generatorConfiguration.getErrorRegistryClass()) }}(); + {% endif %} + + {{ viewHelper.resolvePropertyDecorator(validationProperty) }} + + {% foreach validationProperty.getOrderedValidators() as validator %} + {{ viewHelper.renderValidator(validator, schema) }} + {% endforeach %} + + {% if generatorConfiguration.collectErrors() %} + if ($this->_errorRegistry->getErrors()) { + $invalidProperties[$propertyKey] = $this->_errorRegistry->getErrors(); + + continue; + } + {% endif %} + + // The key validated against the unevaluatedProperties subschema — record it so an + // enclosing schema's unevaluatedProperties accumulator can credit it. + $this->_evaluatedPropertyKeys[$propertyKey] = true; + + {% if collectUnevaluatedProperties %} + $this->_unevaluatedProperties[$propertyKey] = $value; + {% endif %} + } catch (\Exception $exception) { + isset($invalidProperties[$propertyKey]) + ? $invalidProperties[$propertyKey][] = $exception + : $invalidProperties[$propertyKey] = [$exception]; + } + } + + {% if generatorConfiguration.collectErrors() %} + $this->_errorRegistry = $originalErrorRegistry; + {% endif %} + + if (!empty($invalidProperties)) { + {% if collectUnevaluatedProperties %} + $this->_unevaluatedProperties = $rollbackUnevaluatedProperties; + {% endif %} + $this->_evaluatedPropertyKeys = $rollbackEvaluatedPropertyKeys; + } + + return !empty($invalidProperties); +})() diff --git a/src/Utils/ArrayHash.php b/src/Utils/ArrayHash.php index d41d583c..3c8ddcfc 100644 --- a/src/Utils/ArrayHash.php +++ b/src/Utils/ArrayHash.php @@ -9,7 +9,7 @@ class ArrayHash public static function hash(array $array, array $relevantFields = []): string { if ($relevantFields) { - foreach ($array as $key => $_) { + foreach (array_keys($array) as $key) { if (!in_array($key, $relevantFields)) { unset($array[$key]); } diff --git a/src/Utils/Json/JsonPointerLocator.php b/src/Utils/Json/JsonPointerLocator.php index 2939d32a..82e82e20 100644 --- a/src/Utils/Json/JsonPointerLocator.php +++ b/src/Utils/Json/JsonPointerLocator.php @@ -4,7 +4,7 @@ namespace PHPModelGenerator\Utils\Json; -use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Utils\JsonSchema; /** * Resolves an RFC 6901 JSON pointer against raw JSON source text to the source position of the diff --git a/src/Utils/JsonSchema.php b/src/Utils/JsonSchema.php new file mode 100644 index 00000000..a5d9eecf --- /dev/null +++ b/src/Utils/JsonSchema.php @@ -0,0 +1,57 @@ +getJsonSchema()->getPointer(), which would silently diverge if + * PropertyMerger's choice of JsonSchema changed. + */ + public static function resolvePrimaryJsonPointer(PropertyInterface $property): string + { + foreach ($property->getAttributes() as $attribute) { + if ($attribute->getFqcn() === JsonPointer::class) { + return (string) $attribute->getArguments()[0]; + } + } + + return $property->getJsonSchema()->getPointer(); + } +} diff --git a/src/Utils/RenderHelper.php b/src/Utils/RenderHelper.php index a0ee6d79..6145c0da 100644 --- a/src/Utils/RenderHelper.php +++ b/src/Utils/RenderHelper.php @@ -10,6 +10,7 @@ use PHPModelGenerator\Model\Validator\ExtractedMethodValidator; use PHPModelGenerator\Model\Validator\PropertyTemplateValidator; use PHPModelGenerator\Model\Validator\PropertyValidatorInterface; +use PHPModelGenerator\Model\Validator\UnevaluatedItemsValidator; /** * Class RenderHelper @@ -230,6 +231,27 @@ public function isMutableBaseValidator(GeneratorConfiguration $generatorConfigur return !$generatorConfiguration->isImmutable() && $isBaseValidator; } + /** + * True when the property carries an UnevaluatedItemsValidator among its own validators. + * + * `_evaluatedItemIndices[$propertyName]` must be reset to an empty array before this + * property's validator chain runs (ahead of any composition validator, which may credit + * indices into the same slot during this same pass) — otherwise indices credited by a + * previous, unrelated validation pass (e.g. an earlier setter call) would incorrectly count + * as "already evaluated" for a completely different array value, silently skipping both + * validation and any transforming filter for that index. + */ + public function hasUnevaluatedItemsValidator(PropertyInterface $property): bool + { + foreach ($property->getValidators() as $propertyValidator) { + if ($propertyValidator->getValidator() instanceof UnevaluatedItemsValidator) { + return true; + } + } + + return false; + } + /** * Render $value as a PHP literal suitable for embedding directly in generated code: short-array ([...]) syntax * for arrays (recursively, preserving string keys for associative arrays), plain var_export() for everything @@ -263,6 +285,41 @@ private static function exportScalar(mixed $value): string return $value === null ? 'null' : var_export($value, true); } + /** + * Returns a PHP array literal whose values are each raw $patterns entry wrapped with `/` + * delimiters and with any embedded `/` characters escaped so they cannot terminate the + * delimiter early. The result is ready for direct `preg_match` use at runtime. + * + * @param string[] $rawPatterns Raw patternProperties regexes from a JSON Schema. + */ + public static function varExportPcrePatterns(array $rawPatterns): string + { + return self::varExportArray( + array_map( + static fn(string $rawPattern): string => '/' . addcslashes($rawPattern, '/') . '/', + $rawPatterns, + ), + ); + } + + /** + * Returns a PHP array literal keyed by each raw pattern wrapped for direct `preg_match` + * use. Distinct from varExportPcrePatterns because the caller needs the values (not the + * numeric indices) so a per-pattern piece of metadata such as a JSON pointer can be looked + * up at runtime by the matched pattern. + * + * @param array $rawPatternMap Keyed by raw regex; value is preserved verbatim. + */ + public static function varExportPcrePatternMap(array $rawPatternMap): string + { + $keyed = []; + foreach ($rawPatternMap as $rawPattern => $value) { + $keyed['/' . addcslashes((string) $rawPattern, '/') . '/'] = $value; + } + + return var_export($keyed, true); + } + public static function filterClassImports(array $imports, string $namespace): array { // filter out non-compound uses and uses which link to the current namespace diff --git a/src/Utils/TypeCheck.php b/src/Utils/TypeCheck.php index 14fe927d..3a59604a 100644 --- a/src/Utils/TypeCheck.php +++ b/src/Utils/TypeCheck.php @@ -79,30 +79,35 @@ public static function buildNegatedCompound(array $typeNames): string /** * Build a negated runtime check for a single JSON Schema type name, as used by the "type" - * keyword (TypeCheckValidator, and MultiTypeCheckValidator via ReflectionTypeCheckValidator - - * both always call this with exactly one type name per instance). + * keyword. Differs from negating buildCheck() for "array"/"object" because + * json_decode($x, true) maps both an empty JSON object `{}` and an empty JSON array `[]` to + * the same empty PHP array - see ObjectInstantiationDecorator.phptpl for the same ambiguity + * handled on the instantiation side. "array" requires array_is_list($value) to reject a PHP + * map masquerading as an array. This must NOT bleed into buildCheck()/buildCompound()/ + * buildNegatedCompound(), which are general PHP-type checks for filter input/output and must + * keep accepting any PHP array. * - * Identical to negating buildCheck() except for "array": a JSON object and a JSON array both - * decode to a PHP array via json_decode($x, true), so "type": "array" must additionally - * require array_is_list($value) to reject a JSON object represented as a PHP map. No special - * case is needed for an empty array: array_is_list() already treats [] as a list, so an empty - * JSON array correctly satisfies "type": "array" here. (An empty JSON *object* satisfying - * "type": "array" too is the inherent, accepted {} vs [] limitation - see - * ObjectInstantiationDecorator.phptpl, which carries the opposite-direction carve-out so an - * empty object is still accepted for "type": "object".) - * - * This must NOT bleed into buildCheck()/buildCompound()/buildNegatedCompound(), which - * represent general PHP-type checks for filter input/output types - a filter declaring - * "array" as an accepted PHP type must keep accepting any PHP array, list or map, since it - * operates on already-decoded PHP values rather than re-deriving JSON Schema type semantics. + * $treatObjectAsUninstantiatedShape applies the mirror-image "object" carve-out + * (`$value === []` also counts as an object), needed only by MultiTypeCheckValidator: when a + * multi-type property's own composition validator - not ObjectInstantiationDecorator - owns + * instantiation, "object" candidacy must be checked against the raw, not-yet-instantiated + * value. Without the carve-out, `{}` is wrongly rejected as "not an object" whenever the + * property's other candidate type isn't "array" too (pairing with "array" masks the gap, + * since `[]` then legitimately matches that candidate anyway). */ - public static function buildNegatedJsonSchemaTypeCheck(string $typeName): string - { - if ($typeName !== 'array') { - return self::buildNegatedCompound([$typeName]); + public static function buildNegatedJsonSchemaTypeCheck( + string $typeName, + bool $treatObjectAsUninstantiatedShape = false, + ): string { + if ($typeName === 'array') { + return '!(is_array($value) && array_is_list($value))'; + } + + if ($typeName === 'object' && $treatObjectAsUninstantiatedShape) { + return '!(is_object($value) || (is_array($value) && (!array_is_list($value) || $value === [])))'; } - return '!(is_array($value) && array_is_list($value))'; + return self::buildNegatedCompound([$typeName]); } /** diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index acb81702..1570a3c0 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -33,11 +33,6 @@ use ReflectionType; use ReflectionUnionType; -/** - * Class AbstractPHPModelGeneratorTest - * - * @package PHPModelGenerator\Tests\Objects - */ abstract class AbstractPHPModelGeneratorTestCase extends TestCase { protected const EXTERNAL_JSON_DIRECTORIES = []; @@ -50,6 +45,16 @@ abstract class AbstractPHPModelGeneratorTestCase extends TestCase protected string $lastGeneratedNamespacePrefix = ''; + /** + * Destination directories copyExternalJSON() has written outside TEST_BASE_DIR (see its + * docblock), tracked so registerExternalJsonCleanup()'s shutdown function can remove them. + * + * @var array + */ + private static array $externalJsonCopyRoots = []; + + private static bool $externalJsonCleanupRegistered = false; + /** * Set up an empty directory for the tests */ @@ -105,21 +110,96 @@ public function tearDown(): void } /** - * Copy given external JSON schema files into the tmp directory to make them available during model generation + * Copy given external JSON schema files into the tmp directory to make them available during + * model generation. + * + * The destination must sit one level above TEST_BASE_DIR, not inside it: an + * EXTERNAL_JSON_DIRECTORIES entry (e.g. "../SomeTest_external") is relative to the fixture's + * schema directory, which is also how the fixture's own `$ref` keywords reference it - + * resolved at generation time relative to TEST_BASE_DIR, where generateClass() writes the + * temporary schema file. + * + * That destination sits outside TEST_BASE_DIR, so bootstrap.php's shutdown cleanup never + * touches it - left alone it accumulates forever in the shared system temp directory. Each + * copy is therefore (a) preceded by clearing any pre-existing content at the destination, and + * (b) tracked in self::$externalJsonCopyRoots for registerExternalJsonCleanup() to remove at + * process exit. Known residual gap: the destination name is not session-unique (it can't be, + * without also rewriting every fixture's `$ref` strings), so two fully concurrent test-suite + * processes can still collide - accepted, since this only closes the never-cleaned + * accumulation a single/sequential run hits. */ private function copyExternalJSON(): void { - $baseDir = TEST_BASE_DIR . DIRECTORY_SEPARATOR; $copyBaseDir = __DIR__ . "/Schema/{$this->getStaticClassName()}/"; foreach (static::EXTERNAL_JSON_DIRECTORIES as $directory) { - $di = new RecursiveDirectoryIterator($copyBaseDir . $directory, FilesystemIterator::SKIP_DOTS); + $sourceRoot = realpath($copyBaseDir . $directory); + + if ($sourceRoot === false) { + continue; + } + + $destinationRoot = dirname(TEST_BASE_DIR) . DIRECTORY_SEPARATOR . basename($sourceRoot); + + self::$externalJsonCopyRoots[$destinationRoot] = true; + self::registerExternalJsonCleanup(); + self::removeDirectoryRecursively($destinationRoot); + + $sourceIterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($sourceRoot, FilesystemIterator::SKIP_DOTS), + ); + + foreach ($sourceIterator as $file) { + $destination = $destinationRoot . DIRECTORY_SEPARATOR + . substr((string) $file, strlen($sourceRoot) + 1); + + @mkdir(dirname($destination), 0777, true); + @copy((string) $file, $destination); + } + } + } + + /** + * Registers, at most once per process, a shutdown function that removes every destination + * copyExternalJSON() has recorded. Warnings inside must stay suppressed (@-operators in + * removeDirectoryRecursively()): this runs after the last test finishes, with no TestCase on + * the call stack, so an unsuppressed warning would crash the run via PHPUnit's + * NoTestCaseObjectOnCallStackException instead of just leaving the directory behind. + */ + private static function registerExternalJsonCleanup(): void + { + if (self::$externalJsonCleanupRegistered) { + return; + } + + self::$externalJsonCleanupRegistered = true; - foreach (new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST) as $file) { - @mkdir($baseDir . dirname(str_replace($copyBaseDir, '', (string) $file)), 0777, true); - @copy((string) $file, $baseDir . str_replace($copyBaseDir, '', (string) $file)); + register_shutdown_function(static function (): void { + foreach (array_keys(self::$externalJsonCopyRoots) as $destinationRoot) { + self::removeDirectoryRecursively($destinationRoot); } + }); + } + + /** + * A no-op when $directory does not exist. + */ + private static function removeDirectoryRecursively(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + $file->isDir() ? @rmdir($file->getRealPath()) : @unlink($file->getRealPath()); } + + @rmdir($directory); } /** @@ -193,7 +273,8 @@ protected function generateClass( string $schemaProviderClass = RecursiveDirectoryProvider::class, ): string { $generatorConfiguration = clone ( - $generatorConfiguration ?? (new GeneratorConfiguration())->setCollectErrors(false) + $generatorConfiguration ?? (new GeneratorConfiguration()) + ->setCollectErrors(false) ); $generatorConfiguration->setImplicitNull($implicitNull); @@ -613,6 +694,41 @@ protected function getGeneratedFiles(): array return $this->generatedFiles; } + /** + * Resolves the names of classes the generator emitted alongside a parent class — i.e. + * files whose basename starts with the parent class name followed by an underscore. This + * is the naming pattern the generator applies to nested schemas produced by properties, + * composition branches, and `patternProperties` / `additionalProperties` value-subschemas. + * The expected count is asserted so a change in the generator's naming scheme surfaces as + * a clear failure rather than a downstream regex mismatch. + * + * Returns a plain string when exactly one nested class is expected (the ergonomic common + * case for exception-message assertions); returns a list of names otherwise. + * + * @return string|string[] + */ + protected function resolveNestedClassName(string $parentClassName, int $expectedCount = 1): string | array + { + $prefix = $parentClassName . '_'; + $nestedFiles = array_values(array_filter( + $this->generatedFiles, + static fn(string $path): bool => str_starts_with(basename($path), $prefix), + )); + + $this->assertCount( + $expectedCount, + $nestedFiles, + sprintf('expected %d nested class file(s) for %s', $expectedCount, $parentClassName), + ); + + $classNames = array_map( + static fn(string $path): string => str_replace('.php', '', basename($path)), + $nestedFiles, + ); + + return $expectedCount === 1 ? $classNames[0] : $classNames; + } + protected function getSchemaFilePath(string $file): string { return __DIR__ . '/Schema/' . $this->getStaticClassName() . '/' . $file; diff --git a/tests/Basic/BasicSchemaGenerationTest.php b/tests/Basic/BasicSchemaGenerationTest.php index ff4a29ac..6c777882 100644 --- a/tests/Basic/BasicSchemaGenerationTest.php +++ b/tests/Basic/BasicSchemaGenerationTest.php @@ -135,6 +135,39 @@ public function testReadOnlyPropertyDoesntGenerateSetter(): void $this->assertTrue(is_callable([$object, 'setNoReadOnly'])); } + /** + * A schema whose derived class name collides with a PHP-reserved class name (`readonly` is + * reserved since PHP 8.1 - see https://www.php.net/manual/en/reserved.other-reserved-words.php) + * currently generates uncompilable PHP: the class is written to disk, then RenderJob::render() + * `require`s it, and PHP throws `ParseError: syntax error, unexpected token "readonly", + * expecting identifier` - not a `SchemaException` raised cleanly at generation time. + * + * Explicit inline `"title": "ReadOnly"` plus `$originalClassNames: true` is required to + * reproduce this through the test harness: `generateClassFromFile()`'s default path always + * writes the schema to a temp file named after the *test's own* generated class name (see + * AbstractPHPModelGeneratorTestCase::generateClass()) regardless of the flag, so the + * `ReadOnly.json` fixture used elsewhere in this file never actually reaches the generator + * under the name "ReadOnly" - only an explicit `title` (which the class-name generator + * prioritizes over the filename) reliably forces the collision. + * + * Unrelated to the `readOnly` JSON Schema keyword under test elsewhere in this file; this is + * purely about a schema's own name being reused, unqualified, as the derived PHP class name. + * Tracked in .claude/topics/reserved-php-classname-collision/ as a deferred, separate topic - + * out of scope for whatever change happens to be touching this file. Asserts the intended + * behavior (a SchemaException raised at generation time); currently fails because the real + * exception is a ParseError raised later, from require(). + */ + public function testClassNameCollidingWithPhpReservedWordThrowsSchemaException(): void + { + $this->expectException(SchemaException::class); + + $this->generateClass( + '{"title": "ReadOnly", "type": "object", "properties": {"name": {"type": "string"}}}', + null, + true, + ); + } + public function testSetterChangeTheInternalState(): void { $className = $this->generateClassFromFile( diff --git a/tests/Basic/PatternPropertiesTest.php b/tests/Basic/PatternPropertiesTest.php index 5422ef6e..b8986f40 100644 --- a/tests/Basic/PatternPropertiesTest.php +++ b/tests/Basic/PatternPropertiesTest.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Tests\Basic; use PHPModelGenerator\Exception\ErrorRegistryException; +use PHPModelGenerator\Exception\Object\InvalidAdditionalPropertiesException; use PHPModelGenerator\Exception\Object\InvalidPatternPropertiesException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; @@ -277,4 +278,48 @@ public function testObjectTypedPatternPropertiesAreValidated(): void $this->assertSame('Bob', $bob->getName()); $this->assertNull($bob->getAge()); } + + /** + * `additionalProperties: {schema}` combined with a sibling `patternProperties` must route + * a pattern-matching key through the pattern's own subschema and only fall through to + * `additionalProperties` for a key the pattern does not match. Regression guard for + * `AdditionalProperties.phptpl`'s pattern-exclusion loop, which referenced an undefined + * `$property` instead of the enclosing `foreach`'s `$propertyKey` — every key, matching or + * not, hit `preg_match($pattern, null)`, which throws a `TypeError` under PHP 8's strict + * internal-function typing. The `catch (\Exception $exception)` around the per-key + * validation body does not catch `TypeError` (it extends `Error`, not `Exception`), so the + * bug was an uncaught fatal crash on construction for any input, not just a wrong-validator + * result — this is why the fixture has no `properties` block at all: even a key matching + * neither `properties` nor the pattern must be reachable to exercise the fallthrough. + */ + public function testAdditionalPropertiesSchemaValidatesKeysThePatternDoesNotMatch(): void + { + $className = $this->generateClassFromFile('AdditionalPropertiesSchemaWithSiblingPattern.json'); + + // Pattern-matching key validated against the pattern's own string schema. + new $className(['x_foo' => 'hello']); + + // Non-matching key validated against additionalProperties' integer schema. + new $className(['other' => 5]); + + try { + new $className(['x_foo' => 123]); + $this->fail('Expected InvalidPatternPropertiesException for a non-string pattern match'); + } catch (InvalidPatternPropertiesException $exception) { + $this->assertStringContainsString( + "Invalid type for 'pattern property': requires 'string', got 'integer'", + $exception->getMessage(), + ); + } + + try { + new $className(['other' => 'not-an-int']); + $this->fail('Expected InvalidAdditionalPropertiesException for a non-integer fallthrough key'); + } catch (InvalidAdditionalPropertiesException $exception) { + $this->assertStringContainsString( + "Invalid type for 'additional property': requires 'int', got 'string'", + $exception->getMessage(), + ); + } + } } diff --git a/tests/Basic/SchemaDependencyTest.php b/tests/Basic/SchemaDependencyTest.php index 91eaa3f7..196f089a 100644 --- a/tests/Basic/SchemaDependencyTest.php +++ b/tests/Basic/SchemaDependencyTest.php @@ -202,63 +202,74 @@ public static function validSchemaDependencyCompositionDataProvider(): array #[DataProvider('invalidSchemaDependencyCompositionDataProvider')] public function testInvalidSchemaDependencyComposition( array $propertyValue, - string $message, + string $messageTemplate, ): void { - $this->expectException(ErrorRegistryException::class); - $this->expectExceptionMessageMatches("/$message/m"); - $className = $this->generateClassFromFile( 'CompositionSchemaDependency.json', (new GeneratorConfiguration())->setCollectErrors(true), ); + // Generation emits three nested classes: the dependency wrapper (named + // *_credit_card_Dependency) plus one class per allOf branch. Only the wrapper + // appears in the composition-decline error, so filter for it explicitly. + $nestedClasses = $this->resolveNestedClassName($className, 3); + $wrapperMatches = array_values(array_filter( + $nestedClasses, + static fn(string $name): bool => str_ends_with($name, '_credit_card_Dependency'), + )); + $this->assertCount(1, $wrapperMatches, 'expected exactly one dependency wrapper class'); + $dependencyClass = $wrapperMatches[0]; + + $this->expectException(ErrorRegistryException::class); + $this->expectExceptionMessage( + str_replace('{dependencyClass}', $dependencyClass, $messageTemplate), + ); + new $className($propertyValue); } - // phpcs:disable Generic.Files.LineLength.TooLong public static function invalidSchemaDependencyCompositionDataProvider(): array { return [ 'required attribute not provided 1' => [ ['credit_card' => 12345], - << [ ['credit_card' => 12345, 'age' => 42], - << [ + 'invalid data type' => [ ['credit_card' => 12345, 'name' => false, 'age' => 42], - <<generateClassFromFile( + 'AllOfTupleBranches.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['tags' => ['alpha', 'beta']]); + + try { + $object->setTags(['alpha', 'beta', 'gamma']); + $this->fail('Expected setTags to throw because index 2 is unevaluated'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + $this->assertSame(['alpha', 'beta'], $object->getTags()); + $this->assertSame(['tags' => ['alpha', 'beta']], $object->meta()->rawInput()); + + $object->setTags(['solo']); + $this->assertSame(['solo'], $object->getTags()); + $this->assertSame(['tags' => ['solo']], $object->meta()->rawInput()); + } + + /** + * populate() with array data that changes the array's length: + * - a longer array introduces a tail index neither branch covers; populate must throw + * and roll the model back to its pre-populate state; + * - a shorter array (or the same length) whose indices are all covered succeeds and + * the new value is observable. + */ + public function testPopulateLengthChangeRevalidatesUnevaluatedItems(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new PopulatePostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'AllOfTupleBranches.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['tags' => ['alpha', 'beta']]); + + try { + $object->populate(['tags' => ['alpha', 'beta', 'gamma']]); + $this->fail('Expected populate to throw because index 2 is unevaluated'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + $this->assertSame(['alpha', 'beta'], $object->getTags()); + $this->assertSame(['tags' => ['alpha', 'beta']], $object->meta()->rawInput()); + + $object->populate(['tags' => ['solo']]); + $this->assertSame(['solo'], $object->getTags()); + $this->assertSame(['tags' => ['solo']], $object->meta()->rawInput()); + } +} diff --git a/tests/Basic/UnevaluatedItemsValidatorTest.php b/tests/Basic/UnevaluatedItemsValidatorTest.php new file mode 100644 index 00000000..1278f925 --- /dev/null +++ b/tests/Basic/UnevaluatedItemsValidatorTest.php @@ -0,0 +1,1178 @@ +rawInput()` unchanged. + * + * @return array}> + */ + public static function acceptanceProvider(): array + { + return [ + 'unevaluatedItems: false with empty array' => [ + 'NoOtherConstraintsFalse.json', + ['tags' => []], + ], + 'unevaluatedItems: false with array property absent' => [ + 'NoOtherConstraintsFalse.json', + [], + ], + 'unevaluatedItems schema-form accepts matching values' => [ + 'NoOtherConstraintsSchema.json', + ['tags' => ['alpha', 'beta', 'gamma']], + ], + 'unevaluatedItems schema-form accepts empty array' => [ + 'NoOtherConstraintsSchema.json', + ['tags' => []], + ], + ]; + } + + /** + * @param array $input + */ + #[DataProvider('acceptanceProvider')] + public function testAcceptedInputRoundTrips(string $schemaFile, array $input): void + { + $className = $this->generateClassFromFile($schemaFile); + $instance = new $className($input); + + $this->assertSame($input, $instance->meta()->rawInput()); + } + + /** + * Rejected input for the `false` form — construction must throw `UnevaluatedItemsException` + * with the expected message. Indices are reported with the `#` prefix that the rest of the + * array-side exception family uses (InvalidItemException, InvalidTupleException, etc.) — + * the pin includes the prefix so a regression that drops it surfaces here. + * + * @return array, 1: string}> + */ + public static function falseFormRejectionProvider(): array + { + return [ + // Single-element array — minimal case for the bracket+prefix render. + 'rejects single item' => [ + ['tags' => ['only']], + "Provided JSON for 'tags' contains not allowed unevaluated items [#0]", + ], + // Three-element array — exercises the comma-separated list path so a future change + // to the joiner shows up here. + 'reports every offending index' => [ + ['tags' => ['a', 'b', 'c']], + "Provided JSON for 'tags' contains not allowed unevaluated items [#0, #1, #2]", + ], + ]; + } + + /** + * @param array $input + */ + #[DataProvider('falseFormRejectionProvider')] + public function testFalseFormRejectsUnevaluatedIndicesWithFullMessage( + array $input, + string $expectedMessage, + ): void { + $className = $this->generateClassFromFile('NoOtherConstraintsFalse.json'); + + try { + new $className($input); + $this->fail('Expected UnevaluatedItemsException'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame($expectedMessage, $exception->getMessage()); + $this->assertSame( + array_keys($input['tags']), + $exception->getUnevaluatedItems(), + 'getUnevaluatedItems() must report the same indices the message lists', + ); + // `tags` is the array property at /properties/tags; its unevaluatedItems keyword + // sits at /properties/tags/unevaluatedItems, which is the pointer stamped by the + // factory when the false-form validator is constructed. + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); + } + } + + /** + * Schema-form rejection wraps a per-index aggregate around the inner validation errors. + * The fixture array deliberately has two failing indices (integer at #1, boolean at #3) + * so the test exercises: + * + * - Aggregation across multiple failing indices in declaration order — a regression that + * dropped any one failure, reordered them, or collapsed them into a single line would + * fail the heredoc compare. + * - The nested exception list returned by `getInvalidItems()` keys each failure under + * its original index and preserves the per-failure `InvalidTypeException` unchanged, + * so a consumer that catches the wrapper can still surface the precise reason + * (type was integer / boolean, expected string) per index. + * + * The expected surface message is built via heredoc so the test source reads line-by-line + * exactly as the runtime message will. + */ + public function testSchemaFormRejectionPreservesNestedInvalidTypeExceptionForEachFailingIndex(): void + { + $className = $this->generateClassFromFile('NoOtherConstraintsSchema.json'); + + try { + new $className(['tags' => ['ok', 42, 'also-ok', true]]); + $this->fail('Expected InvalidUnevaluatedItemsException'); + } catch (InvalidUnevaluatedItemsException $exception) { + $this->assertSame( + <<<'MSG' + Invalid unevaluated items in array 'tags': + - invalid unevaluated item #1 + * Invalid type for 'unevaluated item': requires 'string', got 'integer' + - invalid unevaluated item #3 + * Invalid type for 'unevaluated item': requires 'string', got 'boolean' + MSG, + $exception->getMessage(), + ); + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); + + $invalidItems = $exception->getInvalidItems(); + $this->assertSame( + [1, 3], + array_keys($invalidItems), + 'only the two non-string indices should fail, keyed by their original index', + ); + + $this->assertCount(1, $invalidItems[1], 'one inner exception per failing index'); + $this->assertCount(1, $invalidItems[3], 'one inner exception per failing index'); + + $integerFailure = $invalidItems[1][0]; + $this->assertInstanceOf(InvalidTypeException::class, $integerFailure); + $this->assertSame( + "Invalid type for 'unevaluated item': requires 'string', got 'integer'", + $integerFailure->getMessage(), + ); + $this->assertSame('string', $integerFailure->getExpectedType()); + + $booleanFailure = $invalidItems[3][0]; + $this->assertInstanceOf(InvalidTypeException::class, $booleanFailure); + $this->assertSame( + "Invalid type for 'unevaluated item': requires 'string', got 'boolean'", + $booleanFailure->getMessage(), + ); + $this->assertSame('string', $booleanFailure->getExpectedType()); + } + } + + /** + * The tuple form of `items` evaluates every index it covers: index i is evaluated when + * i < count(items) and the value at i validated against items[i]. Only indices past the + * tuple remain for `unevaluatedItems` — per the 2019-09 annotation rules a sibling + * applicator's claims must be credited even without any composition keyword involved. + */ + public function testSiblingTupleItemsCreditTheirEvaluatedIndices(): void + { + $className = $this->generateClassFromFile('SiblingTupleItems.json'); + + // Index 0 is covered by the tuple — nothing is left for unevaluatedItems. + $accepted = new $className(['tags' => ['covered']]); + $this->assertSame(['covered'], $accepted->getTags()); + + // Index 1 lies past the tuple and no other applicator claims it — only that index + // may be reported as unevaluated. + try { + new $className(['tags' => ['covered', 'surplus']]); + $this->fail('Expected UnevaluatedItemsException for the index past the tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#1]", + $exception->getMessage(), + ); + $this->assertSame([1], $exception->getUnevaluatedItems()); + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); + } + } + + /** + * A sibling `contains` evaluates exactly the indices whose values satisfy its subschema. + * Matched indices are credited to the accumulator; every other index remains unevaluated. + */ + public function testSiblingContainsCreditsOnlyMatchingIndices(): void + { + $className = $this->generateClassFromFile('SiblingContains.json'); + + // The single element matches the contains subschema and is therefore evaluated. + $accepted = new $className(['tags' => [5]]); + $this->assertSame([5], $accepted->getTags()); + + // Index 0 matches, index 1 does not — only the non-matching index is unevaluated. + try { + new $className(['tags' => [5, 'surplus']]); + $this->fail('Expected UnevaluatedItemsException for the index contains did not match'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#1]", + $exception->getMessage(), + ); + $this->assertSame([1], $exception->getUnevaluatedItems()); + } + } + + /** + * An explicit `items: true` is an applicator: it validates (trivially) and annotates every + * index, leaving nothing for `unevaluatedItems`. This mirrors the object side, where an + * explicit `additionalProperties: true` claims every extra key — in contrast to an omitted + * keyword, which produces no annotation. + */ + public function testSiblingItemsTrueCreditsEveryIndex(): void + { + $className = $this->generateClassFromFile('SiblingItemsTrue.json'); + + $accepted = new $className(['tags' => ['anything', 42]]); + $this->assertSame(['anything', 42], $accepted->getTags()); + } + + /** + * A composition branch may carry `unevaluatedItems` even when the array property itself + * does not: `allOf: [{unevaluatedItems: {schema}}]`. Within the branch every index is + * unevaluated (the branch declares no other applicator), so the branch's subschema applies + * to every item and decides the branch outcome. + * + * The rejection surfaces through the property-level composition wrapping: the inner + * unevaluatedItems failure is swallowed by the branch's try/catch and the composition + * reports the failed branch, including the branch's own per-element failure detail. + */ + public function testBranchOnlyUnevaluatedItemsValidatesEveryIndex(): void + { + $className = $this->generateClassFromFile('InnerUnevaluatedItemsOnlyInBranch.json'); + + // Every index satisfies the branch's unevaluatedItems schema — the branch succeeds. + $accepted = new $className(['tags' => ['alpha', 'beta']]); + $this->assertSame(['alpha', 'beta'], $accepted->getTags()); + + // A non-string item violates the branch's unevaluatedItems schema — the branch and + // therefore the allOf composition must reject the array. + $this->expectException(AllOfException::class); + $this->expectExceptionMessage( + <<<'MSG' + Invalid value for 'tags' declined by composition constraint + Requires to match all composition elements but matched 0 elements + MSG, + ); + + new $className(['tags' => [5]]); + } + + /** + * `uniqueItems: true` is orthogonal to `unevaluatedItems`. uniqueItems failure has its own + * exception identity and message; the unevaluated check does not get to fire for that + * array because the uniqueItems failure surfaces first. + */ + public function testUniqueItemsExceptionIdentityIsPreserved(): void + { + $this->expectException(UniqueItemsException::class); + $this->expectExceptionMessage("Items of array 'tags' are not unique"); + + $className = $this->generateClassFromFile('UnevaluatedFalseWithUniqueItems.json'); + new $className(['tags' => ['dup', 'dup']]); + } + + /** + * Three sibling shapes make `unevaluatedItems` unreachable; each emits a generation-time + * warning instead of a SchemaException. The warning pin captures the developer's intended + * class/property identifier so the source of the dead code is obvious in build output. + * + * @return array + */ + public static function deadCodeProvider(): array + { + return [ + // items: {schema} claims every index per the JSON Schema spec; the unevaluated + // bucket is permanently empty, so the false form can never fire. + 'items schema form (false)' => [ + 'ItemsSchemaWithUnevaluatedFalse.json', + "sibling items: {schema} already validates every index", + ], + // Same dead-cell shape as above but with the schema form of unevaluatedItems — + // the keyword still has nothing left to validate. + 'items schema form (schema)' => [ + 'ItemsSchemaWithUnevaluatedSchema.json', + "sibling items: {schema} already validates every index", + ], + // items: false reduces the array to empty; no index can be unevaluated. + 'items: false leaves no indices' => [ + 'ItemsFalseWithUnevaluatedFalse.json', + "sibling items: false rejects every index, leaving no unevaluated items", + ], + // Tuple items + additionalItems: false rejects everything past the tuple length; + // the unevaluatedItems keyword cannot contribute. + 'tuple items with additionalItems: false' => [ + 'TupleAdditionalFalseWithUnevaluatedFalse.json', + "sibling additionalItems: false rejects every tail index past the tuple", + ], + ]; + } + + #[DataProvider('deadCodeProvider')] + public function testDeadCodeShapesWarn(string $schemaFile, string $reason): void + { + $logger = new RecordingLogger(); + + $className = $this->generateClassFromFile( + $schemaFile, + (new GeneratorConfiguration())->setLogger($logger), + ); + + $this->assertTrue( + $this->hasLogEntry( + $logger->getEntries(), + 'warning', + 'unevaluatedItems on {class}::{property} is dead code — {reason}', + ['property' => 'tags', 'reason' => $reason], + ), + 'Expected a warning naming the dead unevaluatedItems keyword on tags', + ); + + // Each warned shape still compiles into a working class — an empty tags array + // constructs cleanly, proving the keyword did not break codegen. + $instance = new $className(['tags' => []]); + $this->assertSame(['tags' => []], $instance->meta()->rawInput()); + } + + /** + * Non-bool / non-object values for `unevaluatedProperties` and `unevaluatedItems` must + * fail loudly at generation time with a SchemaException. The generator never produces + * broken code for these inputs; CLAUDE.md's "Schema error handling" rule applies. + * + * @return array + */ + public static function invalidTypeProvider(): array + { + return [ + 'unevaluatedItems with integer value' => [ + 'InvalidUnevaluatedItemsType.json', + 'unevaluatedItems', + ], + 'unevaluatedProperties with integer value' => [ + 'InvalidUnevaluatedPropertiesType.json', + 'unevaluatedProperties', + ], + ]; + } + + #[DataProvider('invalidTypeProvider')] + public function testInvalidTypeForKeywordThrowsSchemaException( + string $schemaFile, + string $keyword, + ): void { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + '/^Invalid ' . preg_quote($keyword, '/') . ' 42 for property \'\S+\' in file /', + ); + + $this->generateClassFromFile($schemaFile); + } + + /** + * `unevaluatedItems` declared on a non-array-typed property is inapplicable per spec — array + * applicators impose no constraint on a value that is not an array — and must be silently + * ignored: no array-index validator is emitted for the property, and the property behaves + * exactly as if `unevaluatedItems` were absent. Regression guard for + * `activateArrayPropertyTracking`'s type-mismatch skip actually being a no-op rather than a + * state-corruption path (e.g. wiring up index-tracking machinery for a scalar property, or + * suppressing an unrelated sibling keyword). A sibling `unevaluatedProperties: false` on the + * parent still activates and works normally, proving this property's malformed-for-its-type + * keyword does not interfere with unrelated validators on the class. + */ + public function testUnevaluatedItemsOnNonArrayPropertyIsSilentlyIgnored(): void + { + $className = $this->generateClassFromFile('UnevaluatedItemsOnNonArrayProperty.json'); + + $accepted = new $className(['tags' => 'hello']); + $this->assertSame('hello', $accepted->getTags()); + $this->assertSame(['tags' => 'hello'], $accepted->meta()->rawInput()); + + try { + new $className(['tags' => 'hello', 'extra' => 1]); + $this->fail('unevaluatedProperties: false on the parent must still reject unclaimed keys'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['extra']", + $exception->getMessage(), + ); + } + } + + /** + * `items: null` is not a valid `items` value (the keyword must be a boolean or a schema), + * but the dead-code classifier must still degrade gracefully rather than misclassifying it + * as one of the three recognised dead-code shapes (`items: false`, tuple-form with + * `additionalItems: false`, or `items: {schema}`). `null` claims nothing, so + * `unevaluatedItems: false` correctly falls through to "not dead code" and rejects every + * index — proving the classifier's final fallback branch is a safe default, not a silent + * misclassification that would let a malformed `items` value slip through unvalidated. + */ + public function testItemsNeitherBooleanTupleNorSchemaFallsThroughToNotDeadCode(): void + { + $className = $this->generateClassFromFile('ItemsNeitherBooleanTupleNorSchema.json'); + + try { + new $className(['tags' => ['a', 'b']]); + $this->fail('Expected UnevaluatedItemsException since items: null claims no indices'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#0, #1]", + $exception->getMessage(), + ); + } + } + + /** + * Composition branches contribute their evaluated indices to a sibling unevaluatedItems + * accumulator. The fixture has two tuple-form items branches under allOf: branch 1 covers + * index 0, branch 2 covers indices 0-1. When both succeed (string array), the union + * covers 0-1 and any tail index is reported as unevaluated. + */ + public function testAllOfBranchesContributeEvaluatedIndices(): void + { + $className = $this->generateClassFromFile('AllOfTupleBranches.json'); + + // Both branches succeed; union covers 0-1; no tail → accept. + $accepted = new $className(['tags' => ['alpha', 'beta']]); + $this->assertSame(['alpha', 'beta'], $accepted->getTags()); + + // Both branches succeed; union covers 0-1; index 2 is unevaluated → reject just index 2. + try { + new $className(['tags' => ['alpha', 'beta', 'gamma']]); + $this->fail('Expected UnevaluatedItemsException for tail index past union'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); + } + } + + /** + * Regression guard for the chain-orchestration bug where composition failure assigned + * `$value = $proposedValue` (null) at IIFE end, causing the downstream + * `is_array($value)` gate inside `unevaluatedItems` to short-circuit and silently + * suppress its error in collectErrors mode. With the fix, both errors appear: the + * composition's AllOfException listing the failing branches and the + * UnevaluatedItemsException listing every index as unevaluated (composition contributed + * no claims on failure). + */ + public function testCompositionFailurePreservesArrayValueSoUnevaluatedItemsCheckStillFires(): void + { + $className = $this->generateClassFromFile( + 'AllOfTupleBranches.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + try { + new $className(['tags' => [1, 2]]); + $this->fail('Expected ErrorRegistryException combining composition + unevaluated errors'); + } catch (ErrorRegistryException $exception) { + $this->assertSame( + <<<'MSG' + Invalid value for 'tags' declined by composition constraint + Requires to match all composition elements but matched 0 elements + - Composition element #1: Failed + * Invalid tuple item in array 'tags': + - invalid tuple #1 + * Invalid type for 'tuple item #0 of array tags': requires 'string', got 'integer' + - Composition element #2: Failed + * Invalid tuple item in array 'tags': + - invalid tuple #1 + * Invalid type for 'tuple item #0 of array tags': requires 'string', got 'integer' + - invalid tuple #2 + * Invalid type for 'tuple item #1 of array tags': requires 'string', got 'integer' + Provided JSON for 'tags' contains not allowed unevaluated items [#0, #1] + MSG, + $exception->getMessage(), + ); + } + } + + /** + * Two properties on the same class — one array with composition + `unevaluatedItems: + * false`, one object with composition + `unevaluatedProperties: false` — exercise the + * structural separation between `_compositionAnnotated` (array side) and + * `_compositionEvaluations` (object side). Each accumulator reads from its own field, so + * cross-contamination between the two paths is impossible by construction. + */ + public function testArrayAndObjectPropertyCompositionsCoexistOnTheSameClass(): void + { + $className = $this->generateClassFromFile('ArrayAndObjectPropertyCompositionsCoexist.json'); + + $accepted = new $className([ + 'tags' => ['only'], + 'meta' => ['kind' => 'X'], + ]); + $this->assertSame(['only'], $accepted->getTags()); + $this->assertSame('X', $accepted->getMeta()->getKind()); + } + + public function testArrayPropertyUnevaluatedItemsRejectsTailIndexWhileObjectPropertyAccepts(): void + { + $className = $this->generateClassFromFile('ArrayAndObjectPropertyCompositionsCoexist.json'); + + $this->expectException(UnevaluatedItemsException::class); + $this->expectExceptionMessage("Provided JSON for 'tags' contains not allowed unevaluated items [#1]"); + + new $className([ + 'tags' => ['head', 'tail'], + 'meta' => ['kind' => 'X'], + ]); + } + + /** + * Regression guard for the extracted-method-name collision: two compositions on the + * same property previously hashed to the same `_validateTags_ComposedProperty_` + * method (the hash was derived from the property's JSON alone), so the second + * registration overwrote the first and both call sites invoked the same body. With + * the fix mixing the validator's object hash into the method name, each composition + * keeps its own body and writes to its own slot key. + */ + public function testMultipleSiblingCompositionsContributeIndependently(): void + { + $className = $this->generateClassFromFile('MultipleCompositionsOnProperty.json'); + + // allOf branch (tuple-1) + oneOf branch (tuple-2) both succeed on two-string array. + // Union {0, 1} covers everything → accept. + $accepted = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $accepted->getTags()); + + // Three-element string array: both branches still succeed (tuples allow extras); + // union is still {0, 1}; index 2 unevaluated. + try { + new $className(['tags' => ['a', 'b', 'c']]); + $this->fail('Expected UnevaluatedItemsException for tail past widest tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + } + + /** + * Regression guard for the PropertyProxy clone reset: + * `CompositionPropertyDecorator::getOrderedValidators()` returns fresh + * `withProperty(...)` clones on every call. A previous fix attempt set + * `setTrackBranchMatches(true)` on the clones returned to the post-processor, leaving + * the validator instances actually emitted into generated code with the flag still + * false; contains-matched indices were silently dropped. With the fix iterating the + * wrapped property's source validators via `getWrappedProperty()`, the flag reaches + * the rendered instance. + * + * Three-element array `['a', 5, 'c']`: contains matches index 1 (integer). The branch + * claims {1}; sibling unevaluatedItems reports {0, 2} as unevaluated — not {0, 1, 2}. + */ + public function testContainsOnlyBranchClaimsMatchedIndex(): void + { + $className = $this->generateClassFromFile('ContainsOnlyBranch.json'); + + // All-integer array: contains matches every index; nothing left unevaluated. + $accepted = new $className(['tags' => [1, 2, 3]]); + $this->assertSame([1, 2, 3], $accepted->getTags()); + + // Mixed array: contains matches index 1; non-matching indices fail unevaluatedItems. + try { + new $className(['tags' => ['a', 5, 'c']]); + $this->fail('Expected UnevaluatedItemsException for non-matching indices'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#0, #2]", + $exception->getMessage(), + ); + $this->assertSame([0, 2], $exception->getUnevaluatedItems()); + } + } + + /** + * Items + contains combined branch — tuple items claims index 0, contains matches its + * own indices, and the branch's evaluated set is the union of both. Items covers index 0 + * (string), contains matches index 1 (integer). Together they cover the whole two- + * element array — accept. + */ + public function testItemsPlusContainsBranchUnionsBothClaimSources(): void + { + $className = $this->generateClassFromFile('ItemsPlusContainsBranch.json'); + + // Items claims index 0, contains claims index 1 → union covers everything → accept. + $accepted = new $className(['tags' => ['head', 5]]); + $this->assertSame(['head', 5], $accepted->getTags()); + + // Three-element array: items covers 0, contains covers 1; index 2 is unevaluated. + try { + new $className(['tags' => ['head', 5, 'tail']]); + $this->fail('Expected UnevaluatedItemsException for unclaimed index'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + } + + /** + * Regression guard for three independent, previously-infinite recursions on the same + * self-referencing shape - `{type: array, allOf: [{$ref: "#/definitions/recursive"}], + * unevaluatedItems: false}`, where the $ref resolves back to the same schema: + * + * - The activation walk in `UnevaluatedPropertiesPostProcessor::activateArrayComposition()` + * - guarded by `$activatedCompositions`. Verified by hand: removing the guard and + * running this fixture aborts with "Xdebug has detected a possible infinite loop... a + * stack depth of '512' frames", frames pointing at `activateArrayComposition` / + * `activateValidatorsInBranch`. + * - `CompositionTypeHintDecorator::getTypeHint()`, which recurses into the composed + * branch's wrapped property - itself, on this fixture - with no cycle protection. + * Fixed by a per-instance recursion-depth guard mirroring `ArrayTypeHintDecorator`'s + * existing pattern for the analogous array-composition cycle. + * - `UnevaluatedPropertiesPostProcessor::propertyHasBranchUnevaluatedItems()`, which + * recurses into `getWrappedProperty()` whenever a branch has no nested `Schema` (true + * for every array-typed branch) - also unguarded, only surfaced once the other two were + * fixed. Fixed with a `$seen` map keyed on file+pointer, mirroring `needsActivation()`'s + * own guard; keyed on the string location rather than object identity because + * `getOrderedValidators()` returns fresh clones on every call, so the wrapped property + * instance isn't guaranteed stable across recursive calls. + * + * Generation now completes. Constructing an instance of the generated class still crashes + * with a stack overflow - the schema means "this array must recurse into a composition + * requiring itself, unconditionally", a degenerate constraint with no base case (unlike the + * `items: {$ref: "#"}` shape, which recurses into progressively smaller array *elements* + * and terminates correctly - verified separately, not affected by this). Tracked as its own, + * deeper, runtime issue: see the implementation plan for the fix options considered + * (rejecting the degenerate shape at generation time vs. runtime cycle memoization). + */ + public function testSelfReferencingArrayCompositionDoesNotRecurseIndefinitely(): void + { + $className = $this->generateClassFromFile('SelfReferencingArrayComposition.json'); + + $this->assertNotEmpty($className); + } + + /** + * A composition (`oneOf`) nested inside a composition branch (`allOf`) on an array-typed + * property, with `unevaluatedItems` declared only on the innermost branch and nowhere at + * the outer property level, used to crash construction with `Error: Call to undefined + * method ...::collectUnevaluatedIndices()` rather than validate or reject cleanly. + * + * Root cause: `UnevaluatedPropertiesPostProcessor::compositionValidatorsNeedActivation()` + * (and its property-level sibling `propertyHasBranchUnevaluatedItems()`) checked a + * branch's own JSON directly and recursed into a branch's nested `Schema` — but an + * array-typed branch never gets its own nested `Schema` (unlike an object-typed one, + * which always routes through `processSchema()`), so a further composition nested + * directly inside another branch's own JSON (no intervening object type) was invisible to + * the detection walk. The schema-processing step that renders the innermost branch's own + * `unevaluatedItems` validator is not gated by that detection at all, so the call to + * `collectUnevaluatedIndices()` (from `CompositionEvaluationTrait`) rendered into the + * class regardless — but the trait itself, and the `_evaluatedItemIndices` field, were + * never added, since the post processor's `process()` short-circuited on the (wrongly) + * negative detection result before reaching either. + * + * Fixed by recursing through the branch's wrapped property's own validators — mirroring + * how `activateValidatorsInBranch()` already walks this exact structure for the + * activation step itself — whenever a composed property's own nested `Schema` is null. + */ + public function testCompositionNestedInsideArrayBranchIsNotSilentlyDropped(): void + { + $className = $this->generateClassFromFile('NestedCompositionInsideArrayBranchSilentlyDropped.json'); + + $accepted = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $accepted->getTags()); + + try { + new $className(['tags' => [1, 2]]); + $this->fail('Expected the innermost branch\'s unevaluatedItems: {type: string} to reject integers'); + } catch (AllOfException $exception) { + $this->assertStringContainsString( + "Invalid unevaluated items in array 'tags'", + $exception->getMessage(), + ); + } + } + + /** + * A composition (`allOf`) nested two levels inside another composition (`allOf` inside + * `allOf`) on an array property, with `unevaluatedItems: false` declared at the *outer* + * property level (not inside a branch). The innermost branch's own tuple `items` claims + * indices 0-1; that claim must reach the outer `unevaluatedItems` accumulator through both + * levels of nesting, not just the first. + * + * Root cause (distinct from `testCompositionNestedInsideArrayBranchIsNotSilentlyDropped` + * above — that one is an activation-detection gap causing a fatal error; this one is a + * downstream aggregation gap once activation is already correctly triggered): + * `ComposedItem.phptpl`'s per-branch index-set block only populated + * `$compositionEvaluatedIndices` from a branch's own *direct* `items`/`additionalItems`/ + * `contains` shape (`CompositionPropertyDecorator::branchIsArrayKind()`). A branch that is + * itself purely a nested composition (`{allOf: [{items: [...]}]}`, with no array + * applicator of its own) fails that check, so the block never ran for it — its own nested + * composition's tracked slot (`_compositionAnnotated['tags_1']`) was computed correctly + * but never read back into the enclosing branch's own slot (`_compositionAnnotated + * ['tags_0']`), which the outer `unevaluatedItems: false` reads. Before the fix, all three + * indices were reported unevaluated instead of just index 2. + * + * Fixed by `CompositionPropertyDecorator::getNestedCompositionSlotKeys()`: the indices of + * any composition validator nested directly inside a branch's own JSON (found by walking + * the branch's wrapped property's own validators, the same way the activation step already + * does) get unioned into the branch's own evaluated set. + */ + public function testCompositionNestedTwoLevelsDeepCreditsOuterAccumulator(): void + { + $className = $this->generateClassFromFile('CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json'); + + // Exactly the tuple length: both indices covered by the innermost branch — accept. + $accepted = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $accepted->getTags()); + + // A trailing element past the tuple is not covered by any level of the nesting. + try { + new $className(['tags' => ['a', 'b', 'c']]); + $this->fail('Expected UnevaluatedItemsException for the index past the nested tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + } + + /** + * Spec propagation: a nested `unevaluatedItems` schema-form validator inside a + * successful `allOf` branch must contribute its claimed indices to the enclosing + * `unevaluatedItems` accumulator. The inner validator writes + * `_evaluatedItemIndices['tags'][$index] = true` after each successful per-index + * validation; the outer reads that map via `collectUnevaluatedIndices()`. + * + * Without the inner write, the outer `false` form would see every index as + * unevaluated and reject a perfectly valid all-string array; with the write, the + * outer credits the indices the inner already cleared. + */ + public function testInnerUnevaluatedItemsInAllOfBranchPropagatesIndicesToOuterAccumulator(): void + { + $className = $this->generateClassFromFile('InnerUnevaluatedItemsInBranch.json'); + + // Both indices validate as strings against the inner schema; inner writes + // [0, 1] into _evaluatedItemIndices['tags']; outer's false form sees zero + // unevaluated indices → accept. + $accepted = new $className(['tags' => ['alpha', 'beta']]); + $this->assertSame(['alpha', 'beta'], $accepted->getTags()); + } + + /** + * Snapshot/restore guard: when the nested `unevaluatedItems` inside an `allOf` + * branch FAILS (any per-index check yields an invalid item), the inner template + * must restore `_evaluatedItemIndices` to its pre-IIFE state so no partial writes + * leak to the outer accumulator. Under `collectErrors` mode the chain continues + * past the failing composition so the outer `false` form runs against the + * snapshot-restored state — if the rollback worked, every index appears + * unevaluated and is reported in the outer error. + * + * Without the snapshot/restore, indices 0 and 2 (the successfully-validated + * string entries) would leak into `_evaluatedItemIndices['tags']` and the outer + * would mistakenly report only index 1 as unevaluated. + */ + public function testInnerUnevaluatedItemsFailureRollsBackPartialWritesUnderCollectErrors(): void + { + $className = $this->generateClassFromFile( + 'InnerUnevaluatedItemsInBranch.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + try { + new $className(['tags' => ['alpha', 42, 'gamma']]); + $this->fail('Expected ErrorRegistryException combining composition + outer unevaluated errors'); + } catch (ErrorRegistryException $exception) { + $this->assertSame( + <<<'MSG' + Invalid value for 'tags' declined by composition constraint + Requires to match all composition elements but matched 0 elements + - Composition element #1: Failed + * Invalid unevaluated items in array 'tags': + - invalid unevaluated item #1 + * Invalid type for 'unevaluated item': requires 'string', got 'integer' + Provided JSON for 'tags' contains not allowed unevaluated items [#0, #1, #2] + MSG, + $exception->getMessage(), + ); + } + } + + /** + * `contains` with `minContains: 0` credits every index whose value matched the contains + * schema — even when zero matches would still satisfy the branch. The evaluated set is + * driven by which indices matched, not by the count. Non-matching indices remain + * unevaluated and the outer `unevaluatedItems: false` rejects them. + * + * Two assertions on the same generated class: + * - `[]` accepts because no indices exist to be evaluated; contains-empty is legal + * under minContains: 0 and the branch succeeds. + * - `[1, 'a', 2, 'b', 3]` — indices 0, 2, 4 match the integer contains schema; indices + * 1, 3 are non-matching and surface as unevaluated. + */ + public function testContainsWithMinContainsZeroCreditsMatchedIndicesOnly(): void + { + $className = $this->generateClassFromFile('ContainsMinContainsZeroBranch.json'); + + // Empty array: contains matches zero indices but minContains: 0 keeps the branch + // successful. Nothing is unevaluated because there are no indices to evaluate. + $accepted = new $className(['tags' => []]); + $this->assertSame([], $accepted->getTags()); + + // Mixed array: only integer indices are credited; string indices fall through to + // unevaluatedItems: false and are reported in declaration order. + try { + new $className(['tags' => [1, 'a', 2, 'b', 3]]); + $this->fail('Expected UnevaluatedItemsException for the two non-matching indices'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#1, #3]", + $exception->getMessage(), + ); + $this->assertSame([1, 3], $exception->getUnevaluatedItems()); + } + } + + /** + * `oneOf` with two branches of different tuple lengths: the branch that succeeds + * determines how many indices are evaluated. Only one branch may succeed at a time + * because otherwise `oneOf` fails as a whole. The outer `unevaluatedItems: false` then + * inspects the surviving branch's evaluated indices and rejects any tail past that + * length. + * + * Three assertions on the same generated class: + * - `['a', 'b']` matches only branch 0 (strings, tuple length 2); no tail indices, + * accepted. + * - `[1, 2, 3]` matches only branch 1 (integers, tuple length 3); no tail indices, + * accepted. + * - `[1, 2, 3, 4]` — branch 1 succeeds and covers indices 0-2, but index 3 is not + * covered by any successful branch. `unevaluatedItems: false` rejects. + */ + public function testOneOfBranchesOfDifferentTupleLengthsControlEvaluatedSet(): void + { + $className = $this->generateClassFromFile('OneOfDifferentTupleLengths.json'); + + $acceptedStrings = new $className(['tags' => ['a', 'b']]); + $this->assertSame(['a', 'b'], $acceptedStrings->getTags()); + + $acceptedIntegers = new $className(['tags' => [1, 2, 3]]); + $this->assertSame([1, 2, 3], $acceptedIntegers->getTags()); + + try { + new $className(['tags' => [1, 2, 3, 4]]); + $this->fail('Expected UnevaluatedItemsException for index 3 past widest surviving tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#3]", + $exception->getMessage(), + ); + $this->assertSame([3], $exception->getUnevaluatedItems()); + } + } + + /** + * `if`/`then`/`else` on an array property: whichever of `then`/`else` actually runs + * determines how many indices are evaluated, exercising `ConditionalComposedItem.phptpl`'s + * array-side branch. The fixture's `if` checks whether index 0 is the literal `"typed"`: + * when it is, `then` requires a 2-tuple (`"typed"` then any string) and covers indices 0-1; + * otherwise `else` requires a 1-tuple (an integer) and covers only index 0. `unevaluatedItems: + * false` rejects whatever index neither branch of the winning path covers. + * + * Four assertions on the same generated class: + * - `['typed', 'x']` — `if` passes, `then` covers both indices, accepted; + * - `['typed', 'x', 'extra']` — `if` passes, `then` covers 0-1, index 2 unevaluated; + * - `[5]` — `if` fails (index 0 is not `"typed"`), `else` covers index 0, accepted; + * - `[5, 6]` — `if` fails, `else` covers only index 0, index 1 unevaluated. + */ + public function testIfThenElseArrayCompositionCreditsTheWinningBranchesIndices(): void + { + $className = $this->generateClassFromFile('IfThenElseArrayUnevaluatedItems.json'); + + $acceptedThen = new $className(['tags' => ['typed', 'x']]); + $this->assertSame(['typed', 'x'], $acceptedThen->getTags()); + + try { + new $className(['tags' => ['typed', 'x', 'extra']]); + $this->fail('Expected UnevaluatedItemsException for the index past the then-branch tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#2]", + $exception->getMessage(), + ); + $this->assertSame([2], $exception->getUnevaluatedItems()); + } + + $acceptedElse = new $className(['tags' => [5]]); + $this->assertSame([5], $acceptedElse->getTags()); + + try { + new $className(['tags' => [5, 6]]); + $this->fail('Expected UnevaluatedItemsException for the index past the else-branch tuple'); + } catch (UnevaluatedItemsException $exception) { + $this->assertSame( + "Provided JSON for 'tags' contains not allowed unevaluated items [#1]", + $exception->getMessage(), + ); + $this->assertSame([1], $exception->getUnevaluatedItems()); + $this->assertSame( + '/properties/tags/unevaluatedItems', + $exception->getJsonPointer()->pointer, + ); + } + } + + /** + * A transforming filter (here `dateTime`) declared inside `unevaluatedItems`'s subschema + * behaves like a filter on any other property: the transformed value is persisted (the + * getter reports `DateTime` instances, not the raw strings), an already-transformed value + * passed directly is accepted (the type check is widened via + * `TransformingFilterOutputTypePostProcessor`, which recurses into + * `UnevaluatedItemsValidator::getValidationProperty()` the same way it already does for + * `UnevaluatedPropertiesValidator`), and a mixed list of raw and already-transformed values + * is accepted since each index is validated independently. + * + * Serialization applies the filter's outputFormat to every index credited to the + * unevaluatedItems validator, turning each DateTime back into the raw representation the + * filter accepts. + * + * An invalid date string still fails the filter's own validation. + */ + public function testTransformingFilterPersistsAndAcceptsAlreadyTransformedValues(): void + { + $className = $this->generateClassFromFile( + 'SchemaFormWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + // Valid raw date strings pass the filter and the transformed value is persisted. + $accepted = new $className(['tags' => ['2020-10-10', '2020-12-12']]); + $this->assertEquals( + [new DateTime('2020-10-10'), new DateTime('2020-12-12')], + $accepted->getTags(), + ); + $this->assertSame(['2020-10-10', '2020-12-12'], $accepted->meta()->rawInput()['tags']); + + $this->assertSame(['tags' => ['20201010', '20201212']], $accepted->toArray()); + $decoded = json_decode($accepted->toJSON(), true); + $this->assertSame(['tags' => ['20201010', '20201212']], $decoded); + + // An already-transformed DateTime passed directly is accepted and persisted as-is. + $alreadyTransformed = new $className(['tags' => [new DateTime('2020-10-10')]]); + $this->assertEquals([new DateTime('2020-10-10')], $alreadyTransformed->getTags()); + $this->assertEquals([new DateTime('2020-10-10')], $alreadyTransformed->meta()->rawInput()['tags']); + + // A mixed list of raw and already-transformed values is accepted — each index is + // validated independently. + $mixed = new $className(['tags' => ['2020-10-10', new DateTime('2020-12-12')]]); + $this->assertEquals( + [new DateTime('2020-10-10'), new DateTime('2020-12-12')], + $mixed->getTags(), + ); + + // The setter must exercise the same validator chain as construction: a raw date string + // is transformed and persisted, and an already-transformed value is accepted directly. + $accepted->setTags(['2020-01-01']); + $this->assertEquals([new DateTime('2020-01-01')], $accepted->getTags()); + + $accepted->setTags([new DateTime('2020-02-02')]); + $this->assertEquals([new DateTime('2020-02-02')], $accepted->getTags()); + + // An invalid date string still fails the filter's own validation. + try { + new $className(['tags' => ['not-a-date']]); + $this->fail('Expected an exception for an invalid date string'); + } catch (ErrorRegistryException $exception) { + $this->assertSame( + <<<'MSG' + Invalid unevaluated items in array 'tags': + - invalid unevaluated item #0 + * Invalid value for property 'unevaluated item' denied by filter 'dateTime': Invalid Date Time value "not-a-date" + MSG, + $exception->getMessage(), + ); + } + } + + /** + * `_evaluatedItemIndices['tags']` records which indices the unevaluatedItems validator + * credited so composition/serialization can read it back — but it must never leak from one + * validation pass into the next. Without a reset before the validator chain runs, a + * `setTags()` call would see stale credits from a previous pass and skip validating (and, + * with a transforming filter, skip re-filtering) indices whose value has since completely + * changed. Uses a plain type check with no filter to prove this is a general unevaluatedItems + * mutability issue, not something specific to the transforming-filter interaction. + */ + public function testSetterRevalidatesEveryIndexRegardlessOfPriorPassCredits(): void + { + $className = $this->generateClassFromFile( + 'NoOtherConstraintsSchema.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['tags' => ['alpha', 'beta']]); + $this->assertSame(['alpha', 'beta'], $object->getTags()); + + // A value that would have been valid at construction time must still be rejected when + // set later — proving the same index isn't silently exempted from validation because a + // previous, unrelated array happened to validate successfully at that position. + try { + $object->setTags([42]); + $this->fail('Expected an exception for an integer where unevaluatedItems requires a string'); + } catch (ErrorRegistryException $exception) { + $this->assertSame( + <<<'MSG' + Invalid unevaluated items in array 'tags': + - invalid unevaluated item #0 + * Invalid type for 'unevaluated item': requires 'string', got 'integer' + MSG, + $exception->getMessage(), + ); + } + + // The rejected setter call must leave the object's state unchanged. + $this->assertSame(['alpha', 'beta'], $object->getTags()); + + // A genuinely valid replacement is still accepted. + $object->setTags(['gamma']); + $this->assertSame(['gamma'], $object->getTags()); + } + + /** + * Which indices unevaluatedItems credits is only known at runtime (tracked in + * `_evaluatedItemIndices`) — unlike a tuple index, it can't be resolved to a static list at + * generation time. Index 0 is claimed by an `allOf` branch's tuple-form `items` (no filter, + * passes through unchanged); index 1 is left over for `unevaluatedItems` (filtered). Proves + * the generated serializer only transforms the indices actually credited to + * unevaluatedItems, not every index in the array. + * + * Uses composition-based crediting (`allOf` branch), not a direct sibling `items` tuple, + * because direct-sibling tuple crediting is a separately tracked, currently-broken + * interaction (see testSiblingTupleItemsCreditTheirEvaluatedIndices) unrelated to this test. + */ + public function testSerializationOfUnevaluatedItemsWithTransformingFilterOnlyAffectsCreditedIndices(): void + { + $className = $this->generateClassFromFile( + 'TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $object = new $className(['tags' => ['plain', '2020-10-10']]); + + $this->assertSame(['tags' => ['plain', '20201010']], $object->toArray()); + + $decoded = json_decode($object->toJSON(), true); + $this->assertSame(['tags' => ['plain', '20201010']], $decoded); + } + + /** + * A tuple index and unevaluatedItems on the same array property, each with their own + * transforming filter, must not clobber each other: `SerializationPostProcessor` generates + * one `_serialize{Property}()` method per property, and independently generating one from + * the tuple validator and another from the unevaluatedItems validator would silently + * overwrite whichever ran last (`Schema::addMethod()` is a plain array write, not a merge). + * Both filters must be reflected in the serialized output: index 0 (tuple, `Ymd`) and + * index 1 (unevaluatedItems, `Y-m-d`) end up in visibly different formats, proving neither + * one was silently dropped. + * + * Uses direct-sibling `items`/`unevaluatedItems` (not composition) because that is the only + * shape that reaches `SerializationPostProcessor`'s array-item recursion at all — composition + * branches are a separate, not-yet-covered case (the branch's own tuple validator lives on + * the branch's nested property, invisible to this post processor). As a side effect this + * also exercises the direct-sibling tuple-crediting gap tracked elsewhere + * (`testSiblingTupleItemsCreditTheirEvaluatedIndices`): `_evaluatedItemIndices` ends up + * including the tuple-covered index too, since that credit-tracking bug is unrelated to and + * not fixed by this test. This test still passes despite it, because the combined serializer + * defensively skips indices already handled by the static tuple branch before running the + * dynamic unevaluatedItems branch — proving that defense actually works, not just that the + * two "happen" not to collide. + */ + public function testSerializationCombinesTupleAndUnevaluatedItemsFiltersWithoutClobbering(): void + { + $className = $this->generateClassFromFile( + 'SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $object = new $className(['tags' => ['2020-10-10', '2020-12-12']]); + + $this->assertSame(['tags' => ['20201010', '2020-12-12']], $object->toArray()); + + $decoded = json_decode($object->toJSON(), true); + $this->assertSame(['tags' => ['20201010', '2020-12-12']], $decoded); + } + + /** + * Array-side counterpart of `UnevaluatedPropertiesValidatorTest:: + * testBranchOnlyUnevaluatedPropertiesThrowsUnsupportedSchemaFeatureException()` - same root + * cause, confirmed to affect this side too. A branch's own `unevaluatedItems` cannot see + * indices a *sibling* applicator on the same array property already claims: here, the + * sibling tuple `items: [{type: string}]` claims index 0, but the `allOf` branch's own + * `unevaluatedItems: false` has no way to see that claim - it only knows what its own + * (empty) local applicators evaluated, so `['a']` at index 0 used to be wrongly rejected as + * unevaluated even though the sibling tuple already validated and claimed it. + * + * Rather than silently computing a wrong "unevaluated" set, the generator now rejects the + * schema itself at generation time - see + * `UnsupportedSchemaFeatureException` and `.claude/topics/branch-unevaluated-down-propagation/` + * for why a real fix (the branch would need the enclosing property to compute and pass its + * siblings' claims into the branch instance at construction time - siblings' claims can be + * instance-dependent, unlike the object-side's enclosing-declared-*names* case, which is + * static) was deferred instead of implemented. + */ + public function testBranchUnevaluatedItemsThrowsUnsupportedSchemaFeatureException(): void + { + $this->expectException(UnsupportedSchemaFeatureException::class); + $this->expectExceptionMessage( + "Branch #1 of the composition for 'tags' declares 'unevaluatedItems', which cannot " + . 'yet see property names or indices declared by the enclosing schema or a ' + . "sibling branch - remove 'unevaluatedItems' from the branch, or restructure the " + . 'schema so it is declared only at the level that needs it at line 12, column 9', + ); + + $this->generateClassFromFile('BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json', null, true); + } +} diff --git a/tests/Basic/UnevaluatedPropertiesMutabilityTest.php b/tests/Basic/UnevaluatedPropertiesMutabilityTest.php new file mode 100644 index 00000000..4decf261 --- /dev/null +++ b/tests/Basic/UnevaluatedPropertiesMutabilityTest.php @@ -0,0 +1,299 @@ +setImmutable(false)->setCollectErrors(false); + } + + /** + * Two assertions on the same generated class: + * - re-setting kind to the same value hits the setter's early-return guard so no + * validation or revalidation runs and no observable state changes; + * - flipping the discriminator orphans alphaOnly under the new branch — the setter + * throws UnevaluatedPropertiesException carrying a message that names the orphaned + * key, and rolls every field back to the pre-setter state. + */ + public function testOneOfDiscriminatorFlipRollsBackAndSameValueIsNoOp(): void + { + $className = $this->generateClassFromFile('OneOfKindDiscriminator.json', $this->defaultConfig()); + $object = new $className(['kind' => 'a', 'alphaOnly' => 1]); + + // Same-value setter is an early return — neither the validator nor the revalidation + // hook runs because the setter's first check returns before either fires. + $object->setKind('a'); + $this->assertSame('a', $object->getKind()); + $this->assertSame(['kind' => 'a', 'alphaOnly' => 1], $object->meta()->rawInput()); + + // Flipping the discriminator: branch 1 succeeds but does not claim alphaOnly, so the + // outer unevaluatedProperties:false rejects after the setter commits. The hook's + // catch branch restores every snapshotted field. + try { + $object->setKind('b'); + $this->fail('Expected setKind to throw because alphaOnly becomes unevaluated'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['alphaOnly']", + $exception->getMessage(), + ); + $this->assertSame(['alphaOnly'], $exception->getUnevaluatedProperties()); + } + $this->assertSame('a', $object->getKind()); + $this->assertSame(['kind' => 'a', 'alphaOnly' => 1], $object->meta()->rawInput()); + } + + /** + * if/then/else discriminator flip. Same combined shape as the oneOf test: + * - same-value setter is a no-op; + * - flipping mode swaps which branch (`then` or `else`) contributes evaluated names; + * the prior branch's inline key (`onlyWhenOn`) becomes unevaluated; the setter + * throws and the model rolls back. + */ + public function testIfThenElseFlipRollsBackAndSameValueIsNoOp(): void + { + $className = $this->generateClassFromFile('IfThenElseDiscriminator.json', $this->defaultConfig()); + $object = new $className(['mode' => 'on', 'onlyWhenOn' => 5]); + + $object->setMode('on'); + $this->assertSame('on', $object->getMode()); + $this->assertSame(['mode' => 'on', 'onlyWhenOn' => 5], $object->meta()->rawInput()); + + try { + $object->setMode('off'); + $this->fail('Expected setMode to throw because onlyWhenOn becomes unevaluated'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['onlyWhenOn']", + $exception->getMessage(), + ); + $this->assertSame(['onlyWhenOn'], $exception->getUnevaluatedProperties()); + } + $this->assertSame('on', $object->getMode()); + $this->assertSame(['mode' => 'on', 'onlyWhenOn' => 5], $object->meta()->rawInput()); + } + + /** + * anyOf with two branches that both claim `shared`. After setKind('secondary') branch 0 + * (constrained by `const: primary`) fails but branch 1 (any string kind) keeps + * succeeding and still claims `shared`. Because at least one successful branch still + * covers the key, the unevaluated check accepts and the setter completes silently. + */ + public function testAnyOfOverlappingCoverageKeepsKeyEvaluatedAfterFlip(): void + { + $className = $this->generateClassFromFile('AnyOfOverlappingCoverage.json', $this->defaultConfig()); + $object = new $className(['kind' => 'primary', 'shared' => 7]); + + $object->setKind('secondary'); + + $this->assertSame('secondary', $object->getKind()); + $this->assertSame(['kind' => 'secondary', 'shared' => 7], $object->meta()->rawInput()); + } + + /** + * anyOf where each branch declares its own kind-keyed properties and `xOnly` is only + * claimed by branch 0. Flipping kind makes branch 0 fail; branch 1 succeeds but does + * not claim xOnly, so the outer unevaluatedProperties:false rejects and the setter + * rolls back. Exercises the same code path as the oneOf and if/then/else flip tests + * but with a distinct composition shape so the harvest of composition-branch names by + * the activation walk is exercised against anyOf too. + */ + public function testAnyOfSoleCovererFlipMakesOrphanedKeyUnevaluated(): void + { + $className = $this->generateClassFromFile('AnyOfSoleCoverer.json', $this->defaultConfig()); + $object = new $className(['kind' => 'x', 'xOnly' => 3]); + + try { + $object->setKind('y'); + $this->fail('Expected setKind to throw because xOnly loses its sole coverer'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['xOnly']", + $exception->getMessage(), + ); + $this->assertSame(['xOnly'], $exception->getUnevaluatedProperties()); + } + + $this->assertSame('x', $object->getKind()); + $this->assertSame(['kind' => 'x', 'xOnly' => 3], $object->meta()->rawInput()); + } + + /** + * Nested-branch composition: the parent's `type: object` causes the allOf branches to be + * rendered as separate nested classes rather than merged inline. The cache stores the + * nested instance reference and the accumulator rebuild calls getEvaluatedProperties() + * on it. + * + * Two assertions on the same generated class: + * - extra keys are rejected at construction even when the branches live in nested + * classes (proves the activation/cache wiring fires for nested-branch composition); + * - `name` is declared directly on the outer schema and by neither branch, so + * `CompositionValidationPostProcessor`'s validator-property map must not wire + * `setName()` to call `_validateComposition_0()` at all — a cache-hit skip, not a + * revalidation that happens to succeed. Proven observably, not just asserted from + * reading the generated code: `foo`'s raw-input value is corrupted via reflection to + * something the branch's own type check would reject, then `setName()` is called. If + * the setter *did* trigger composition revalidation, the corrupted `foo` would surface + * as a validation failure; since it does not, the map correctly excluded `name`. + */ + public function testNestedBranchSchemaRejectsExtrasAndSkipsCompositionRevalidationForUnrelatedSetters(): void + { + $className = $this->generateClassFromFile('AllOfNestedBranches.json', $this->defaultConfig()); + + try { + new $className(['name' => 'Alice', 'foo' => 'hello', 'bar' => 42, 'stray' => 'unclaimed']); + $this->fail('constructor must reject the stray key claimed by no branch'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['stray']", + $exception->getMessage(), + ); + $this->assertSame(['stray'], $exception->getUnevaluatedProperties()); + } + + $object = new $className(['name' => 'Alice', 'foo' => 'hello', 'bar' => 42]); + + $rawModelDataInputProperty = new ReflectionProperty($object, '_rawModelDataInput'); + $corruptedRawInput = $rawModelDataInputProperty->getValue($object); + $corruptedRawInput['foo'] = 12345; + $rawModelDataInputProperty->setValue($object, $corruptedRawInput); + + $object->setName('Bob'); + + $this->assertSame('Bob', $object->getName()); + $this->assertSame( + ['name' => 'Bob', 'foo' => 12345, 'bar' => 42], + $object->meta()->rawInput(), + ); + } + + /** + * populate() that breaks the unevaluated check rolls every snapshotted field back so the + * caller observing the throw sees the pre-call state. Populate.phptpl runs a single + * one-shot revalidation after the merge — the per-property hooks emit empty code in + * batch mode — and the rollback restores both rollbackValues and _rawModelDataInput. + */ + public function testPopulateRollsBackEveryFieldWhenDeltaBreaksUnevaluated(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new PopulatePostProcessor()); + }; + + $className = $this->generateClassFromFile('OneOfKindDiscriminator.json', $this->defaultConfig()); + $object = new $className(['kind' => 'a', 'alphaOnly' => 1]); + + try { + $object->populate(['kind' => 'b']); + $this->fail('Expected populate to throw because alphaOnly becomes unevaluated'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['alphaOnly']", + $exception->getMessage(), + ); + $this->assertSame(['alphaOnly'], $exception->getUnevaluatedProperties()); + } + + $this->assertSame('a', $object->getKind()); + $this->assertSame(['kind' => 'a', 'alphaOnly' => 1], $object->meta()->rawInput()); + } + + /** + * Collect-errors mode: the registry accumulates failures rather than throwing on the + * first one. The hook's collect-errors emission compares the registry's state after + * the revalidate call and surfaces the registry when new errors landed. Confirms the + * codegen-time branch picks the right shape for collect-errors mode and that the + * collected error carries the orphaned-key message produced by the unevaluated + * validator. + */ + /** + * Collect-errors mode must surface every error detected by a setter in one exception, + * regardless of which validation phase recorded it. This setter triggers both a + * property-validator failure (kind's minLength constraint) and an unevaluated-properties + * violation (alphaOnly becomes orphaned when the discriminator flips). The registry + * should carry both errors so the caller can fix everything in one round. + * + * Asserts the full property-validator message, the full unevaluated-properties message, + * and that the model state is rolled back to its pre-setter values. + */ + public function testCollectErrorsModeCollectsValidatorAndUnevaluatedErrorsTogether(): void + { + $className = $this->generateClassFromFile( + 'OneOfKindDiscriminatorConstrainedString.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(true), + ); + + $object = new $className(['kind' => 'aaa', 'alphaOnly' => 1]); + + try { + // Two-char value: violates kind's minLength: 3 *and* fails both oneOf branches' + // const checks. With the setter deferring its registry throw past the after-hook + // the unevaluated revalidation still runs and contributes its own error to the + // registry before the setter finally throws. + $object->setKind('bb'); + $this->fail('Expected setKind to throw an aggregated error registry'); + } catch (ErrorRegistryException $registry) { + $errors = $registry->getErrors(); + + $minLengthErrors = array_values(array_filter( + $errors, + static fn(\Throwable $error): bool => $error instanceof MinLengthException, + )); + $unevaluatedErrors = array_values(array_filter( + $errors, + static fn(\Throwable $error): bool => $error instanceof UnevaluatedPropertiesException, + )); + + $this->assertCount( + 1, + $minLengthErrors, + 'expected one MinLengthException; got messages: ' . implode( + ' | ', + array_map(static fn(\Throwable $e): string => $e->getMessage(), $errors), + ), + ); + $this->assertSame("Value for 'kind' must not be shorter than 3", $minLengthErrors[0]->getMessage()); + + $this->assertCount(1, $unevaluatedErrors, 'expected one UnevaluatedPropertiesException'); + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['alphaOnly']", + $unevaluatedErrors[0]->getMessage(), + ); + $this->assertSame(['alphaOnly'], $unevaluatedErrors[0]->getUnevaluatedProperties()); + } + + // The model is rolled back: every assertion below would fail with the half-applied + // mutation that the prior (eager-throw) behaviour produced. + $this->assertSame('aaa', $object->getKind()); + $this->assertSame(['kind' => 'aaa', 'alphaOnly' => 1], $object->meta()->rawInput()); + } +} diff --git a/tests/Basic/UnevaluatedPropertiesValidatorTest.php b/tests/Basic/UnevaluatedPropertiesValidatorTest.php new file mode 100644 index 00000000..a5d374f1 --- /dev/null +++ b/tests/Basic/UnevaluatedPropertiesValidatorTest.php @@ -0,0 +1,1209 @@ +rawInput()` unchanged. One data row per scenario. + * + * @return array}> + */ + public static function acceptanceProvider(): array + { + return [ + 'declared property only' => [ + 'NoExtraPropertiesAllowed.json', + ['name' => 'Alice'], + ], + 'empty input when no required properties' => [ + 'NoExtraPropertiesAllowed.json', + [], + ], + 'pattern-matched property accepted' => [ + 'PatternPropertiesEvaluated.json', + ['x_one' => 'a', 'x_two' => 'b'], + ], + 'unevaluatedProperties schema accepts matching extra' => [ + 'SchemaFormUnevaluated.json', + ['name' => 'Alice', 'count' => 42], + ], + 'allOf branches contribute evaluated keys to outer' => [ + 'AllOfBranchesCoverEvaluation.json', + ['name' => 'Alice', 'foo' => 'hello', 'bar' => 42], + ], + 'anyOf first-branch success contributes kind' => [ + 'AnyOfSelectsSuccessfulBranches.json', + ['kind' => 'A'], + ], + 'anyOf second-branch success contributes value' => [ + 'AnyOfSelectsSuccessfulBranches.json', + ['value' => 99], + ], + 'if-branch pass contributes if + then names' => [ + 'IfThenElseEvaluation.json', + ['kind' => 'A', 'valueA' => 'hello'], + ], + 'if-branch fail contributes else name only' => [ + 'IfThenElseEvaluation.json', + ['kind' => 'B', 'valueB' => 7], + ], + 'not-branch success leaves outer declared property accessible' => [ + 'NotBranchContributesNothing.json', + ['foo' => 'hello'], + ], + ]; + } + + /** + * @param array $input + */ + #[DataProvider('acceptanceProvider')] + public function testAcceptedInputRoundTrips(string $schemaFile, array $input): void + { + $className = $this->generateClassFromFile($schemaFile); + $instance = new $className($input); + + $this->assertSame($input, $instance->meta()->rawInput()); + } + + /** + * Rejected input — construction must throw the named exception with a message describing + * the offending key(s). The regex pattern is anchored on the full message so a different + * exception body cannot accidentally satisfy it. + * + * @return array, 2: class-string<\Throwable>, 3: string}> + */ + public static function rejectionProvider(): array + { + $notAllowed = static fn(string $propertyName): string => + "/^Provided JSON for '.*' contains not allowed unevaluated properties \['" + . preg_quote($propertyName, '/') + . "'\]$/"; + + return [ + 'undeclared property at top level' => [ + 'NoExtraPropertiesAllowed.json', + ['name' => 'Alice', 'extra' => 'value'], + UnevaluatedPropertiesException::class, + $notAllowed('extra'), + ], + 'non-pattern property rejected' => [ + 'PatternPropertiesEvaluated.json', + ['x_ok' => 'a', 'other' => 'bad'], + UnevaluatedPropertiesException::class, + $notAllowed('other'), + ], + 'unevaluatedProperties schema rejects failing extra' => [ + 'SchemaFormUnevaluated.json', + ['name' => 'Alice', 'count' => 'not-an-integer'], + InvalidUnevaluatedPropertiesException::class, + '/^Provided JSON for .* contains invalid unevaluated properties/', + ], + 'allOf rejects key not claimed by any branch' => [ + 'AllOfBranchesCoverEvaluation.json', + ['name' => 'Alice', 'foo' => 'hello', 'stray' => 'unclaimed'], + UnevaluatedPropertiesException::class, + $notAllowed('stray'), + ], + 'if/then/else rejects key only claimed by opposite branch' => [ + // kind='A' triggers `if` → `then` (which declares valueA), not `else` (valueB). + // valueB is not claimed by any successful branch. + 'IfThenElseEvaluation.json', + ['kind' => 'A', 'valueB' => 7], + UnevaluatedPropertiesException::class, + $notAllowed('valueB'), + ], + 'not-branch success does not contribute forbidden to evaluated' => [ + // The fixture's `not` schema is { properties: { forbidden: integer }, + // required: [forbidden] }. A string `forbidden` makes the not-body fail + // (string ≠ integer), so `not` SUCCEEDS — meaning the not-branch's `forbidden` + // declaration is visible at slot-write time. If the implementation treated + // not-success as contributing annotations, this case would slip through. + 'NotBranchContributesNothing.json', + ['foo' => 'hello', 'forbidden' => 'string-value'], + UnevaluatedPropertiesException::class, + $notAllowed('forbidden'), + ], + ]; + } + + /** + * @param array $input + * @param class-string<\Throwable> $exceptionClass + */ + #[DataProvider('rejectionProvider')] + public function testRejectedInputThrows( + string $schemaFile, + array $input, + string $exceptionClass, + string $messageRegex, + ): void { + $this->expectException($exceptionClass); + $this->expectExceptionMessageMatches($messageRegex); + + $className = $this->generateClassFromFile($schemaFile); + new $className($input); + } + + /** + * When the same schema declares a non-false `additionalProperties`, `unevaluatedProperties` + * is a no-op — every key not in `properties`/`patternProperties` is already claimed by + * `additionalProperties`. The factory therefore skips emitting any unevaluatedProperties + * validator. This is a separate test rather than an acceptance row because the absence of + * a validator (not merely its acceptance) is what is being verified. + */ + public function testAdditionalPropertiesShortCircuitsUnevaluatedCheck(): void + { + $className = $this->generateClassFromFile('AdditionalPropertiesShortcut.json'); + + // `extra` is not in `properties`, but `additionalProperties: {type: string}` claims it. + // The unevaluatedProperties: false declaration must NOT reject it. + $instance = new $className(['name' => 'Alice', 'extra' => 'value']); + + $this->assertSame( + ['name' => 'Alice', 'extra' => 'value'], + $instance->meta()->rawInput(), + ); + } + + /** + * Required and unevaluated both fire when a required property is missing AND an undeclared + * extra is present. Two passes on the same generated schema: + * + * - Direct-exception mode: the constructor runs _executeBaseValidators() → + * _executePostCompositionValidators() → per-property processing in that order. The + * unevaluated check lives in the post-composition phase and surfaces first because the + * required check is part of per-property processing, which runs last. Documents the + * actual surfacing order so a future refactor that moves required earlier surfaces as + * a deliberate change instead of a silent regression. + * + * - Collect-errors mode: both errors accumulate in the registry. Asserts each + * exception's class identity and its full message so the registry's contents are + * pinned to the spec-relevant identifiers. + */ + public function testRequiredAndUnevaluatedBothFireWhenBothAreViolated(): void + { + // Direct-exception mode. + $directClassName = $this->generateClassFromFile('RequiredPropertyFailsFirst.json'); + + try { + new $directClassName(['extra' => 'value']); + $this->fail('Expected the unevaluated check to surface in direct-exception mode'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$directClassName}' contains not allowed unevaluated properties ['extra']", + $exception->getMessage(), + ); + $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + // Root-level unevaluatedProperties: false — pointer stamped by the factory identifies + // the exact schema keyword that produced the rejection. + $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); + } + + // Collect-errors mode — both errors must land in the registry. + $collectClassName = $this->generateClassFromFile( + 'RequiredPropertyFailsFirst.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + try { + new $collectClassName(['extra' => 'value']); + $this->fail('Expected the error registry to throw in collect-errors mode'); + } catch (ErrorRegistryException $registry) { + $errors = $registry->getErrors(); + + $requiredErrors = array_values(array_filter( + $errors, + static fn(\Throwable $error): bool => $error instanceof RequiredValueException, + )); + $unevaluatedErrors = array_values(array_filter( + $errors, + static fn(\Throwable $error): bool => $error instanceof UnevaluatedPropertiesException, + )); + + $this->assertCount(1, $requiredErrors, 'expected one RequiredValueException'); + $this->assertSame("Missing required value for 'name'", $requiredErrors[0]->getMessage()); + + $this->assertCount(1, $unevaluatedErrors, 'expected one UnevaluatedPropertiesException'); + $this->assertSame( + "Provided JSON for '{$collectClassName}' contains not allowed unevaluated properties ['extra']", + $unevaluatedErrors[0]->getMessage(), + ); + $this->assertSame('/unevaluatedProperties', $unevaluatedErrors[0]->getJsonPointer()->pointer); + } + } + + /** + * The JSON Schema spec defines `default` as annotation-only: the keyword does not modify + * the instance during validation. Generators that materialise the default into a PHP field + * after validation must keep validation operating on the JSON instance view — otherwise a + * declared-but-absent property with a default would erroneously appear as evaluated to an + * enclosing `unevaluatedProperties` keyword, and worse, leak into `meta()->rawInput()` as + * a key the caller never supplied. + * + * Two assertions on one generated class: construction with the property absent succeeds and + * `meta()->rawInput()` reflects only what the caller passed in; the property getter + * returns the default value so the PHP field side of the contract is also intact. + */ + public function testDefaultValueIsNotPartOfEvaluatedSet(): void + { + $className = $this->generateClassFromFile('DefaultsNotEvaluated.json'); + $instance = new $className(['name' => 'Alice']); + + // The instance view used by validation never includes `timeout`, so + // unevaluatedProperties: false accepts the construction. + $this->assertSame(['name' => 'Alice'], $instance->meta()->rawInput()); + + // The PHP property side still surfaces the materialised default — the spec only forbids + // the default from entering the JSON instance view, not the language-level field. + $this->assertSame(30, $instance->getTimeout()); + } + + /** + * Empty `allOf: []` is spec-legal — the schema's existing + * `AbstractCompositionValidatorFactory::warnIfEmpty()` mechanism emits a warning at code- + * generation time when output is enabled, but the schema still compiles and runs. With no + * composition branches to contribute evaluated keys, the outer unevaluatedProperties: + * false sees only the local `properties` declarations. + * + * Two scenarios on the same generated class: + * - declared key accepted on its own — assertion on `meta()->rawInput()`; + * - any extra key rejected because the empty composition cannot claim it for the + * accumulator — assertions on the exception message and the offending key list. + */ + public function testEmptyAllOfStillEnforcesUnevaluatedAtOuterLevel(): void + { + $className = $this->generateClassFromFile('EmptyAllOf.json'); + + $accepted = new $className(['name' => 'Alice']); + $this->assertSame(['name' => 'Alice'], $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', 'extra' => 'value']); + $this->fail('Empty allOf must not rescue extras from unevaluatedProperties: false'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['extra']", + $exception->getMessage(), + ); + $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); + } + } + + /** + * A bare boolean `true` composition element is spec-legal inside `allOf` — it always + * succeeds and contributes no declared properties of its own. Regression guard for a + * null-nested-schema dereference: the always-true branch is represented by + * `createAlwaysTrueBranchProperty()` and has no nested `Schema`, so the accumulator must + * skip it rather than crash trying to read declared property names off it. The sibling + * real branch (`{properties: {kind}}`) still contributes `kind` normally. + * + * Two scenarios on the same generated class: + * - `{name, kind}` accepted — the real branch's `kind` is evaluated, `name` is a + * declared outer property; + * - `{name, kind, extra}` rejected — the always-true branch contributes nothing to the + * evaluated set, so `extra` remains unevaluated. + */ + public function testAlwaysTrueCompositionBranchContributesNoEvaluatedKeys(): void + { + $className = $this->generateClassFromFile('AlwaysTrueBranchContributesNoEvaluatedKeys.json'); + + $accepted = new $className(['name' => 'Alice', 'kind' => 'X']); + $this->assertSame(['name' => 'Alice', 'kind' => 'X'], $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', 'kind' => 'X', 'extra' => 1]); + $this->fail('An always-true branch must not rescue extras from unevaluatedProperties: false'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['extra']", + $exception->getMessage(), + ); + $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); + } + } + + /** + * A composition branch that declares its own `unevaluatedProperties: {schema}` records + * the keys it evaluates against that schema. Those records must propagate to an + * outer `unevaluatedProperties: false` so the outer treats them as already evaluated. + * + * Two scenarios on the same generated class (collect-errors mode so the registry + * surfaces both the inner failure cause and the outer-level orphans in one message): + * - `{foo: "hi", bar: 5}` exercises the success path. The branch's `properties` + * declares `foo`. The branch's `unevaluatedProperties: {integer}` validates `bar` + * (5 is integer). The outer `unevaluatedProperties: false` sees both keys evaluated + * through the branch's contribution and accepts. + * - `{foo: "hi", bar: "not-int"}` exercises the inner-rejection path. The branch's + * `unevaluatedProperties: {integer}` rejects `bar` ("not-int" fails the integer + * check); the allOf composition therefore matches zero elements; and the outer's + * `unevaluatedProperties: false` then sees both `foo` and `bar` as orphans because + * no successful branch claimed them. All three pieces appear in the registry's + * aggregated message. + */ + public function testNestedUnevaluatedInBranchPropagatesClaimsToOuter(): void + { + $className = $this->generateClassFromFile( + 'NestedUnevaluatedInBranchPropagatesClaims.json', + (new GeneratorConfiguration())->setCollectErrors(true), + ); + + $accepted = new $className(['foo' => 'hi', 'bar' => 5]); + $this->assertSame(['foo' => 'hi', 'bar' => 5], $accepted->meta()->rawInput()); + + // The branch is rendered as a nested class alongside the outer; its uniqid-suffixed + // name is resolved so the assertion below can spell the full message. + $nestedClassName = $this->resolveNestedClassName($className); + + try { + new $className(['foo' => 'hi', 'bar' => 'not-int']); + $this->fail('Inner unevaluatedProperties: {integer} must reject the non-integer extra'); + } catch (ErrorRegistryException $registry) { + // The registry surfaces the inner cause AND a separate outer-level orphan report + // for [foo, bar]. Both lines are correct under JSON Schema 2019-09's applicator + // rules: a composition branch that fails as a whole contributes no annotations, + // so even foo — whose individual value passed the branch's properties.foo + // validator — is treated as unevaluated at the outer level because the branch's + // claim never propagated. The two messages are complementary: the composition + // block reports why the branch failed, and the trailing line reports what the + // outer's unevaluatedProperties: false saw once it ran with no successful + // branches. + $this->assertSame( + <<getMessage(), + ); + } + } + + /** + * A composition branch that declares its own `additionalProperties: {schema}` contributes + * the keys it validated against that schema to the outer accumulator. Two assertions on the + * same generated class: + * - `{kind: "X", extra: 1}` exercises the success path: branch 0's additionalProperties + * validates `extra` against `type: integer` and credits `extra` to its evaluated set; + * the outer unevaluatedProperties: false sees `extra` as evaluated and accepts. + * - `{kind: "X", extra: "bar"}` exercises the failure path: branch 0's additionalProperties + * rejects `extra`'s value, so branch 0 records `success: false` and contributes no + * evaluated keys. Branch 1 still succeeds (no constraint on extras at all means it makes + * no claim either) so anyOf passes — but the outer accumulator no longer credits `extra` + * and unevaluatedProperties: false rejects. + * + * The failure surface here is the outer unevaluated check, not the branch-internal + * additionalProperties — that internal failure is silently swallowed by the branch's + * try/catch, which is exactly the behaviour the per-key validity signal preserves. + */ + public function testBranchLevelAdditionalPropertiesFeedsEvaluatedSetThroughComposition(): void + { + $className = $this->generateClassFromFile('BranchLevelAdditionalProperties.json'); + + $accepted = new $className(['kind' => 'X', 'extra' => 1]); + $this->assertSame(['kind' => 'X', 'extra' => 1], $accepted->meta()->rawInput()); + + try { + new $className(['kind' => 'X', 'extra' => 'bar']); + $this->fail( + 'Branch-level additionalProperties value failure must orphan the key so the ' + . 'outer unevaluated check fires', + ); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['extra']", + $exception->getMessage(), + ); + $this->assertSame(['extra'], $exception->getUnevaluatedProperties()); + } + } + + /** + * A branch-level `unevaluatedProperties: true` is an applicator like any other: within the + * branch every key is unevaluated, `true` validates all of them, and per the 2019-09 + * annotation rules the branch thereby claims every key on the instance. The outer + * `unevaluatedProperties: false` therefore accepts any input — including keys no other + * sibling covers. An explicit `true` must not be collapsed into the absent-keyword case, + * which produces no annotation. + */ + public function testBranchLevelUnevaluatedTrueClaimsEveryKeyForTheOuterAccumulator(): void + { + $className = $this->generateClassFromFile('BranchUnevaluatedTrueClaims.json'); + + $accepted = new $className(['foo' => 'x', 'bar' => 5]); + $this->assertSame('x', $accepted->getFoo()); + $this->assertSame(['foo' => 'x', 'bar' => 5], $accepted->meta()->rawInput()); + } + + /** + * A composition branch's own `unevaluatedProperties` cannot see property names the + * *enclosing* schema (or a sibling branch) declares — `allOf` does not introduce a new + * evaluation-context boundary, so per spec a branch-level `unevaluatedProperties` should be + * evaluated against the *same* annotation set an outer-level one would be, but nothing in + * the current architecture plumbs the enclosing schema's declared names (or a sibling + * branch's claims) through to a branch's own, separately-generated class - only the reverse + * direction (branch claims propagating up) is implemented. + * + * Rather than silently computing a wrong "unevaluated" set (this exact shape used to reject + * `{name: "Alice"}`, even though `name` is declared and validated by the enclosing schema's + * own `properties`), the generator now rejects the schema itself at generation time with a + * distinct exception - see `.claude/topics/branch-unevaluated-down-propagation/` for why a + * real fix was deferred instead of implemented (a sound fix exists for `allOf` alone, but + * the equivalent for `anyOf`/`oneOf` can have no unique consistent answer at all when two + * sibling branches each gate their own `unevaluatedProperties` on the other's claims). + */ + public function testBranchOnlyUnevaluatedPropertiesThrowsUnsupportedSchemaFeatureException(): void + { + $this->expectException(UnsupportedSchemaFeatureException::class); + $this->expectExceptionMessage( + "Branch #1 of the composition for 'BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations' " + . "declares 'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 10, column 5', + ); + + $this->generateClassFromFile('BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json', null, true); + } + + /** + * The unsupported-down-propagation guard above is driven by + * `AbstractComposedPropertyValidator::getComposedProperties()`, which every composition + * keyword's validator populates uniformly - confirms the guard actually reaches `anyOf`, + * `oneOf`, and `if`/`then`/`else` too, not just the `allOf` shape the bug was originally + * found with. Also confirms the *sibling-branch* half of the detection (as opposed to the + * enclosing-schema-declares-something half every other row here exercises): the enclosing + * schema itself declares nothing in `SiblingBranchUnsupportedDownPropagation.json` - only a + * *sibling* `allOf` branch declares `properties`. + * + * @return array + */ + public static function unsupportedDownPropagationDataProvider(): array + { + return [ + 'anyOf branch' => [ + 'AnyOfBranchUnsupportedDownPropagation.json', + "Branch #1 of the composition for 'AnyOfBranchUnsupportedDownPropagation' declares " + . "'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 10, column 5', + ], + 'oneOf branch' => [ + 'OneOfBranchUnsupportedDownPropagation.json', + "Branch #1 of the composition for 'OneOfBranchUnsupportedDownPropagation' declares " + . "'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 10, column 5', + ], + 'if/then branch' => [ + 'IfThenBranchUnsupportedDownPropagation.json', + "Branch #2 of the composition for 'IfThenBranchUnsupportedDownPropagation' declares " + . "'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 19, column 11', + ], + 'sibling branch declares the claim, not the enclosing schema' => [ + 'SiblingBranchUnsupportedDownPropagation.json', + "Branch #1 of the composition for 'SiblingBranchUnsupportedDownPropagation' declares " + . "'unevaluatedProperties', which cannot yet see property names or indices " + . 'declared by the enclosing schema or a sibling branch - remove ' + . "'unevaluatedProperties' from the branch, or restructure the schema so it is " + . 'declared only at the level that needs it at line 5, column 5', + ], + ]; + } + + #[DataProvider('unsupportedDownPropagationDataProvider')] + public function testUnsupportedDownPropagationThrowsAcrossCompositionKeywords( + string $schemaFile, + string $expectedMessage, + ): void { + $this->expectException(UnsupportedSchemaFeatureException::class); + $this->expectExceptionMessage($expectedMessage); + + $this->generateClassFromFile($schemaFile, null, true); + } + + /** + * `not` is deliberately exempt from the unsupported-down-propagation guard: it already + * blocks annotations from crossing its boundary in both directions by design (see + * `not.rst`) - nothing about its own outcome depends on outside annotations, so there is + * nothing for the guard to protect against here, unlike every other composition keyword. + */ + public function testNotBranchIsExemptFromUnsupportedDownPropagationGuard(): void + { + $className = $this->generateClassFromFile('NotBranchExemptFromUnsupportedDownPropagation.json'); + + $this->assertSame(['name' => 'Alice'], (new $className(['name' => 'Alice']))->meta()->rawInput()); + } + + /** + * Keys matched by a successful branch's `patternProperties` (with passing values) are + * evaluated by that branch and must be credited to the outer accumulator — also when the + * branch renders as a nested class rather than being merged or inlined. Two assertions on + * the same generated class: + * - `{x-a: "v"}` matches the branch pattern with a passing value → credited → accepted. + * - `{other: 1}` matches no pattern and no other applicator → rejected by the outer + * unevaluated check. + */ + public function testBranchLevelPatternPropertiesMatchesCreditTheOuterAccumulator(): void + { + $className = $this->generateClassFromFile('BranchPatternPropertiesClaim.json'); + + $accepted = new $className(['x-a' => 'v']); + $this->assertSame(['x-a' => 'v'], $accepted->meta()->rawInput()); + + try { + new $className(['other' => 1]); + $this->fail('Expected UnevaluatedPropertiesException for a key no branch pattern matches'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['other']", + $exception->getMessage(), + ); + $this->assertSame(['other'], $exception->getUnevaluatedProperties()); + } + } + + /** + * A property subschema declaring object applicators (`properties`, `unevaluatedProperties`) + * without an explicit `type: object` still applies those keywords when the instance value + * is an object — JSON Schema applicators are not gated on a type declaration. The keyword + * must not be silently dropped for untyped subschemas: the subschema behaves exactly as if + * `type: object` were declared, so the rejection surfaces through the nested-object + * wrapping. The nested class name is matched by regex because the class does not carry a + * predictable name (the class-name generator appends a uniqid). + */ + public function testUnevaluatedPropertiesAppliesWithoutExplicitObjectTypeDeclaration(): void + { + $className = $this->generateClassFromFile('UntypedNestedSchemaWithUnevaluated.json'); + + // Object value: only the declared key is present — nothing is unevaluated inside `child`. + $accepted = new $className(['child' => ['known' => 'a']]); + $this->assertSame(['child' => ['known' => 'a']], $accepted->meta()->rawInput()); + + // Non-object value: an untyped schema imposes no type constraint, so a scalar passes through + // untouched even though `child` declares object applicators. + $this->assertSame('a string', (new $className(['child' => 'a string']))->getChild()); + + // A schema carrying only object-constraining keywords (no sibling scalar-type + // applicator) is object-describing (see docs/source/combinedSchemas/impliedObjects.rst): + // a non-object value passes through unconditionally, so the getter stays untyped `mixed` + // rather than naming the nested class. + $this->assertSame('mixed', $this->getReturnTypeAnnotation($className, 'getChild')); + + // `extra` is claimed by nothing inside `child` and must be rejected by unevaluatedProperties. + try { + new $className(['child' => ['known' => 'a', 'extra' => 1]]); + $this->fail('Unevaluated key inside an untyped object subschema must be rejected'); + } catch (NestedObjectException $exception) { + $this->assertMatchesRegularExpression( + <<<'REGEX' + /^Invalid nested object for property 'child': + - Provided JSON for '.+' contains not allowed unevaluated properties \['extra'\]$/ + REGEX, + $exception->getMessage(), + ); + } + } + + /** + * The `unevaluatedProperties` keyword may be expressed as an inline `$ref` — the runtime + * shape is a schema, so the ref-resolved schema must be applied to every extra key. Two + * assertions on the same generated class: + * - `{name: "Alice", count: 42}` accepts because 42 satisfies the resolved integer + * schema. + * - `{name: "Alice", count: "not-int"}` rejects because the value fails the resolved + * integer schema. + */ + public function testUnevaluatedPropertiesReferencedViaRef(): void + { + $className = $this->generateClassFromFile('UnevaluatedIsRef.json'); + + $accepted = new $className(['name' => 'Alice', 'count' => 42]); + $this->assertSame(['name' => 'Alice', 'count' => 42], $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', 'count' => 'not-int']); + $this->fail('$ref-resolved unevaluatedProperties schema must reject non-integer extra'); + } catch (InvalidUnevaluatedPropertiesException $exception) { + $this->assertSame( + <<getMessage(), + ); + $this->assertSame('/unevaluatedProperties', $exception->getJsonPointer()->pointer); + } + } + + /** + * A `$ref` composition branch (same-file `$defs`, or an external file - see + * EXTERNAL_JSON_DIRECTORIES above) resolves to the referenced schema at generation time, and + * its `properties` must contribute to the outer `unevaluatedProperties: false` accumulator. + * One data provider, not two methods: only `$ref` resolution differs between the rows (its + * own concern, covered in ReferencePropertyTest/RefSiblingsTest), not the behaviour under + * test here. + * + * Note: `ExternalRefBranch.json`'s target renders as an ordinary, fully-validating nested + * class, not an `ExternalSchema` placeholder (a `$ref` outside the provider's base directory + * doesn't by itself produce one - see testExternalSchemaPlaceholderBranchCreditsNothingLikeAVacuousBranch() + * for that case, which needs a target file with no `type`/`properties`/composition of its + * own). + */ + #[DataProvider('refResolvedBranchDataProvider')] + public function testRefResolvedBranchContributesAnnotations( + string $schemaFile, + array $acceptedInput, + string $strayKey, + ): void { + $className = $this->generateClassFromFile($schemaFile); + + $accepted = new $className($acceptedInput); + $this->assertSame($acceptedInput, $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', $strayKey => 1]); + $this->fail('An undeclared key must be rejected by unevaluatedProperties: false'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['{$strayKey}']", + $exception->getMessage(), + ); + $this->assertSame([$strayKey], $exception->getUnevaluatedProperties()); + } + } + + public static function refResolvedBranchDataProvider(): array + { + return [ + 'same-file $defs reference' => [ + 'RefResolvedBranchContributesAnnotations.json', + ['name' => 'Alice', 'foo' => 'hi', 'bar' => 5], + 'stray', + ], + 'external-file reference crossing a directory boundary' => [ + 'ExternalRefBranch.json', + ['name' => 'Alice', 'external' => 'hi'], + 'whatever', + ], + ]; + } + + /** + * A `$ref` to an entire external file declaring no `type`/`properties`/composition (only + * `definitions`) renders as an `ExternalSchema` placeholder - a branch that's vacuously + * satisfied (like `{}`) but declares none of `properties`/`patternProperties`/ + * `additionalProperties`, so per the spec-mandated rule (combinedSchemas/allOf.rst's + * "Property and item evaluation propagation") it credits nothing to `unevaluatedProperties`, + * same as a literal `{}` or `true` branch. `whatever` is therefore correctly rejected. + * + * Also asserts `warnIfVacuousBranch()` still flags this branch even though it inherits the + * outer `type: object` - the vacuousness check must look past an inherited type to the + * branch's own authored content. + */ + public function testExternalSchemaPlaceholderBranchCreditsNothingLikeAVacuousBranch(): void + { + $logger = new RecordingLogger(); + + $className = $this->generateClassFromFile( + 'ExternalSchemaPlaceholderBranch.json', + (new GeneratorConfiguration())->setLogger($logger)->setCollectErrors(false), + ); + + $this->assertTrue( + $this->hasLogEntry( + $logger->getEntries(), + 'warning', + "Composition branch #{index} for '{property}' carries no validation keyword and" + . ' matches any value', + ['index' => 1], + ), + 'The placeholder branch declares no validation keyword and matches any value - it ' + . 'should be flagged as vacuous even though it inherits the outer type.', + ); + + $accepted = new $className(['name' => 'Alice']); + $this->assertSame(['name' => 'Alice'], $accepted->meta()->rawInput()); + + try { + new $className(['name' => 'Alice', 'whatever' => 'x']); + $this->fail('An undeclared key must be rejected by unevaluatedProperties: false'); + } catch (UnevaluatedPropertiesException $exception) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['whatever']", + $exception->getMessage(), + ); + $this->assertSame(['whatever'], $exception->getUnevaluatedProperties()); + } + } + + /** + * Non-regression companion: a branch that explicitly declares its own `type` (even one + * identical to the parent's) must stay silent - confirms the fix above distinguishes an + * inherited type from an author-declared one in both directions. + */ + public function testAuthorDeclaredTypeBranchStaysSilent(): void + { + $logger = new RecordingLogger(); + + $this->generateClassFromFile( + 'AuthorDeclaredTypeBranchStaysSilent.json', + (new GeneratorConfiguration())->setLogger($logger), + ); + + $this->assertFalse( + $this->hasLogEntry( + $logger->getEntries(), + 'warning', + "Composition branch #{index} for '{property}' carries no validation keyword and" + . ' matches any value', + ), + 'A branch with its own explicit type declaration must not be flagged as vacuous.', + ); + } + + /** + * A schema that references itself through `$defs` must not cause the activation walk to + * recurse indefinitely — the walk's `seenSchemas` map is required (not defensive) for + * termination. This is the object-side analogue of the array-side self-reference test. + * + * Three scenarios on the same generated class: + * - `{root: {name: "n", child: {name: "n2"}}}` accepts because the recursive node's + * `unevaluatedProperties: false` sees only the declared `name` / `child` keys at + * every level. + * - A stray key one level down (`root.stray`) surfaces as a `NestedObjectException` + * wrapping the inner `UnevaluatedPropertiesException`. Unwrapping through + * `getNestedException()` confirms the inner exception's identity and its + * `getUnevaluatedProperties()` return value. + * - A stray key two levels down (`root.child.stray`) proves the same enforcement + * applies to the recursive `child` slot — meaning the `$ref`-resolved schema + * produces a working `unevaluatedProperties` validator at every nesting depth, + * not only at the outer entry point. + */ + public function testRecursiveSelfReferenceTerminates(): void + { + $className = $this->generateClassFromFile('RecursiveSelfReference.json'); + + $accepted = new $className([ + 'root' => [ + 'name' => 'outer', + 'child' => ['name' => 'inner'], + ], + ]); + $this->assertSame( + [ + 'root' => [ + 'name' => 'outer', + 'child' => ['name' => 'inner'], + ], + ], + $accepted->meta()->rawInput(), + ); + + // The recursive Node class is generated once and shared by both `root` and `child` + // (SchemaDefinitionDictionary caches by pointer). Resolve its class name for the + // exception-message assertions below. + $nodeClassName = $this->resolveNestedClassName($className); + + try { + new $className([ + 'root' => [ + 'name' => 'outer', + 'stray' => 1, + ], + ]); + $this->fail('unevaluatedProperties: false on the recursive node must reject stray'); + } catch (NestedObjectException $exception) { + $innerException = $exception->getNestedException(); + $this->assertInstanceOf(UnevaluatedPropertiesException::class, $innerException); + $this->assertSame(['stray'], $innerException->getUnevaluatedProperties()); + + $this->assertSame( + <<getMessage(), + ); + } + + try { + new $className([ + 'root' => [ + 'name' => 'outer', + 'child' => [ + 'name' => 'inner', + 'stray' => 1, + ], + ], + ]); + $this->fail( + 'unevaluatedProperties: false must still enforce at the recursively referenced ' + . 'child level', + ); + } catch (NestedObjectException $exception) { + // Two-level unwrap: the outer NestedObjectException wraps a + // NestedObjectException for `child`, which in turn wraps the + // UnevaluatedPropertiesException that flagged `stray`. + $childException = $exception->getNestedException(); + $this->assertInstanceOf(NestedObjectException::class, $childException); + + $innerException = $childException->getNestedException(); + $this->assertInstanceOf(UnevaluatedPropertiesException::class, $innerException); + $this->assertSame(['stray'], $innerException->getUnevaluatedProperties()); + + $this->assertSame( + <<getMessage(), + ); + } + } + + /** + * When `unevaluatedProperties` appears inside a `patternProperties` value-subschema, the + * inner subschema activates its own tracking during its own generated class's + * post-processing pass — the parent's activation walk does not need to recurse into + * pattern-value subschemas. `RenderQueue::execute()` runs + * `UnevaluatedPropertiesPostProcessor::process()` on every schema separately, and the + * inner class's `needsActivation()` returns true on the very first check because the + * inner JSON directly declares `unevaluatedProperties`. + * + * - `{p_alpha: {known: "hi"}}` accepts — the nested class's declared property covers + * the sole key. + * - `{p_alpha: {known: "hi", stray: 1}}` rejects — the nested class enforces its own + * `unevaluatedProperties: false`, surfacing at the parent as + * `InvalidPatternPropertiesException` wrapping the inner + * `UnevaluatedPropertiesException`. + */ + public function testPatternPropertiesValueSubschemaEnforcesOwnUnevaluatedProperties(): void + { + $className = $this->generateClassFromFile('PatternValueSubschemaUnevaluated.json'); + + $accepted = new $className(['p_alpha' => ['known' => 'hi']]); + $this->assertSame(['p_alpha' => ['known' => 'hi']], $accepted->meta()->rawInput()); + + $nestedClassName = $this->resolveNestedClassName($className); + + $this->expectException(InvalidPatternPropertiesException::class); + $this->expectExceptionMessage( + << ['known' => 'hi', 'stray' => 1]]); + } + + /** + * When `unevaluatedProperties` appears inside an `additionalProperties` value-subschema, + * the inner subschema activates its own tracking during its own generated class's + * post-processing pass — mirroring the pattern-value case above. The parent has no + * `unevaluatedProperties` of its own; only the inner subschema does, and the inner class + * self-activates because its own JSON declares the keyword. + * + * - `{id: 1, dyn: {known: "hi"}}` accepts. + * - `{id: 1, dyn: {known: "hi", stray: 1}}` rejects — the nested class throws + * `UnevaluatedPropertiesException`, surfacing at the parent as + * `InvalidAdditionalPropertiesException`. + */ + public function testAdditionalPropertiesValueSubschemaEnforcesOwnUnevaluatedProperties(): void + { + $className = $this->generateClassFromFile('AdditionalValueSubschemaUnevaluated.json'); + + $accepted = new $className(['id' => 1, 'dyn' => ['known' => 'hi']]); + $this->assertSame( + ['id' => 1, 'dyn' => ['known' => 'hi']], + $accepted->meta()->rawInput(), + ); + + $nestedClassName = $this->resolveNestedClassName($className); + + $this->expectException(InvalidAdditionalPropertiesException::class); + $this->expectExceptionMessage( + << 1, 'dyn' => ['known' => 'hi', 'stray' => 1]]); + } + + /** + * Sibling `additionalProperties` (or the effective `false` produced by the + * `denyAdditionalProperties()` config flag) short-circuits the unevaluated bucket. Each row + * pins the exact factory warning so a message drift in the factory surfaces here rather than + * as a silent behaviour change to consumers who grep build output for these hints. + * + * @return array + */ + public static function deadCodeProvider(): array + { + $baseConfig = static fn(): GeneratorConfiguration => + (new GeneratorConfiguration())->setLogger(new RecordingLogger()); + + return [ + // additionalProperties: true accepts every extra unchecked; without a matching + // annotation contribution the unevaluated accumulator would still reject them, + // which would defeat the intent of `additionalProperties: true`. + 'additionalProperties: true suppresses unevaluated' => [ + 'AdditionalTrueDeadCode.json', + 'sibling additionalProperties: true accepts every extra without crediting the unevaluated accumulator', + $baseConfig(), + ], + // additionalProperties: {schema} validates and claims every extra; unevaluated has + // nothing left to reach. + 'additionalProperties: {schema} claims every extra' => [ + 'AdditionalSchemaDeadCode.json', + 'sibling additionalProperties: {schema} already validates every extra key', + $baseConfig(), + ], + // additionalProperties: false rejects every extra at the base-validator phase, + // long before the post-composition unevaluated validator would run. + 'additionalProperties: false rejects every extra first' => [ + 'AdditionalFalseDeadCode.json', + 'sibling additionalProperties: false rejects every extra before the unevaluated phase runs', + $baseConfig(), + ], + // Same as the previous row but with the schema form of unevaluatedProperties. + 'additionalProperties: false leaves unevaluated {schema} unreachable' => [ + 'AdditionalFalseWithUnevaluatedSchema.json', + 'sibling additionalProperties: false rejects every extra before the unevaluated phase runs', + $baseConfig(), + ], + // denyAdditionalProperties() flips a missing additionalProperties to false at + // configuration time — the same dead-cell shape as the explicit false row but + // reached through the generator config instead of the JSON schema. + 'denyAdditionalProperties() flag mimics false and warns' => [ + 'DenyAdditionalDeadCode.json', + 'denyAdditionalProperties() flips missing additionalProperties to false,' + . ' rejecting every extra before the unevaluated phase runs', + $baseConfig()->setDenyAdditionalProperties(true), + ], + ]; + } + + /** + * Each dead-cell shape emits the factory warning through the configured logger and skips + * emitting the unevaluated validator. Where an assertion on the resulting class is + * meaningful (extras still land where their governing keyword expects them), the test + * exercises the runtime path after checking the warning entry. + */ + #[DataProvider('deadCodeProvider')] + public function testDeadCellShapesEmitWarningAndSkipValidator( + string $schemaFile, + string $reason, + GeneratorConfiguration $config, + ): void { + $className = $this->generateClassFromFile($schemaFile, $config); + + $this->assertTrue( + $this->hasLogEntry( + $config->getLogger()->getEntries(), + 'warning', + 'unevaluatedProperties on {class} is dead code — {reason}', + ['reason' => $reason], + ), + 'Expected a warning naming the dead unevaluatedProperties keyword', + ); + + // Constructing with just the declared property must always succeed — the suppressed + // unevaluated validator can never contribute a false negative here. + $instance = new $className(['name' => 'Alice']); + $this->assertSame(['name' => 'Alice'], $instance->meta()->rawInput()); + } + + /** + * `additionalProperties: false` combined with `unevaluatedProperties: {schema}` (or `false`) + * must reject extras with `AdditionalPropertiesException`, never `UnevaluatedPropertiesException`, + * because the unevaluated validator is not emitted. Extra property `count: 42` would satisfy + * the unevaluated integer schema, so if the unevaluated validator were still running the + * construction would succeed. The rejection therefore proves the factory suppressed the + * validator as intended. + */ + public function testAdditionalFalseRejectsExtrasEvenWhenUnevaluatedSchemaWouldAccept(): void + { + $className = $this->generateClassFromFile('AdditionalFalseWithUnevaluatedSchema.json'); + + $this->expectException(AdditionalPropertiesException::class); + $this->expectExceptionMessage( + "Provided JSON for '{$className}' contains not allowed additional properties ['count']", + ); + + new $className(['name' => 'Alice', 'count' => 42]); + } + + /** + * `unevaluatedProperties: {schema}` whose inner schema is contradictory (allOf of two + * incompatible non-null types) must surface the same `SchemaException` the rest of the + * codebase throws for contradictory allOf types. The contradictory-type detection is + * shared machinery; this test only pins that the SchemaException identity survives when + * the offending subschema is nested under `unevaluatedProperties`. + */ + public function testContradictoryInnerSchemaThrowsSchemaExceptionPointingAtFile(): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + "/^Property 'unevaluated property' is defined with conflicting types in allOf" + . ' composition branches \\(file \\S+\\)\\. allOf requires all constraints to' + . ' hold simultaneously, making this schema unsatisfiable\\.' + . ' at line \\d+, column \\d+$/', + ); + + $this->generateClassFromFile('ContradictoryUnevaluatedSchema.json'); + } + + /** + * `propertyNames` and `unevaluatedProperties` are orthogonal but propertyNames runs first + * because it is a base-phase validator whereas unevaluatedProperties is a post-composition + * validator. A key that violates the propertyNames regex must surface + * `InvalidPropertyNamesException` in direct-exception mode — even if the offending key's + * value would also fail the unevaluated schema, unevaluated never runs. Under error + * collection both fires do land in the registry because the base phase does not abort; + * assert both classes are present and the combined message pins the ordering. + * + * @return array + * [config, expected exception class, expected message with %s placeholder for class name] + */ + public static function propertyNamesRejectionProvider(): array + { + return [ + // Direct-exception mode: propertyNames throws first and the constructor never + // reaches the post-composition phase where unevaluated lives. The + // InvalidPropertyNamesException wraps the inner PatternException. The inner one + // names 'property name' (the sub-property name the propertyNames validator + // assigns) rather than the offending key; the outer names the offending key + // ('FOO') on its enclosing line. + 'direct exception surfaces propertyNames only' => [ + (new GeneratorConfiguration())->setCollectErrors(false), + InvalidPropertyNamesException::class, + <<<'MSG' + Provided JSON for '{className}' contains properties with invalid names + - invalid property 'FOO' + * Value for 'property name' does not match pattern '^[a-z]+$' + MSG, + ], + // Error-collection mode: ErrorRegistryException joins each collected error with a + // single "\n" (no blank line). The propertyNames block lands first (base phase); + // the unevaluated-schema block lands second (post-composition phase). The value + // 'bar' fails the unevaluated schema's `type: integer` check, so the inner + // InvalidUnevaluatedPropertiesException reports the type mismatch — not the + // false-form "not allowed unevaluated properties" phrasing. + 'collected errors capture both failures in phase order' => [ + (new GeneratorConfiguration())->setCollectErrors(true), + ErrorRegistryException::class, + <<<'MSG' + Provided JSON for '{className}' contains properties with invalid names + - invalid property 'FOO' + * Value for 'property name' does not match pattern '^[a-z]+$' + Provided JSON for '{className}' contains invalid unevaluated properties + - invalid unevaluated property 'FOO' + * Invalid type for 'unevaluated property': requires 'int', got 'string' + MSG, + ], + ]; + } + + #[DataProvider('propertyNamesRejectionProvider')] + public function testPropertyNamesRejectionPrecedesUnevaluated( + GeneratorConfiguration $config, + string $expectedExceptionClass, + string $expectedMessageTemplate, + ): void { + $className = $this->generateClassFromFile('PropertyNamesFailsFirst.json', $config); + + $this->expectException($expectedExceptionClass); + $this->expectExceptionMessage(str_replace('{className}', $className, $expectedMessageTemplate)); + + new $className(['FOO' => 'bar']); + } + + /** + * `dependentSchemas` is a Draft 2019-09 applicator whose dependent subschemas contribute + * annotations to the unevaluated accumulator: when the trigger key is present on the + * instance and the dependent subschema validates, its `properties`/`patternProperties`/ + * `additionalProperties` claims flow into the enclosing accumulator. The fixture + * declares `dependentSchemas: {kind: {properties: {extra: {type: integer}}}}` alongside + * `unevaluatedProperties: false`. + * + * Two shapes exercise the applicator once implemented: + * 1. `{kind, extra}` — `extra` is covered by the dependent subschema's `properties` + * declaration, so the accumulator credits it and construction succeeds. + * 2. `{kind, stray}` — `stray` is NOT covered by the dependent subschema, so the + * accumulator does not credit it and `unevaluatedProperties: false` rejects. + * + * Assertions are written for the post-implementation shape; the test is currently + * skipped because dependentSchemas is not yet recognised by the schema processor. When + * the applicator lands, removing the markTestSkipped line makes both assertions live. + */ + public function testDependentSchemasContributionCreditsDependentPropertiesToAccumulator(): void + { + $this->markTestSkipped( + 'dependentSchemas applicator not yet implemented in the schema processor; the' + . ' fixture and assertions describe the intended post-implementation shape.', + ); + + // @phpstan-ignore-next-line dead code — unreachable until the skip is removed + $className = $this->generateClassFromFile('DependentSchemasWithUnevaluated.json'); + + // Success path: `extra` is covered by the dependent subschema — accepted. + $instance = new $className(['kind' => 'X', 'extra' => 5]); + $this->assertSame(['kind' => 'X', 'extra' => 5], $instance->meta()->rawInput()); + + // Failure path: `stray` is not covered anywhere — `unevaluatedProperties: false` + // rejects. The pin proves that dependentSchemas only credits what its own subschema + // actually declares. + $this->expectException(UnevaluatedPropertiesException::class); + $this->expectExceptionMessage( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['stray']", + ); + new $className(['kind' => 'X', 'stray' => 5]); + } +} diff --git a/tests/ComposedValue/ComposedAllOfTest.php b/tests/ComposedValue/ComposedAllOfTest.php index 1c7546d7..13ed3509 100644 --- a/tests/ComposedValue/ComposedAllOfTest.php +++ b/tests/ComposedValue/ComposedAllOfTest.php @@ -11,9 +11,11 @@ use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use PHPModelGenerator\Tests\Fixtures\RecordingLogger; +use ReflectionClass; use ReflectionMethod; use stdClass; use PHPModelGenerator\Tests\Support\ApplicableDrafts; +use PHPModelGenerator\Tests\Support\JsonSchemaDraft; use PHPUnit\Framework\Attributes\DataProvider; /** @@ -838,6 +840,97 @@ public function testAllOfNullableTypesWithEmptyNonNullIntersectionReturnsEarlyWi $this->assertNull($object->getProperty()); } + /** + * A mutable object-level allOf whose branch declares no `properties` (here it carries only + * `minProperties`) still runs through the composition template, which caches each branch's + * outcome in `_propertyValidationState`. The composition post processor used to declare that + * field only when at least one branch declared a property, so a branch like this one left the + * field undeclared and the template's write created it dynamically — a deprecation as of PHP + * 8.4. The field must be declared for every mutable composition regardless of branch shape. + * + * Draft-pinned: the behaviour is a property of the composition post processor and does not + * vary by draft, so one run is sufficient. + */ + #[ApplicableDrafts(from: JsonSchemaDraft::DRAFT_2019_09, until: JsonSchemaDraft::DRAFT_2019_09)] + public function testMutableCompositionBranchWithoutDeclaredPropertiesDeclaresValidationStateField(): void + { + $className = $this->generateClassFromFile( + 'MutableCompositionBranchWithoutDeclaredProperties.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // The cache field is declared, so the template's write targets a real property. + $this->assertTrue((new ReflectionClass($className))->hasProperty('_propertyValidationState')); + + // Constructing a valid instance exercises the write path; capture any deprecation the + // write would raise if the field were only created dynamically. + $deprecations = []; + set_error_handler( + static function (int $severity, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, + E_DEPRECATED, + ); + + try { + $object = new $className(['name' => 'Alice']); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $deprecations); + $this->assertSame(['name' => 'Alice'], $object->meta()->rawInput()); + } + + /** + * A declared property that no composition branch declares must still revalidate a branch + * whose outcome depends on keys the branch does not name — here `maxProperties: 1`. Setting + * `other` raises the key count to 2, which the branch rejects, so the setter must throw and + * roll the model back rather than commit a state its own constructor would refuse. + * + * The composition post processor previously mapped a validator only to the properties its + * branches declared; this branch declares none, so `setOther` received no revalidation call + * and silently accepted the second key. A key-sensitive branch now maps to every declared + * property. + * + * Draft-pinned: the setter-side revalidation mapping does not vary by draft. + */ + #[ApplicableDrafts(from: JsonSchemaDraft::DRAFT_2019_09, until: JsonSchemaDraft::DRAFT_2019_09)] + public function testSetterRevalidatesBranchThatReactsToUndeclaredKeys(): void + { + $className = $this->generateClassFromFile( + 'BranchMaxPropertiesReactsToUndeclaredKeys.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // One key: the branch's maxProperties: 1 is satisfied. + $object = new $className(['kind' => 'a']); + + try { + // `other` is a valid string, but adding it makes two keys — the branch rejects it. + $object->setOther('second'); + $this->fail('Expected the branch maxProperties claim to reject the second key'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + // Rollback discipline: the rejected setter left the model at its pre-call state. + $this->assertNull($object->getOther()); + $this->assertSame(['kind' => 'a'], $object->meta()->rawInput()); + + // The constructor refuses the same two-key state, proving the setter now agrees with it. + $this->expectException(AllOfException::class); + new $className(['kind' => 'a', 'other' => 'second']); + } + /** * A schema root always has `type: object` forced onto it before its composition is processed, * and that type is then inherited by every branch declaring none of its own. A branch whose diff --git a/tests/Draft/AutoDetectedDraftSubschemaTest.php b/tests/Draft/AutoDetectedDraftSubschemaTest.php new file mode 100644 index 00000000..44fd62a6 --- /dev/null +++ b/tests/Draft/AutoDetectedDraftSubschemaTest.php @@ -0,0 +1,69 @@ +generateClassFromFile('NestedObjectUnevaluatedProperties.json'); + $nestedClassName = $this->resolveNestedClassName($className); + + // Only the declared key is present — nothing is unevaluated inside `child`. + $accepted = new $className(['child' => ['known' => 'a']]); + $this->assertSame(['child' => ['known' => 'a']], $accepted->meta()->rawInput()); + + // `extra` is claimed by nothing inside `child` and must be rejected. + $this->expectException(NestedObjectException::class); + $this->expectExceptionMessage( + << ['known' => 'a', 'extra' => 1]]); + } + + /** + * `unevaluatedItems` on an array property subschema must be applied when the document root + * declares the 2019-09 `$schema` URI. With no sibling applicator every element is + * unevaluated — the same behaviour the explicitly-pinned-draft tests assert for this + * schema shape. + */ + public function testDetectedDraftAppliesToArrayPropertySubschemas(): void + { + $className = $this->generateClassFromFile('ArrayPropertyUnevaluatedItems.json'); + + $accepted = new $className(['tags' => []]); + $this->assertSame(['tags' => []], $accepted->meta()->rawInput()); + + $this->expectException(UnevaluatedItemsException::class); + $this->expectExceptionMessage("Provided JSON for 'tags' contains not allowed unevaluated items [#0]"); + + new $className(['tags' => ['surplus']]); + } +} diff --git a/tests/Draft/DraftTest.php b/tests/Draft/DraftTest.php index 24e32374..9663466b 100644 --- a/tests/Draft/DraftTest.php +++ b/tests/Draft/DraftTest.php @@ -7,6 +7,7 @@ use PHPModelGenerator\Draft\AutoDetectionDraft; use PHPModelGenerator\Draft\Draft_07; use PHPModelGenerator\Draft\Draft_2019_09; +use PHPModelGenerator\Draft\Draft_2020_12; use PHPModelGenerator\Draft\Element\Type; use PHPModelGenerator\Draft\Producer\PropertyProducerInterface; use PHPModelGenerator\Exception\SchemaException; @@ -174,18 +175,18 @@ public function testAutoDetectionReturnsDraft07ForDraft07SchemaKeyword(): void $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); } - public function testAutoDetectionFallsBackToDraft07WhenSchemaKeywordAbsent(): void + public function testAutoDetectionFallsBackToDraft202012WhenSchemaKeywordAbsent(): void { $jsonSchema = new JsonSchema('test.json', ['type' => 'object']); - $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + $this->assertInstanceOf(Draft_2020_12::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); } - public function testAutoDetectionFallsBackToDraft07ForUnrecognisedSchemaKeyword(): void + public function testAutoDetectionFallsBackToDraft202012ForUnrecognisedSchemaKeyword(): void { $jsonSchema = new JsonSchema('test.json', ['$schema' => 'https://example.com/custom-schema']); - $this->assertInstanceOf(Draft_07::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + $this->assertInstanceOf(Draft_2020_12::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); } /** @return array */ @@ -212,7 +213,7 @@ public function testAutoDetectionReusesCachedDraft07Instance(): void $autoDetectionDraft = new AutoDetectionDraft(); $firstSchema = new JsonSchema('first.json', ['$schema' => 'http://json-schema.org/draft-07/schema#']); - $secondSchema = new JsonSchema('second.json', ['type' => 'object']); + $secondSchema = new JsonSchema('second.json', ['$schema' => 'https://json-schema.org/draft-07/schema']); $this->assertSame( $autoDetectionDraft->getDraftForSchema($firstSchema), @@ -220,6 +221,73 @@ public function testAutoDetectionReusesCachedDraft07Instance(): void ); } + /** @return array */ + public static function draft202012SchemaUriProvider(): array + { + return [ + 'https without trailing hash' => ['https://json-schema.org/draft/2020-12/schema'], + 'https with trailing hash' => ['https://json-schema.org/draft/2020-12/schema#'], + 'http without trailing hash' => ['http://json-schema.org/draft/2020-12/schema'], + 'http with trailing hash' => ['http://json-schema.org/draft/2020-12/schema#'], + ]; + } + + #[DataProvider('draft202012SchemaUriProvider')] + public function testAutoDetectionReturnsDraft202012ForDraft202012SchemaKeyword(string $schemaUri): void + { + $jsonSchema = new JsonSchema('test.json', ['$schema' => $schemaUri]); + + $this->assertInstanceOf(Draft_2020_12::class, (new AutoDetectionDraft())->getDraftForSchema($jsonSchema)); + } + + /** + * The dialect declared at the document root governs every subschema of the same source file: + * a subschema (queried after the root, and carrying no `$schema` of its own) resolves to the + * draft the root declared rather than falling back to the default. + */ + public function testAutoDetectionAppliesRootDialectToSubschemasOfTheSameFile(): void + { + $autoDetectionDraft = new AutoDetectionDraft(); + + $rootSchema = new JsonSchema('document.json', [ + '$schema' => 'https://json-schema.org/draft/2019-09/schema', + 'type' => 'object', + ]); + $subSchema = new JsonSchema('document.json', ['type' => 'object'], '/properties/child'); + + // Root is resolved first, seeding the per-file dialect. + $this->assertInstanceOf(Draft_2019_09::class, $autoDetectionDraft->getDraftForSchema($rootSchema)); + $this->assertInstanceOf(Draft_2019_09::class, $autoDetectionDraft->getDraftForSchema($subSchema)); + } + + /** + * A nested subschema that declares its own `$schema` resolves to that dialect for itself, but + * must not overwrite the document dialect seeded by the root: a sibling subschema processed + * afterwards still inherits the root's dialect, not the nested declaration's. + */ + public function testNestedSchemaDeclarationDoesNotLeakIntoSiblingSubschemas(): void + { + $autoDetectionDraft = new AutoDetectionDraft(); + + $rootSchema = new JsonSchema('document.json', [ + '$schema' => 'https://json-schema.org/draft/2019-09/schema', + 'type' => 'object', + ]); + $nestedResourceRoot = new JsonSchema( + 'document.json', + ['$schema' => 'http://json-schema.org/draft-07/schema#', 'type' => 'object'], + '/properties/child', + ); + $sibling = new JsonSchema('document.json', ['type' => 'object'], '/properties/other'); + + // Document root seeds the file dialect. + $this->assertInstanceOf(Draft_2019_09::class, $autoDetectionDraft->getDraftForSchema($rootSchema)); + // The nested resource root resolves to its own declared dialect. + $this->assertInstanceOf(Draft_07::class, $autoDetectionDraft->getDraftForSchema($nestedResourceRoot)); + // The sibling still inherits the root's dialect — the nested declaration did not leak. + $this->assertInstanceOf(Draft_2019_09::class, $autoDetectionDraft->getDraftForSchema($sibling)); + } + public function testAutoDetectionReusesCachedDraft201909Instance(): void { $autoDetectionDraft = new AutoDetectionDraft(); diff --git a/tests/Issues/Issue/Issue72Test.php b/tests/Issues/Issue/Issue72Test.php index a799cf68..b6b5ef2b 100644 --- a/tests/Issues/Issue/Issue72Test.php +++ b/tests/Issues/Issue/Issue72Test.php @@ -1011,6 +1011,8 @@ public function testArrayItemWithBareObjectValidatorsValidatesObjectItemsAndPass * while branches containing only "const" or "type" - both registered on their Type via * addModifier() rather than addValidator() in Draft_07, so invisible to * Draft::getTypesForKeyword() - must not, since both are genuine constraints. + * + * Also covers 'not', including the type-inheritance exclusion (notBranchInheritingParentType). */ public function testVacuousBranchWarningIsDrivenByRegisteredValidatorsNotAHardcodedList(): void { @@ -1095,6 +1097,21 @@ public function testVacuousBranchWarningIsDrivenByRegisteredValidatorsNotAHardco $this->assertNotNull($emptyBranchEntry, 'Expected a warning entry for the emptyBranchParity property.'); $this->assertSame($trueBranchEntry['message'], $emptyBranchEntry['message']); $this->assertSame($trueBranchEntry['context']['index'], $emptyBranchEntry['context']['index']); + + // 'not' inherits the parent type the same way allOf/anyOf/oneOf do, and the inherited + // type must be excluded from vacuousness the same way too - notBranchInheritingParentType + // declares nothing of its own, so without the exclusion it would look identical to an + // author-declared, non-vacuous `not: {"type": "string"}`. + $this->assertTrue( + $this->hasLogEntry( + $entries, + 'warning', + "Composition branch #{index} for '{property}' carries no validation keyword and" + . ' matches any value', + ['index' => 1, 'property' => 'notBranchInheritingParentType'], + ), + 'Expected a vacuous-branch warning for the notBranchInheritingParentType property.', + ); } /** diff --git a/tests/Objects/AnyPropertyTest.php b/tests/Objects/AnyPropertyTest.php index 8b65163e..7c123225 100644 --- a/tests/Objects/AnyPropertyTest.php +++ b/tests/Objects/AnyPropertyTest.php @@ -4,9 +4,12 @@ namespace PHPModelGenerator\Tests\Objects; +use PHPModelGenerator\Exception\Arrays\MinItemsException; use PHPModelGenerator\Exception\FileSystemException; +use PHPModelGenerator\Exception\Object\NestedObjectException; use PHPModelGenerator\Exception\RenderException; use PHPModelGenerator\Exception\SchemaException; +use PHPModelGenerator\Exception\String\MinLengthException; use PHPModelGenerator\Model\GeneratorConfiguration; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use stdClass; @@ -78,4 +81,139 @@ public static function validPropertyTypeDataProvider(): array ], ); } + + /** + * A string applicator (minLength) declared without an explicit `type: string` still applies — + * JSON Schema applicators are not gated on a type declaration. The emitted validator self-gates + * on `is_string`, so a value of any other type imposes no length constraint and passes through, + * while a string violating the constraint is rejected. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testUntypedStringApplicatorSelfGates(): void + { + $className = $this->generateClassFromFile('AnyPropertyStringConstraint.json'); + + // Non-string values impose no length constraint and pass through unchanged. + $this->assertSame(42, (new $className(['property' => 42]))->getProperty()); + $this->assertSame([1], (new $className(['property' => [1]]))->getProperty()); + $this->assertNull((new $className([]))->getProperty()); + // A string satisfying minLength is accepted. + $this->assertSame('abcd', (new $className(['property' => 'abcd']))->getProperty()); + + // A string shorter than minLength is rejected — the applicator applies without `type: string`. + try { + new $className(['property' => 'ab']); + $this->fail('A string shorter than minLength must be rejected on an untyped property'); + } catch (MinLengthException $exception) { + $this->assertSame("Value for 'property' must not be shorter than 3", $exception->getMessage()); + $this->assertSame('/properties/property/minLength', $exception->getJsonPointer()->pointer); + } + } + + /** + * An array applicator (minItems) declared without an explicit `type: array` still applies. The + * emitted validator self-gates on `is_array`, so a value of any other type passes through while + * an array violating the constraint is rejected. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testUntypedArrayApplicatorSelfGates(): void + { + $className = $this->generateClassFromFile('AnyPropertyArrayConstraint.json'); + + // Non-array values impose no size constraint and pass through unchanged. + $this->assertSame('x', (new $className(['property' => 'x']))->getProperty()); + $this->assertSame(5, (new $className(['property' => 5]))->getProperty()); + $this->assertNull((new $className([]))->getProperty()); + // An array satisfying minItems is accepted. + $this->assertSame([1, 2], (new $className(['property' => [1, 2]]))->getProperty()); + + // An array shorter than minItems is rejected. + try { + new $className(['property' => [1]]); + $this->fail('An array shorter than minItems must be rejected on an untyped property'); + } catch (MinItemsException $exception) { + $this->assertSame( + "Array 'property' must not contain less than 2 items", + $exception->getMessage(), + ); + $this->assertSame('/properties/property/minItems', $exception->getJsonPointer()->pointer); + } + } + + /** + * An untyped subschema may mix object and scalar applicators. Each self-gates independently: an + * object value is wrapped in the generated nested class and validated against the object + * applicators; a string value is validated against minLength; any other value is accepted. The + * getter stays permissive (`|mixed`) because the slot may hold the nested object OR + * any other type the untyped schema also accepts — it is not typed as the nested class alone. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testUntypedMixedObjectAndScalarApplicatorsEachSelfGate(): void + { + $className = $this->generateClassFromFile('AnyPropertyObjectAndStringConstraint.json'); + + // Object value: the object applicators apply — the declared key is exposed through the + // generated nested class. + $object = (new $className(['property' => ['known' => 'a']]))->getProperty(); + $this->assertIsObject($object); + $this->assertSame('a', $object->getKnown()); + + // Non-object, non-string values impose no constraint and pass through. + $this->assertSame(42, (new $className(['property' => 42]))->getProperty()); + // A string satisfying minLength passes through unchanged (it is not wrapped as an object). + $this->assertSame('abcd', (new $className(['property' => 'abcd']))->getProperty()); + + // The getter is permissive: `|mixed` annotation over a `mixed` native type. + $this->assertMatchesRegularExpression( + '/^\w+\|mixed$/', + $this->getReturnTypeAnnotation($className, 'getProperty'), + ); + + // String applicator gates independently: a short string is rejected by minLength. + try { + new $className(['property' => 'ab']); + $this->fail('minLength must reject a short string even alongside object applicators'); + } catch (MinLengthException $exception) { + $this->assertSame("Value for 'property' must not be shorter than 3", $exception->getMessage()); + $this->assertSame('/properties/property/minLength', $exception->getJsonPointer()->pointer); + } + + // Object applicator gates independently: an undeclared key is rejected by additionalProperties. + try { + new $className(['property' => ['known' => 'a', 'extra' => 1]]); + $this->fail('additionalProperties:false must reject an undeclared key on the nested object'); + } catch (NestedObjectException $exception) { + $this->assertMatchesRegularExpression( + <<<'REGEX' + /^Invalid nested object for property 'property': + - Provided JSON for '.+' contains not allowed additional properties \['extra'\]$/ + REGEX, + $exception->getMessage(), + ); + } + } + + /** + * A bare untyped schema (`{}`) carries no applicators, so no nested class is generated and the + * property stays fully permissive — the annotated return type is `mixed`, not a class union. + * + * @throws FileSystemException + * @throws RenderException + * @throws SchemaException + */ + public function testUntypedEmptySchemaRemainsMixedWithoutNestedClass(): void + { + $className = $this->generateClassFromFile('AnyProperty.json'); + + $this->assertSame('mixed', $this->getReturnTypeAnnotation($className, 'getProperty')); + } } diff --git a/tests/Objects/ArrayPropertyTest.php b/tests/Objects/ArrayPropertyTest.php index 697547ea..4efdeb6d 100644 --- a/tests/Objects/ArrayPropertyTest.php +++ b/tests/Objects/ArrayPropertyTest.php @@ -5,6 +5,7 @@ namespace PHPModelGenerator\Tests\Objects; use Closure; +use DateTime; use PHPModelGenerator\Exception\Arrays\InvalidItemException; use PHPModelGenerator\Exception\Arrays\MaxItemsException; use PHPModelGenerator\Exception\Arrays\MinItemsException; @@ -1100,4 +1101,57 @@ public static function invalidMaxItemsValueDataProvider(): array 'boolean' => [true], ]; } + + /** + * A transforming filter (here `dateTime`) declared on a schema-form `items` subschema + * persists the transformed value (the getter reports `DateTime` instances, not the raw + * strings) and accepts an already-transformed value passed directly — the item's own + * TypeCheckValidator is widened by TransformingFilterOutputTypePostProcessor, which recurses + * into ArrayItemValidator::getNestedProperty() to reach it. A mixed list of raw and + * already-transformed values is accepted since each index is validated independently. + * + * Serialization applies the filter's outputFormat to every transformed item, turning each + * DateTime back into the raw representation the filter accepts — mirrors the object-property + * equivalent (UnevaluatedPropertiesAccessorPostProcessorTest's transforming-filter companion + * test). Without a dedicated per-item serializer, DateTime has no public properties for the + * generic fallback to pick up and each item would silently serialize to an empty array + * instead. + */ + public function testTransformingFilterOnArrayItemsPersistsAndAcceptsAlreadyTransformedValues(): void + { + $className = $this->generateClassFromFile( + 'ArrayPropertyWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $accepted = new $className(['tags' => ['2020-10-10', '2020-12-12']]); + $this->assertEquals( + [new DateTime('2020-10-10'), new DateTime('2020-12-12')], + $accepted->getTags(), + ); + // The raw input view stays untransformed — only the property's own storage is affected. + $this->assertSame(['2020-10-10', '2020-12-12'], $accepted->meta()->rawInput()['tags']); + + $this->assertSame(['tags' => ['20201010', '20201212']], $accepted->toArray()); + $decoded = json_decode($accepted->toJSON(), true); + $this->assertSame(['tags' => ['20201010', '20201212']], $decoded); + + $alreadyTransformed = new $className(['tags' => [new DateTime('2020-10-10')]]); + $this->assertEquals([new DateTime('2020-10-10')], $alreadyTransformed->getTags()); + $this->assertEquals([new DateTime('2020-10-10')], $alreadyTransformed->meta()->rawInput()['tags']); + + $mixed = new $className(['tags' => ['2020-10-10', new DateTime('2020-12-12')]]); + $this->assertEquals( + [new DateTime('2020-10-10'), new DateTime('2020-12-12')], + $mixed->getTags(), + ); + + // The setter must exercise the same validator chain as construction: a raw date string + // is transformed and persisted, and an already-transformed value is accepted directly. + $accepted->setTags(['2020-01-01']); + $this->assertEquals([new DateTime('2020-01-01')], $accepted->getTags()); + + $accepted->setTags([new DateTime('2020-02-02')]); + $this->assertEquals([new DateTime('2020-02-02')], $accepted->getTags()); + } } diff --git a/tests/Objects/MultiTypePropertyTest.php b/tests/Objects/MultiTypePropertyTest.php index d128c315..fd6ff5cf 100644 --- a/tests/Objects/MultiTypePropertyTest.php +++ b/tests/Objects/MultiTypePropertyTest.php @@ -17,11 +17,6 @@ use PHPModelGenerator\Tests\Support\ApplicableDrafts; use PHPUnit\Framework\Attributes\DataProvider; -/** - * Class MultiTypePropertyTest - * - * @package PHPModelGenerator\Tests\Objects - */ #[ApplicableDrafts] class MultiTypePropertyTest extends AbstractPHPModelGeneratorTestCase { @@ -325,4 +320,124 @@ public static function invalidRecursiveMultiTypeDataProvider(): array ], ]; } + + /** + * A property typed `["object", "array"]` with a `oneOf` spanning an object branch and an + * array branch used to throw a generation-time SchemaException: narrowing to the object + * variant re-processes the full `oneOf` as that nested class's own schema-root composition, + * and only an object-typed branch ever gets a nested schema, so the array branch looked like + * a conflict. SchemaProcessor::transferComposedPropertiesToSchema() now only treats a typed, + * schema-less branch as a conflict for allOf (which needs every branch to hold at once) - + * not oneOf/anyOf/if-then-else, where an unmatched branch simply isn't reached. + * + * Also covers the object branch on the same class: without further changes it would be + * double-validated against incompatible value shapes (the property-level `oneOf` copy would + * run its `instanceof` checks after the nested copy had already instantiated the value into + * an unrelated class). PropertyFactory::createMultiTypeProperty() now drops the object + * variant's own ObjectInstantiationDecorator and has MultiTypeCheckValidator recognize a raw, + * not-yet-instantiated JSON-object-shaped array, so only the property-level composition + * validator instantiates. + */ + public function testMultiTypePropertyWithCompositionArrayBranchGeneratesAndValidatesArrayInput(): void + { + $className = $this->generateClassFromFile('MultiTypePropertyWithOneOfArrayBranch.json'); + + $arrayObject = new $className(['property' => ['Test']]); + $this->assertSame(['Test'], $arrayObject->getProperty()); + + $objectObject = new $className(['property' => ['name' => 'Hans']]); + $this->assertSame('Hans', $objectObject->getProperty()->getName()); + } + + #[DataProvider('invalidCompositionArrayBranchDataProvider')] + public function testInvalidMultiTypePropertyCompositionArrayBranchThrowsAnException( + mixed $propertyValue, + string $exceptionMessage, + ): void { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage($exceptionMessage); + + $className = $this->generateClassFromFile('MultiTypePropertyWithOneOfArrayBranch.json'); + + new $className(['property' => $propertyValue]); + } + + public static function invalidCompositionArrayBranchDataProvider(): array + { + return [ + 'wrong item type in tuple' => [ + [42], + << [ + 'nope', + "Invalid type for 'property': requires ['object', 'array'], got 'string'", + ], + ]; + } + + public function testInvalidMultiTypePropertyCompositionObjectBranchThrowsAnException(): void + { + $this->expectException(ValidationException::class); + // The array branch's own tuple-item detail beyond the initial type mismatch varies by + // draft (some drafts short-circuit further checks once the primary array-type check + // already failed); only the draft-independent prefix is asserted here. + $this->expectExceptionMessageMatches('/' . preg_quote(<<generateClassFromFile('MultiTypePropertyWithOneOfArrayBranch.json'); + + new $className(['property' => ['name' => 42]]); + } + + /** + * An empty JSON object `{}` and an empty JSON array `[]` both decode to the same empty PHP + * array (see TypeCheck::buildNegatedJsonSchemaTypeCheck() for the `object`/`array` ambiguity + * this causes). `{}` used to be flatly rejected as "not an object" for a multi-type object + * candidate whenever the sibling type wasn't `array` - pairing with `array` happened to mask + * the gap, which is why this needs its own fixture pairing "object" with "string" instead of + * reusing MultiTypePropertyWithOneOfArrayBranch.json. + */ + public function testMultiTypePropertyObjectCandidateAcceptsEmptyObjectInput(): void + { + $className = $this->generateClassFromFile('MultiTypePropertyWithOneOfObjectStringBranch.json'); + + $emptyObject = new $className(['property' => []]); + $this->assertNull($emptyObject->getProperty()->getName()); + + $namedObject = new $className(['property' => ['name' => 'Alice']]); + $this->assertSame('Alice', $namedObject->getProperty()->getName()); + + $string = new $className(['property' => 'abc']); + $this->assertSame('abc', $string->getProperty()); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage( + << 'ab']); + } } diff --git a/tests/Objects/ReferencePropertyTest.php b/tests/Objects/ReferencePropertyTest.php index 38b025cc..8ce09d08 100644 --- a/tests/Objects/ReferencePropertyTest.php +++ b/tests/Objects/ReferencePropertyTest.php @@ -1038,6 +1038,43 @@ public function testMutuallyReferencingRootCompositionsGenerateWithoutRecursingI $this->assertInstanceOf($bClass, new $bClass([])); } + /** + * The `anyOf`/`oneOf` analogue of the `allOf` case above does NOT terminate, unlike that + * one. `allOf` branches referencing each other get flattened into one class per file at + * generation time (`RefResolver`'s `hasComposedPropertyValidator()` + + * `SchemaProcessor::transferComposedPropertiesToSchema()` merge a referenced allOf's + * properties/validators directly into the referencing schema, since allOf requires every + * branch simultaneously) - confirmed by inspecting the allOf fixture's own generated output: + * `A`'s constructor never references `B` at all, only two classes exist in total. + * + * `anyOf`/`oneOf` cannot use the same trick: only one branch must hold, so each branch stays + * an independently instantiable class, and testing a mutually-referencing branch means + * actually constructing the referenced class. Constructing `A` here tries the second `anyOf` + * branch (a full `B`), which tries *its* second branch (a full `A`), forever - no base case. + * Confirmed via direct reproduction: generation succeeds (two classes plus their branch + * companions), but `new $aClass([])` stack-overflows (Xdebug's own infinite-loop guard + * catches it at ~512 frames). + * + * Deferred - a real fix is a design decision (reject the mutual reference at generation + * time, which would refuse a legitimately useful "recursive polymorphic node" schema shape + * unlike the tautological allOf case, vs. runtime cycle memoization), not a mechanical one. + * See `.claude/topics/self-composition-runtime-recursion/analysis.md` for the full + * analysis. Marked incomplete rather than attempting construction, to avoid burning ~512 + * stack frames through Xdebug on every suite run for a known, already-diagnosed failure. + */ + public function testMutuallyReferencingAnyOfRootCompositionsDoesNotYetTerminate(): void + { + $namespace = 'MutuallyReferencingAnyOfRootCompositions'; + $this->generateDirectory('MutuallyReferencingAnyOfRootCompositions', $this->directoryConfig($namespace)); + + $this->markTestIncomplete( + 'Generation succeeds, but constructing an instance recurses indefinitely - a ' + . 'mutually-referencing anyOf/oneOf composition has no base case for runtime ' + . 'branch-matching, unlike allOf (which flattens at generation time instead). ' + . 'Deferred; tracked in .claude/topics/self-composition-runtime-recursion/.', + ); + } + /** * A recursive cross-file schema pair - A's root is an `allOf` of a `$ref` to B, and B has a * property referencing A back - must produce exactly one class per file. diff --git a/tests/Objects/TupleArrayPropertyTest.php b/tests/Objects/TupleArrayPropertyTest.php index cdac199f..606df85b 100644 --- a/tests/Objects/TupleArrayPropertyTest.php +++ b/tests/Objects/TupleArrayPropertyTest.php @@ -4,6 +4,7 @@ namespace PHPModelGenerator\Tests\Objects; +use DateTime; use PHPModelGenerator\Exception\Arrays\InvalidTupleException; use PHPModelGenerator\Exception\FileSystemException; use PHPModelGenerator\Exception\RenderException; @@ -434,4 +435,46 @@ public static function invalidRecursiveTupleDataProvider(): array 'one level nested - invalid nested second tuple' => [['abc', ['abc', 1]]], ]; } + + /** + * A transforming filter (here `dateTime`) declared on one tuple index persists the + * transformed value and accepts an already-transformed value passed directly for that + * index — TransformingFilterOutputTypePostProcessor recurses into + * ArrayTupleValidator::getTupleProperties() to widen the affected index's TypeCheckValidator + * without touching the other, unfiltered index. + * + * Serialization applies the filter's outputFormat only to the tuple index that declares it + * (index 0); the plain second index passes through unchanged — proving the per-index + * serializer doesn't over-apply the filter to indices that never had one. + */ + public function testTransformingFilterOnTupleIndexPersistsAndAcceptsAlreadyTransformedValue(): void + { + $className = $this->generateClassFromFile( + 'TupleArrayWithTransformingFilter.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $accepted = new $className(['tags' => ['2020-10-10', 'plain']]); + $this->assertEquals([new DateTime('2020-10-10'), 'plain'], $accepted->getTags()); + // The raw input view stays untransformed — only the property's own storage is affected. + $this->assertSame(['2020-10-10', 'plain'], $accepted->meta()->rawInput()['tags']); + + $this->assertSame(['tags' => ['20201010', 'plain']], $accepted->toArray()); + $decoded = json_decode($accepted->toJSON(), true); + $this->assertSame(['tags' => ['20201010', 'plain']], $decoded); + + $alreadyTransformed = new $className(['tags' => [new DateTime('2020-10-10'), 'plain']]); + $this->assertEquals([new DateTime('2020-10-10'), 'plain'], $alreadyTransformed->getTags()); + $this->assertEquals( + [new DateTime('2020-10-10'), 'plain'], + $alreadyTransformed->meta()->rawInput()['tags'], + ); + + // The setter must exercise the same validator chain as construction. + $accepted->setTags(['2020-01-01', 'plain']); + $this->assertEquals([new DateTime('2020-01-01'), 'plain'], $accepted->getTags()); + + $accepted->setTags([new DateTime('2020-02-02'), 'plain']); + $this->assertEquals([new DateTime('2020-02-02'), 'plain'], $accepted->getTags()); + } } diff --git a/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php index fc3da68c..e2e5bcdb 100644 --- a/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php +++ b/tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php @@ -6,6 +6,7 @@ use DateTime; use Exception; +use PHPModelGenerator\Exception\ComposedValue\AllOfException; use PHPModelGenerator\Exception\Object\InvalidAdditionalPropertiesException; use PHPModelGenerator\Exception\Object\InvalidPropertyNamesException; use PHPModelGenerator\Exception\Object\MaxPropertiesException; @@ -364,6 +365,103 @@ public function testMinPropertiesIsEnforcedWhenRemovingAPatternPropertyKey(): vo $accessor->remove('a1'); } + /** + * Direct-exception-mode counterpart to + * UnevaluatedPropertiesAccessorPostProcessorTest::testRemoveCollectsMinPropertyAndUnevaluatedErrorsTogether: + * the same removal both drops the count below `minProperties` and flips the `anyOf` + * composition (branch 0 requires `p_foo`; removing it leaves only branch 1, which claims + * nothing, orphaning `q_marker`). In collect-errors mode both violations land in one + * registry; in direct-exception mode `RemoveAdditionalProperty.phptpl`'s + * `minPropertyValidator` check runs first — before the unset, before composition + * revalidation, before the post-composition unevaluatedProperties check — so only + * `MinPropertiesException` ever surfaces, never the orphaned-key exception. The rejected + * call's `catch` rolls `_rawModelDataInput` back, so the object's public behavior is + * verified identical to before the failed removal, not just that an exception was thrown. + */ + public function testRemoveThrowsFirstApplicableExceptionDirectlyAndRollsBackOnCompositionMinPropertiesClash(): void + { + $this->addPostProcessor(true); + + $className = $this->generateClassFromFile( + 'BranchFlipOnRemove.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // Both anyOf branches succeed: branch 0 (kind: string, additionalProperties: integer, + // minProperties: 3) claims p_foo and q_marker; branch 1 (kind: const "X") claims + // nothing. q_marker is credited only through branch 0. + $object = new $className(['kind' => 'X', 'p_foo' => 1, 'q_marker' => 7]); + $accessor = $object->additionalProperties(); + + try { + $accessor->remove('p_foo'); + $this->fail('Expected MinPropertiesException, not an aggregated registry'); + } catch (MinPropertiesException $exception) { + $this->assertSame( + "Provided object for '{$className}' must not contain less than 3 properties", + $exception->getMessage(), + ); + } + + // Rollback discipline: raw input, the additionalProperties bucket, and an unrelated + // setter all behave exactly as they would have if remove() had never been called. + $this->assertSame( + ['kind' => 'X', 'p_foo' => 1, 'q_marker' => 7], + $object->meta()->rawInput(), + ); + $this->assertSame(['q_marker' => 7], $accessor->getAll()); + $object->setKind('X'); + $this->assertSame('X', $object->getKind()); + } + + /** + * A composition branch declaring `additionalProperties` is decided by keys it never names, + * so the setter-side validation cache — which asks whether the mutated keys intersect the + * branch's *declared* property names — must not be consulted for it. A dynamic key never + * intersects that list, so a cached branch outcome would let `set()` store a value the + * branch rejects. + * + * Both entry points must agree: constructing with the same pair is rejected, so setting it + * has to be rejected too, and the rejected write must leave the raw model data untouched. + * + * The fixture declares `kind` twice on purpose. `additionalProperties` only exempts the + * `properties` of the schema object it sits in, so without the branch's own `kind` + * declaration the branch would treat the string `kind` as an additional property, fail its + * `type: integer` claim, and reject the constructor call this test needs to succeed. + */ + public function testSetIsRejectedByABranchAdditionalPropertiesClaimDespiteACachedBranchOutcome(): void + { + $this->addPostProcessor(true); + + $className = $this->generateClassFromFile( + 'BranchAdditionalPropertiesTypesExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // Construction populates the branch's cached outcome with "valid". + $object = new $className(['kind' => 'a']); + + // The same state the constructor refuses below, reached through the accessor instead. + try { + $object->additionalProperties()->set('extra', 'not-an-integer'); + $this->fail('Expected the branch additionalProperties claim to reject the value'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + $this->assertSame([], $object->additionalProperties()->getAll()); + $this->assertSame(['kind' => 'a'], $object->meta()->rawInput()); + + $this->expectException(AllOfException::class); + new $className(['kind' => 'a', 'extra' => 'not-an-integer']); + } + public function testSetterSchemaHooksAreResolvedInSetAdditionalProperties(): void { $this->modifyModelGenerator = static function (ModelGenerator $modelGenerator): void { diff --git a/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php new file mode 100644 index 00000000..491a1125 --- /dev/null +++ b/tests/PostProcessor/UnevaluatedPropertiesAccessorPostProcessorTest.php @@ -0,0 +1,1268 @@ +modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + } + + /** + * The typed accessor companion narrows get/set/getAll to the declared schema type. + * Construction collects matching extras into the backing field; getAll() returns them; + * get() returns one; immutable mode omits the mutators. + */ + public function testTypedAccessorRoundTripsCollectedExtras(): void + { + $this->addPostProcessor(); + + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // Construction collects matching extras into _unevaluatedProperties. + $object = new $className(['name' => 'Alice', 'count' => 42, 'limit' => 7]); + $accessor = $object->unevaluatedProperties(); + + $this->assertSame(['count' => 42, 'limit' => 7], $accessor->getAll()); + $this->assertSame(42, $accessor->get('count')); + $this->assertSame(7, $accessor->get('limit')); + $this->assertNull($accessor->get('missing')); + + // Mutator path: set() routes through _setUnevaluatedProperty which runs validation + + // stores the value, and remove() takes the key out of both _unevaluatedProperties and + // _rawModelDataInput. + $accessor->set('extra', 99); + $this->assertSame(99, $accessor->get('extra')); + $this->assertSame( + ['name' => 'Alice', 'count' => 42, 'limit' => 7, 'extra' => 99], + $object->meta()->rawInput(), + ); + + $this->assertTrue($accessor->remove('extra')); + $this->assertNull($accessor->get('extra')); + $this->assertFalse($accessor->remove('does-not-exist')); + + // Same accessor instance is reused across calls (it's cached on the model). + $this->assertSame($accessor, $object->unevaluatedProperties()); + } + + /** + * `_setUnevaluatedProperty`'s early-return guard + * (`isset($this->_unevaluatedProperties[$key]) && $this->_unevaluatedProperties[$key] === + * $value`) skips validation and rollback bookkeeping entirely when the new value is + * identical to the stored one — not just "revalidates and happens to succeed." Proven + * observably: in collect-errors mode, every non-skip call unconditionally replaces + * `$this->_errorRegistry` with a fresh instance before validating. A sentinel object is + * planted in `_errorRegistry` via reflection; setting the same value must leave that exact + * instance untouched, while setting a different value must replace it — the negative case + * proves the sentinel isn't just always preserved regardless of the guard. + */ + public function testSetSameValueIsANoOpThatSkipsValidationAndRollbackBookkeeping(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['name' => 'Alice', 'count' => 42]); + $errorRegistryProperty = new ReflectionProperty($object, '_errorRegistry'); + + $sentinel = new ErrorRegistryException(); + $errorRegistryProperty->setValue($object, $sentinel); + + $object->unevaluatedProperties()->set('count', 42); + $this->assertSame( + $sentinel, + $errorRegistryProperty->getValue($object), + 'Same-value set() must not touch _errorRegistry at all', + ); + + // Negative control on a fresh instance: a genuinely different value must replace the + // registry, proving the sentinel above was preserved because the guard skipped the + // call, not because _errorRegistry is untouched regardless of value. + $otherObject = new $className(['name' => 'Alice', 'count' => 42]); + $otherSentinel = new ErrorRegistryException(); + $errorRegistryProperty->setValue($otherObject, $otherSentinel); + + $otherObject->unevaluatedProperties()->set('count', 99); + $this->assertNotSame( + $otherSentinel, + $errorRegistryProperty->getValue($otherObject), + 'A different value must run the full validation path, replacing _errorRegistry', + ); + } + + /** + * The unevaluatedProperties subschema's runtime validators run on the shim and reject + * values that satisfy the PHP type signature but fail a constraint expressed only in + * the JSON Schema (here: maximum). The accessor's _unevaluatedProperties backing array + * and the raw model input must be untouched after a rejected set(). + */ + public function testSetRejectsValuesViolatingNonTypeConstraints(): void + { + $this->addPostProcessor(); + // Direct-exception mode (not error-collection) so the inner exception type is exact. + $className = $this->generateClassFromFile( + 'TypedExtrasWithRange.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['name' => 'Alice', 'count' => 1]); + $accessor = $object->unevaluatedProperties(); + + try { + // 999 satisfies the PHP `int` signature but violates `maximum: 100` in the schema. + $accessor->set('bad', 999); + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $exception) { + // Direct-exception mode: the unevaluated subschema's maximum validator fires first + // and the exception bubbles up with a message identifying the offending property. + $this->assertStringContainsString('unevaluated property', $exception->getMessage()); + } + + // Backing field unchanged after rollback inside the unevaluatedProperties template. + $this->assertSame(['count' => 1], $accessor->getAll()); + // Raw input did not record the partial mutation either. + $this->assertSame(['name' => 'Alice', 'count' => 1], $object->meta()->rawInput()); + } + + /** + * When a successful composition branch claims every extra key (here via the branch's + * `additionalProperties: {type: integer}`), a key routed through + * unevaluatedProperties()->set() is subject to that claim. A value violating the claim + * must be rejected — it must never be committed to the raw model data while staying + * invisible to the accessor. After the rejected write the accessor state and the raw + * model data are unchanged, and the model remains constructible from its own raw data + * view (the write must not poison round-tripping). + * + * The expected rejection is the composition re-validation of the candidate state — the + * same AllOfException the constructor raises for `{kind: "a", extra: "not-an-integer"}`. + * In direct-exception mode the composition summary carries no per-element details, so the + * message is fully constructible. + * + * The fixture declares `kind` twice on purpose. `additionalProperties` only exempts the + * `properties` of the schema object it sits in, so without the branch's own `kind` + * declaration the branch would treat the string `kind` as an additional property, fail its + * `type: integer` claim, and reject the constructor call this test needs to succeed. + */ + public function testSetRejectsValueViolatingASuccessfulBranchClaim(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'BranchAdditionalPropertiesClaimsExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['kind' => 'a']); + + try { + $object->unevaluatedProperties()->set('extra', 'not-an-integer'); + $this->fail('Expected the branch additionalProperties claim to reject the value'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + $this->assertSame([], $object->unevaluatedProperties()->getAll()); + $this->assertSame(['kind' => 'a'], $object->meta()->rawInput()); + + // Round-trip guard: the model's raw data view must still satisfy its own schema. + $reconstructed = new $className($object->meta()->rawInput()); + $this->assertSame(['kind' => 'a'], $reconstructed->meta()->rawInput()); + } + + /** + * Keys that match a declared property name must be routed through the named setter and + * not the unevaluated accessor. The shim's runtime guard rejects them with a dedicated + * exception. + */ + public function testSetUnevaluatedPropertyRejectsDeclaredPropertyKey(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['name' => 'Alice']); + + try { + $object->unevaluatedProperties()->set('name', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 'name' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + // `name` is declared directly in `properties`, so the pointer identifies the + // property's declaration site — resolved via the property's #[JsonPointer] + // attribute rather than recomputed from schema paths. + $this->assertSame('/properties/name', $exception->getJsonPointer()->pointer); + } + } + + /** + * The typed companion is a stand-alone class generated next to the model — it mirrors the + * production-library accessor's shape with narrowed signatures but does not extend it. + * (Matches the pattern used by AdditionalPropertiesAccessorPostProcessor.) Its short name + * follows {ModelName}UnevaluatedProperties. + */ + public function testCompanionClassNamingFollowsModelClassName(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(); + $accessor = $object->unevaluatedProperties(); + + $reflection = new ReflectionClass($accessor); + $this->assertSame($className . 'UnevaluatedProperties', $reflection->getName()); + $this->assertTrue($reflection->hasMethod('get')); + $this->assertTrue($reflection->hasMethod('set')); + $this->assertTrue($reflection->hasMethod('getAll')); + $this->assertTrue($reflection->hasMethod('remove')); + } + + /** + * A companion class narrows the accessor to the schema's type — `getType() !== null` on + * the validation property gates it. `unevaluatedProperties: {}` (an untyped, empty schema — + * distinct from the boolean `true`, which is a no-op that emits no validator and therefore + * no accessor at all) has no declared type, so no companion is generated: the accessor + * method returns the bare production-library class directly, typed `mixed` throughout. + */ + public function testUntypedUnevaluatedSchemaUsesTheBareProductionLibraryAccessorClass(): void + { + $this->addPostProcessor(); + + $mutableClassName = $this->generateClassFromFile( + 'UntypedUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + $mutableObject = new $mutableClassName(); + $mutableAccessor = $mutableObject->unevaluatedProperties(); + + $this->assertInstanceOf(UnevaluatedPropertiesAccessor::class, $mutableAccessor); + $this->assertSame(UnevaluatedPropertiesAccessor::class, $mutableAccessor::class); + + // The bare class imposes no type constraint — any value is accepted and round-trips. + $mutableAccessor->set('extra', ['nested' => true]); + $this->assertSame(['nested' => true], $mutableAccessor->get('extra')); + + $immutableClassName = $this->generateClassFromFile( + 'UntypedUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(true), + ); + $immutableObject = new $immutableClassName(['extra' => 'value']); + + $this->assertSame( + ImmutableUnevaluatedPropertiesAccessor::class, + $immutableObject->unevaluatedProperties()::class, + ); + } + + /** + * `setDenyAdditionalProperties(true)` flips a schema with no explicit `additionalProperties` + * to an implicit `false` — every extra is rejected before the unevaluated phase ever runs, + * so `UnevaluatedPropertiesValidatorFactory` treats the keyword as dead code the same way it + * would for an explicit `additionalProperties: false`. No validator means no backing field + * to expose, so the accessor method itself must not be generated at all. + */ + public function testDenyAdditionalPropertiesSuppressesTheUnevaluatedAccessor(): void + { + $this->addPostProcessor(); + $logger = new RecordingLogger(); + + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setDenyAdditionalProperties(true)->setLogger($logger), + ); + + $this->assertFalse((new ReflectionClass($className))->hasMethod('unevaluatedProperties')); + $this->assertTrue( + $this->hasLogEntry( + $logger->getEntries(), + 'warning', + 'unevaluatedProperties on {class} is dead code — {reason}', + [ + 'reason' => 'denyAdditionalProperties() flips missing additionalProperties to' + . ' false, rejecting every extra before the unevaluated phase runs', + ], + ), + ); + } + + /** + * Immutable models receive only the read side of the accessor. When the schema is typed, + * the companion class is still emitted but omits the mutator methods entirely. + */ + public function testImmutableAccessorOmitsMutators(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(true), + ); + + $object = new $className(['name' => 'Alice', 'count' => 5]); + $accessor = $object->unevaluatedProperties(); + + $this->assertSame(5, $accessor->get('count')); + $this->assertSame(['count' => 5], $accessor->getAll()); + + // Immutable companion has no set/remove methods at all. + $this->assertFalse(method_exists($accessor, 'set')); + $this->assertFalse(method_exists($accessor, 'remove')); + } + + /** + * A non-transforming filter (here `trim`) leaves the declared PHP type untouched — the + * companion's get/getAll/set signatures must remain the schema-declared `string` rather + * than widening to include the filter's raw input type. The filter still runs at both + * construction time (extras collected into the backing field) and mutator time (set() + * routes through the shim which re-invokes the filter). + */ + public function testNonTransformingFilterKeepsCompanionTypesAtSchemaType(): void + { + $this->addPostProcessor(); + + $className = $this->generateClassFromFile( + 'TypedExtrasWithFilter.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + // Construction collects the extras and runs `trim` on each value before storing them. + $object = new $className(['name' => 'Alice', 'greeting' => ' hello ']); + $accessor = $object->unevaluatedProperties(); + + $this->assertSame('hello', $accessor->get('greeting')); + $this->assertSame(['greeting' => 'hello'], $accessor->getAll()); + + // set() routes through the shim, filter runs again on the new value. + $accessor->set('farewell', ' bye '); + $this->assertSame('bye', $accessor->get('farewell')); + + // Companion signatures — non-transforming filter must not widen the types. + $this->assertSame('string[]', $this->getReturnTypeAnnotation($accessor, 'getAll')); + $getAllReturn = $this->getReturnType($accessor, 'getAll'); + $this->assertSame('array', $getAllReturn->getName()); + $this->assertFalse($getAllReturn->allowsNull()); + + $this->assertSame('string|null', $this->getReturnTypeAnnotation($accessor, 'get')); + $getReturn = $this->getReturnType($accessor, 'get'); + $this->assertSame('string', $getReturn->getName()); + $this->assertTrue($getReturn->allowsNull()); + + $this->assertSame('string', $this->getParameterTypeAnnotation($accessor, 'set', 1)); + $this->assertSame(['string'], $this->getParameterTypeNames($accessor, 'set', 1)); + } + + /** + * A transforming filter (here `dateTime`) turns the raw input string into a DateTime + * instance at collection time. The companion's get/getAll must therefore return the + * *transformed* type, and set() must accept either the raw input type OR the transformed + * type — mirroring the AdditionalPropertiesAccessorPostProcessor contract so a caller can + * pass a DateTime directly instead of round-tripping through a formatted string. The full + * round-trip serializes back to the raw format the filter's outputFormat declared. + */ + public function testTransformingFilterNarrowsCompanionToTransformedType(): void + { + $this->addPostProcessor(); + + $className = $this->generateClassFromFile( + 'TypedExtrasWithTransformingFilter.json', + (new GeneratorConfiguration()) + ->setImmutable(false) + ->setSerialization(true), + ); + + $object = new $className(['name' => 'Late autumn', 'start' => '2020-10-10']); + $accessor = $object->unevaluatedProperties(); + + // Extras collected at construction time are transformed to DateTime instances. + $this->assertInstanceOf(DateTime::class, $accessor->get('start')); + $this->assertInstanceOf(DateTime::class, $accessor->getAll()['start']); + + // set() with a raw string routes through the filter and stores a DateTime. + $accessor->set('end', '2020-12-12'); + $this->assertInstanceOf(DateTime::class, $accessor->get('end')); + + // set() with an already-transformed value must also be accepted — the type-check on the + // unevaluated subschema's synthetic property is pass-through-wired around the + // transforming filter, so DateTime bypasses the pre-transform `is_string` guard. + $accessor->set('now', new DateTime()); + $this->assertInstanceOf(DateTime::class, $accessor->get('now')); + + // Serialization applies the filter's outputFormat and turns each DateTime back into + // the raw representation the filter accepts. + $this->assertEqualsCanonicalizing( + ['name' => 'Late autumn', 'start' => '20201010', 'end' => '20201212'], + array_intersect_key( + $object->toArray(), + ['name' => true, 'start' => true, 'end' => true], + ), + ); + + // Companion signatures — transforming filter must widen types on both read and write. + $this->assertSame('(DateTime|null)[]', $this->getReturnTypeAnnotation($accessor, 'getAll')); + $getAllReturn = $this->getReturnType($accessor, 'getAll'); + $this->assertSame('array', $getAllReturn->getName()); + $this->assertFalse($getAllReturn->allowsNull()); + + $this->assertSame('DateTime|null', $this->getReturnTypeAnnotation($accessor, 'get')); + $getReturn = $this->getReturnType($accessor, 'get'); + $this->assertSame('DateTime', $getReturn->getName()); + $this->assertTrue($getReturn->allowsNull()); + + $this->assertSame( + 'string|DateTime|null', + $this->getParameterTypeAnnotation($accessor, 'set', 1), + ); + $this->assertEqualsCanonicalizing( + ['string', 'DateTime', 'null'], + $this->getParameterTypeNames($accessor, 'set', 1), + ); + } + + /** + * Composition branches contribute their declared `properties` keys to the evaluated set + * at runtime. The shim must reject those keys for the same reason it rejects locally- + * declared ones: routing them through the unevaluated accessor would bypass the branch's + * own type validation and silently store a value that belongs to the branch's contract. + */ + public function testSetRejectsKeysDeclaredInCompositionBranch(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'AllOfBranchOwnsProperty.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(); + + try { + // `branchOwned` is declared only inside the allOf branch, not on the outer schema. + // The accumulator would normally credit it to the branch — setting it via the + // unevaluated accessor must still throw. + $object->unevaluatedProperties()->set('branchOwned', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 'branchOwned' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + // The composition-branch harvest resolves the pointer to the declaration site + // *inside* the allOf branch (index 0), proving the harvest walks branch schemas + // rather than just recording the property name. + $this->assertSame('/allOf/0/properties/branchOwned', $exception->getJsonPointer()->pointer); + } + } + + /** + * `harvestCompositionPropertyNames()` must recurse into two shapes beyond a branch's own + * flat `properties`: a branch's `patternProperties` (matched by regex, not by exact name — + * `b_foo`, harvested from branch 0), a composition nested *inside* a branch via `oneOf` + * (`nested`, declared by branch 1), and the same nested recursion via `if`/`then`/`else` + * (`onlyWhenA`, declared inside branch 2's `then`) — `allOf`/`anyOf`/`oneOf` and + * `if`/`then`/`else` are two separate recursive code paths in the harvester and neither + * exercises the other. The three shapes sit in separate `allOf` branches rather than + * combined in one to keep the generated nested-class chain shallow enough for Windows + * filename-length limits. A genuinely unclaimed key still passes as a control, proving the + * guard isn't just rejecting everything. + */ + public function testSetRejectsKeysOwnedThroughNestedCompositionAndPatternProperties(): void + { + $this->addPostProcessor(); + // originalClassNames: true — the fixture's branch nesting combined with the default + // uniqid-multiplying class-name generator exceeds Windows' path-length limit. + $className = $this->generateClassFromFile( + 'NestedPatternOwned.json', + (new GeneratorConfiguration())->setImmutable(false), + originalClassNames: true, + ); + + $object = new $className(); + $accessor = $object->unevaluatedProperties(); + + try { + // 'b_foo' is owned by the branch's own patternProperties, matched by regex. + $accessor->set('b_foo', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException for the pattern-owned key'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 'b_foo' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + } + + try { + // 'nested' is owned by a oneOf composition nested inside the allOf branch. + $accessor->set('nested', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException for the nested-composition-owned key'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 'nested' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + } + + try { + // 'onlyWhenA' is owned by the then-branch of an if/then/else nested inside the + // allOf branch — the harvester's separate if/then/else recursion, not oneOf's. + $accessor->set('onlyWhenA', 42); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException for the then-branch-owned key'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 'onlyWhenA' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + } + + // Control: a key owned by nothing is genuinely unevaluated and must be accepted. + $accessor->set('truly_unevaluated', 42); + $this->assertSame(42, $accessor->get('truly_unevaluated')); + } + + /** + * Collect-errors mode: a failed set() leaves the error registry populated. The shim's + * registry reset isolates each setter call so a caller that catches the failure can + * continue using the model — without the reset, every subsequent setter would throw + * stale errors from a prior call instead of the actual current-call errors. + */ + public function testSetterInCollectErrorsModeIsolatesEachCall(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtrasWithRange.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(true), + ); + + $object = new $className(['name' => 'Alice']); + $accessor = $object->unevaluatedProperties(); + + try { + $accessor->set('bad', 999); // exceeds maximum: 100 + $this->fail('First set() was expected to throw'); + } catch (ErrorRegistryException) { + // expected — collect-errors mode wraps validation failures in this exception + } + + // The second call's value is valid — it must succeed even though the first call's + // errors are still stored on the registry field. + $accessor->set('ok', 50); + $this->assertSame(50, $accessor->get('ok')); + } + + /** + * Serialization round-trip: the _unevaluatedProperties backing field is flattened back + * into toArray()/toJSON() output by SerializableTrait::_serializeUnevaluatedProperties. + * Keys claimed at construction time and keys added via set() must both appear in the + * serialized representation. + */ + public function testSerializationFlattensUnevaluatedPropertiesIntoOutput(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration()) + ->setImmutable(false) + ->setSerialization(true), + ); + + $object = new $className(['name' => 'Alice', 'count' => 1, 'limit' => 7]); + $object->unevaluatedProperties()->set('extra', 99); + + $this->assertEqualsCanonicalizing( + ['name' => 'Alice', 'count' => 1, 'limit' => 7, 'extra' => 99], + $object->toArray(), + ); + + $decoded = json_decode($object->toJSON(), true); + $this->assertEqualsCanonicalizing( + ['name' => 'Alice', 'count' => 1, 'limit' => 7, 'extra' => 99], + $decoded, + ); + } + + /** + * `unevaluatedProperties: false` produces a NoUnevaluatedPropertiesValidator with no + * backing storage to expose, so the accessor method must not be emitted at all. + */ + public function testUnevaluatedFalseSuppressesAccessorEmission(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile('UnevaluatedFalse.json'); + + $object = new $className(['name' => 'Alice']); + + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + } + + /** + * Configures both accessor post processors and the serialization post processor on the + * generator so the same code path the user would hit when enabling both accessors + * simultaneously is exercised. + */ + private function addBothAccessorPostProcessors(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + } + + /** + * @return array + * [schemaFile, expectAdditionalAccessor, expectUnevaluatedAccessor] + */ + public static function bothAccessorsEmissionMatrix(): array + { + return [ + // additionalProperties absent, unevaluatedProperties is a typed schema — + // unevaluated owns the bucket; additional accessor needs an explicit opt-in for + // schemas without additionalProperties so it is NOT emitted by default. + 'additional absent, unevaluated typed' => [ + 'TypedExtras.json', + false, + true, + ], + // additionalProperties: true is treated as "claim every extra without validation" + // → additional accessor emits; unevaluated has nothing left to claim and is skipped + // at the factory level (no UnevaluatedPropertiesValidator emitted). + 'additional true, unevaluated typed' => [ + 'AdditionalTrueWithUnevaluatedSchema.json', + true, + false, + ], + // additionalProperties: {schema} validates and claims every extra; unevaluated + // suppressed for the same reason as the true case. + 'additional typed schema, unevaluated typed' => [ + 'AdditionalSchemaWithUnevaluatedSchema.json', + true, + false, + ], + // additionalProperties: false rejects every extra at validation time — neither + // accessor has a bucket to expose; both are suppressed. + 'additional false, unevaluated typed' => [ + 'AdditionalFalseWithUnevaluatedSchema.json', + false, + false, + ], + ]; + } + + /** + * When both accessor post processors are configured at the same time, each one's emission + * policy must independently respect the schema shape. The expected combination depends on + * which keyword owns the bucket of extras. + */ + #[DataProvider('bothAccessorsEmissionMatrix')] + public function testCoexistenceEmissionPolicy( + string $schemaFile, + bool $expectAdditionalAccessor, + bool $expectUnevaluatedAccessor, + ): void { + $this->addBothAccessorPostProcessors(); + $className = $this->generateClassFromFile($schemaFile); + + $this->assertSame( + $expectAdditionalAccessor, + method_exists($className, 'additionalProperties'), + 'additionalProperties() accessor emission did not match the expected shape', + ); + $this->assertSame( + $expectUnevaluatedAccessor, + method_exists($className, 'unevaluatedProperties'), + 'unevaluatedProperties() accessor emission did not match the expected shape', + ); + } + + /** + * Runtime coexistence: when additionalProperties is configured AND a typed unevaluated + * schema is declared, additionalProperties wins at the factory level — extras land in + * _additionalProperties via the additional accessor, never in _unevaluatedProperties. + * Both serializations round-trip through `toArray()` correctly. + */ + public function testAdditionalTrueClaimsExtrasAndUnevaluatedBucketRemainsEmpty(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'AdditionalTrueWithUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(false)->setSerialization(true), + ); + + $object = new $className(['name' => 'Alice', 'extra' => 'hello']); + + $additionalAccessor = $object->additionalProperties(); + $this->assertSame(['extra' => 'hello'], $additionalAccessor->getAll()); + + // Confirm the unevaluatedProperties accessor was not emitted (additionalProperties: true + // suppresses it because additional claims every extra at runtime). + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + + // Serialization flattens the additional bucket back into the output. + $this->assertEqualsCanonicalizing( + ['name' => 'Alice', 'extra' => 'hello'], + $object->toArray(), + ); + } + + /** + * Schema without `additionalProperties` + outer `unevaluatedProperties: false`. The user + * opts the additionalProperties accessor in via `addForModelsWithoutAdditionalPropertiesDefinition` + * and tries to `$model->additionalProperties()->set(...)` a key no sibling claims. The + * shim's cross-state revalidation runs the post-composition phase against a candidate + * raw input view; the enclosing unevaluatedProperties: false rejects the new key, and + * `_additionalProperties` rolls back to its pre-call state. + */ + public function testAdditionalAccessorSetIsRejectedWhenUnevaluatedFalseOrphansTheKey(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor( + new AdditionalPropertiesAccessorPostProcessor( + addForModelsWithoutAdditionalPropertiesDefinition: true, + ), + ); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'NoAdditionalUnevaluatedFalse.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // Construction with an orphan key must already be rejected by unevaluatedProperties: + // false — the accessor's set() path mirrors this rejection at runtime, so the two + // entry points (constructor and accessor) report the same orphan via the same + // exception class. + try { + new $className(['name' => 'Alice', 'foo' => 'orphan']); + $this->fail('Expected construction to reject the orphan key'); + } catch (UnevaluatedPropertiesException $constructorException) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['foo']", + $constructorException->getMessage(), + ); + $this->assertSame(['foo'], $constructorException->getUnevaluatedProperties()); + } + + $object = new $className(['name' => 'Alice']); + + $this->assertTrue(method_exists($object, 'additionalProperties')); + // unevaluatedProperties: false → no accessor (nothing to expose; pure assertion). + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + + $additionalAccessor = $object->additionalProperties(); + + try { + $additionalAccessor->set('foo', 'orphan'); + $this->fail('Expected unevaluatedProperties: false to reject the orphan key'); + } catch (UnevaluatedPropertiesException $setterException) { + $this->assertSame( + "Provided JSON for '{$className}' contains not allowed unevaluated properties ['foo']", + $setterException->getMessage(), + ); + $this->assertSame(['foo'], $setterException->getUnevaluatedProperties()); + } + + // Rollback discipline: neither the additional-properties bucket nor the raw input + // records the failed set. + $this->assertSame([], $additionalAccessor->getAll()); + $this->assertSame(['name' => 'Alice'], $object->meta()->rawInput()); + } + + /** + * `additionalProperties: true` + sibling `unevaluatedProperties: {type: integer}` is a + * dead-code combination: additionalProperties claims every extra at runtime, + * so the unevaluated validator and accessor are both suppressed at codegen. The user can + * therefore call `$model->additionalProperties()->set(...)` with a value of any type — + * the unevaluated schema's `type: integer` constraint never runs, and a string value + * lands unobserved. + * + * The factory also emits a generation-time warning via the configured logger so the + * developer gets a hint that the unevaluatedProperties keyword cannot affect validation at + * this schema level. The assertion below pins the warning entry. + */ + public function testAdditionalAccessorSetUnderSuppressedUnevaluatedIsPermitted(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $logger = new RecordingLogger(); + + $className = $this->generateClassFromFile( + 'AdditionalTrueWithUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(false)->setLogger($logger), + ); + + $this->assertTrue( + $this->hasLogEntry( + $logger->getEntries(), + 'warning', + 'unevaluatedProperties on {class} is dead code — {reason}', + [ + 'reason' => 'sibling additionalProperties: true accepts every extra without' + . ' crediting the unevaluated accumulator', + ], + ), + 'Expected a warning naming the dead unevaluatedProperties keyword', + ); + + $object = new $className(['name' => 'Alice']); + + // No unevaluated accessor — the dead-cell suppression applied at codegen. + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + + // additionalProperties.set() lands a string even though the suppressed + // unevaluatedProperties: {type: integer} would have rejected it. + $object->additionalProperties()->set('foo', 'hello'); + $this->assertSame('hello', $object->additionalProperties()->get('foo')); + $this->assertSame(['name' => 'Alice', 'foo' => 'hello'], $object->meta()->rawInput()); + } + + /** + * Pattern-matched keys land in `_patternProperties`; truly-unevaluated keys land in + * `_unevaluatedProperties`. The two buckets are non-overlapping by construction — the + * pattern keyword runs before the unevaluated check and the unevaluated rebuild credits + * pattern-matched keys as already-evaluated. + * + * The shim's runtime guard rejects keys matching a local `patternProperties` pattern when + * the user tries to route them through `unevaluatedProperties()->set()` — they belong to a + * different contract (the pattern's type schema) and must go through the pattern accessor. + * + * The fixture's pattern is `^s/~` — deliberately containing both RFC 6901 reserved + * characters. When the guard emits the offending pointer, `/` must escape to `~1` and + * `~` to `~0`, with `~1` applied first so a raw `/` doesn't collide with the intermediate + * `~0`. The pattern's pointer segment therefore becomes `^s~1~0`, giving the assertion + * proof that both replacement rules and their ordering fire. + */ + public function testPatternAndUnevaluatedAccessorsExposeNonOverlappingBuckets(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new PatternPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'PatternAndUnevaluatedCoexist.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['name' => 'Alice', 's/~1' => 'pattern-value', 'count' => 42]); + + // pattern-matching key landed in _patternProperties, not in _unevaluatedProperties. + // The pattern accessor's `get()` returns the full key→value map for the pattern. + $patternAccessor = $object->patternProperties(); + $this->assertSame(['s/~1' => 'pattern-value'], $patternAccessor->get('^s/~')); + + // non-matching key landed in _unevaluatedProperties only. + $unevaluatedAccessor = $object->unevaluatedProperties(); + $this->assertSame(['count' => 42], $unevaluatedAccessor->getAll()); + $this->assertNull($unevaluatedAccessor->get('s/~1')); + + // Attempting to route a pattern-matching key through the unevaluated accessor is rejected + // by the shim guard. + try { + $unevaluatedAccessor->set('s/~2', 99); + $this->fail('Expected RegularPropertyAsUnevaluatedPropertyException'); + } catch (RegularPropertyAsUnevaluatedPropertyException $exception) { + $this->assertSame( + "Could not add regular property 's/~2' as an unevaluated property of object '{$className}'", + $exception->getMessage(), + ); + $this->assertSame( + '/patternProperties/^s~1~0', + $exception->getJsonPointer()->pointer, + ); + } + } + + + /** + * `remove()` walks both the backing _unevaluatedProperties array and the raw model data so + * a subsequent `getAll()` reports the post-removal state and `meta()->rawInput()` no longer + * carries the removed key. A second `remove()` on the same key is a no-op and returns + * false. + */ + public function testRemoveDeletesKeyFromBackingFieldAndRawInput(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'TypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false), + ); + + $object = new $className(['name' => 'Alice', 'count' => 5, 'limit' => 7]); + $accessor = $object->unevaluatedProperties(); + + $this->assertTrue($accessor->remove('count')); + + // The removed key is gone from both views; the surviving key is intact. + $this->assertSame(['limit' => 7], $accessor->getAll()); + $this->assertNull($accessor->get('count')); + $this->assertSame(['name' => 'Alice', 'limit' => 7], $object->meta()->rawInput()); + + // Second removal of the same key is a no-op. + $this->assertFalse($accessor->remove('count')); + $this->assertSame(['limit' => 7], $accessor->getAll()); + } + + /** + * `_removeUnevaluatedProperty()` guards against dropping the total property count below + * `minProperties`, mirroring `_removeAdditionalProperty()`'s equivalent guard but through + * the separate `UnevaluatedPropertiesAccessorPostProcessor` / + * `RemoveUnevaluatedProperty.phptpl` pair — the two accessors are structurally parallel but + * are independently generated methods with independent coverage. + * + * On a single instance, constructed at exactly `minProperties` (3): + * - removing an extra would drop the count to 2 < 3 → rejected, and the rejected call + * must roll the model state back to its pre-call values. + * - the object must remain fully usable after the rollback: adding a property back above + * the minimum via `set()` and then removing one (leaving exactly `minProperties`) must + * succeed — proving the rejected call left no internal state (validation caches, + * composition state) corrupted, not just that the getters still report the old values. + * - removing a key that was never set is a no-op (`false`, no exception) even while + * sitting exactly at `minProperties` — the existence check must run before the + * `minProperties` guard, not after, otherwise a harmless no-op removal would be + * rejected by a count check it never needed to reach. + */ + public function testRemoveRejectsCountDropBelowMinPropertiesAndRollsBack(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'MinPropertiesAndUnevaluatedSchema.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['name' => 'Alice', 'a' => 1, 'b' => 2]); + $accessor = $object->unevaluatedProperties(); + + try { + $accessor->remove('a'); + $this->fail('Expected MinPropertiesException'); + } catch (MinPropertiesException $exception) { + $this->assertSame( + "Provided object for '{$className}' must not contain less than 3 properties", + $exception->getMessage(), + ); + } + + // The rejected removal must roll the model state back to its pre-call values. + $this->assertSame(['a' => 1, 'b' => 2], $accessor->getAll()); + $this->assertSame(['name' => 'Alice', 'a' => 1, 'b' => 2], $object->meta()->rawInput()); + + // The object stays usable: adding a property pushes the count back above the minimum, + // so removing one now leaves exactly `minProperties` and must succeed. + $accessor->set('c', 3); + $this->assertTrue($accessor->remove('c')); + $this->assertSame(['a' => 1, 'b' => 2], $accessor->getAll()); + $this->assertSame(['name' => 'Alice', 'a' => 1, 'b' => 2], $object->meta()->rawInput()); + + // Back at exactly `minProperties` (3). Removing a key that was never set must return + // false without ever consulting the minProperties guard — an existence check that ran + // after the count check would wrongly reject this no-op. + $this->assertFalse($accessor->remove('does-not-exist')); + $this->assertSame(['a' => 1, 'b' => 2], $accessor->getAll()); + $this->assertSame(['name' => 'Alice', 'a' => 1, 'b' => 2], $object->meta()->rawInput()); + } + + /** + * remove() must revalidate the post-removal state, not just delete the key. A composition + * branch here requires `minProperties: 3`; removing one of three keys drops the count to 2 + * and the branch fails, so the removal must throw and roll the model back. Without the + * revalidation the shim deleted the key and left a model its own constructor would reject. + * + * The remove shim previously ran only its local `minProperties` check (absent here, since + * the constraint lives inside the branch) and performed no composition revalidation at all, + * unlike the additionalProperties remove path it should mirror. + */ + public function testRemoveRevalidatesCompositionAndRollsBackOnRejection(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'BranchMinPropertiesClaimsExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + // Three keys: the branch's minProperties: 3 is satisfied; the two extras are unevaluated. + $object = new $className(['kind' => 'a', 'first' => 'x', 'second' => 'y']); + $accessor = $object->unevaluatedProperties(); + $this->assertSame(['first' => 'x', 'second' => 'y'], $accessor->getAll()); + + try { + // Removing one extra drops the count to 2 — the branch's minProperties: 3 fails. + $accessor->remove('first'); + $this->fail('Expected the branch minProperties claim to reject the removal'); + } catch (AllOfException $exception) { + $this->assertSame( + <<getMessage(), + ); + } + + // Rollback discipline: both the backing field and the raw input are unchanged. + $this->assertSame(['first' => 'x', 'second' => 'y'], $accessor->getAll()); + $this->assertSame( + ['kind' => 'a', 'first' => 'x', 'second' => 'y'], + $object->meta()->rawInput(), + ); + + // The constructor refuses the same post-removal state, proving remove() agrees with it. + $this->expectException(AllOfException::class); + new $className(['kind' => 'a', 'second' => 'y']); + } + + /** + * A key claimed via the unevaluated accessor stays in `_unevaluatedProperties` even after + * a later mutation makes a composition branch claim the same key. The accumulator-rebuild + * model treats the bucket as a write-once view from the accessor's perspective: keys move + * in via `set()` and `remove()`, never via background reshuffles when an enclosing branch + * starts/stops covering them. + * + * The shim guard rejects keys statically declared inside any composition branch's + * `properties` or `patternProperties`, so the sibling cover in this test comes via a + * branch's `additionalProperties: {type: integer}` — a *dynamic* claim that the guard + * cannot anticipate at codegen. + * + * Sequence on the same generated class: + * - construct `{kind: "X"}` → branch 0 succeeds, no extras, _unevaluatedProperties empty + * - accessor.set('foo', 5) → routes through the shim (guard only sees `kind` in + * composition branches, never `foo`); foo lands in _unevaluatedProperties + * - setKind('Y') → branch 0 fails, branch 1 succeeds and its additionalProperties claims + * `foo` for the accumulator; the unevaluated validator's foreach finds no leftover + * keys to validate and therefore does not touch _unevaluatedProperties + * - assert: `foo` is still in _unevaluatedProperties, exposed by getAll() + * + * Documents the corner the plan calls out: claimed-via-unevaluated values are not + * retroactively reshuffled when a later mutation changes which sibling covers them. + */ + public function testClaimedViaUnevaluatedKeyPersistsAfterSiblingLaterCovers(): void + { + $this->addPostProcessor(); + $className = $this->generateClassFromFile( + 'KindDiscriminatorTypedExtras.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(false), + ); + + $object = new $className(['kind' => 'X']); + $accessor = $object->unevaluatedProperties(); + + $accessor->set('foo', 5); + $this->assertSame(['foo' => 5], $accessor->getAll()); + + // Flip the discriminator: branch 1 now succeeds (kind=Y + foo:5 satisfies its required + // list) and its `properties` declaration credits `foo` to the accumulator. The + // unevaluated validator finds no leftover keys. + $object->setKind('Y'); + + // Still surfaced via the unevaluated accessor — the key is not silently moved into a + // sibling-managed bucket on revalidation. + $this->assertSame(['foo' => 5], $accessor->getAll()); + $this->assertSame(5, $accessor->get('foo')); + $this->assertSame(['kind' => 'Y', 'foo' => 5], $object->meta()->rawInput()); + } + + /** + * Collect-errors mode for `_setAdditionalProperty`: the shim shares a single + * `_errorRegistry` across the base-validator and post-composition phases and throws once + * at the end, so errors from both phases land in the same registry. A patternProperties + * type failure (base phase) combined with an unevaluatedProperties orphan (post-composition + * phase) must both surface. + * + * Schema: `properties: {name}` + `patternProperties: {"^x_": {"type": "integer"}}` + + * `unevaluatedProperties: false`; additional accessor enabled via + * `addForModelsWithoutAdditionalPropertiesDefinition`. + * + * Setting `x_foo` to a string: + * - base phase: pattern matches, value fails `type: integer` → InvalidPatternPropertiesException + * in the registry. PatternProperties.phptpl rolls `_patternProperties` back so the + * storage no longer records `x_foo` for this call's value. + * - post-composition phase: collectUnevaluatedKeys correlates the pattern claim against + * `_patternProperties` to honour per-key validity; the rolled-back + * storage means `x_foo` is *not* credited as evaluated, and unevaluatedProperties: false + * rejects → UnevaluatedPropertiesException in the registry. + * + * Both exceptions are then surfaced through a single ErrorRegistryException. + */ + public function testSetCollectsPatternAndUnevaluatedErrorsTogether(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor( + new AdditionalPropertiesAccessorPostProcessor( + addForModelsWithoutAdditionalPropertiesDefinition: true, + ), + ); + }; + + $className = $this->generateClassFromFile( + 'PatternWithUnevaluatedFalse.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(true), + ); + + $object = new $className(['name' => 'Alice', 'x_foo' => 5]); + $additionalAccessor = $object->additionalProperties(); + + try { + $additionalAccessor->set('x_foo', 'bad-string'); + $this->fail('Expected ErrorRegistryException with pattern + unevaluated errors'); + } catch (ErrorRegistryException $registry) { + $this->assertSame( + <<getMessage(), + ); + } + + // Rollback discipline: _patternProperties restored to the pre-call value; + // _rawModelDataInput unchanged. + $this->assertSame(['name' => 'Alice', 'x_foo' => 5], $object->meta()->rawInput()); + } + + /** + * Collect-errors mode for `_removeAdditionalProperty`: the shim's `minPropertyValidator` + * inline check, the composition revalidation (so `_compositionEvaluations` reflects the + * post-removal state), and the post-composition revalidation share a single + * `_errorRegistry` and a single throw at the end. Removing a key can flip a composition + * branch and orphan a key that the previous branch had claimed; without composition + * revalidation the cache stays stale and the unevaluated check misses the orphan. + * + * Schema: `properties: {kind}` + `patternProperties: {"^p_": {"type": "integer"}}` + + * `minProperties: 3` + an anyOf where branch 0 requires `p_foo` and claims `q_marker` and + * branch 1 succeeds whenever `kind` is `"X"` and claims nothing. With construction + * `{kind: "X", p_foo: 1, q_marker: 7}`, both anyOf branches succeed and `q_marker` is + * credited by branch 0's declared-property list; the model is valid. + * + * Removing `p_foo`: + * - minPropertyValidator: candidate count drops to 2 < 3 → MinPropertiesException. + * - composition revalidation on candidate: branch 0 fails (missing p_foo); branch 1 + * still succeeds (kind=X); anyOf as a whole still passes — but `q_marker` is no + * longer in any successful branch's claimed set. + * - post-composition unevaluatedProperties: false: `q_marker` is now an orphan → + * UnevaluatedPropertiesException. + * + * Both errors land in the registry. On the throw, the model state is rolled back so the + * caller still sees the pre-removal raw input and the pre-removal pattern-bucket. + */ + public function testRemoveCollectsMinPropertyAndUnevaluatedErrorsTogether(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor( + new AdditionalPropertiesAccessorPostProcessor( + addForModelsWithoutAdditionalPropertiesDefinition: true, + ), + ); + }; + + $className = $this->generateClassFromFile( + 'BranchFlipOnRemove.json', + (new GeneratorConfiguration())->setImmutable(false)->setCollectErrors(true), + ); + + $object = new $className(['kind' => 'X', 'p_foo' => 1, 'q_marker' => 7]); + $additionalAccessor = $object->additionalProperties(); + + try { + $additionalAccessor->remove('p_foo'); + $this->fail('Expected ErrorRegistryException with min-property + unevaluated errors'); + } catch (ErrorRegistryException $registry) { + $this->assertSame( + <<getMessage(), + ); + } + + // The rejected removal must roll the model state back to its pre-call values. + $this->assertSame( + ['kind' => 'X', 'p_foo' => 1, 'q_marker' => 7], + $object->meta()->rawInput(), + ); + } + + /** + * Runtime coexistence: additionalProperties: false rejects every extra at construction + * time even though the user enabled both accessor post processors. The unevaluated + * accessor is suppressed at codegen because its bucket would be unreachable. + */ + public function testAdditionalFalseRejectsExtrasAndSuppressesUnevaluatedAccessor(): void + { + $this->modifyModelGenerator = static function (ModelGenerator $generator): void { + $generator->addPostProcessor(new AdditionalPropertiesAccessorPostProcessor()); + $generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor()); + }; + + $className = $this->generateClassFromFile( + 'AdditionalFalseWithUnevaluatedSchema.json', + (new GeneratorConfiguration())->setCollectErrors(false), + ); + + // Constructing with no extras is fine. + $object = new $className(['name' => 'Alice']); + $this->assertFalse(method_exists($object, 'additionalProperties')); + $this->assertFalse(method_exists($object, 'unevaluatedProperties')); + + // Constructing with an extra throws on the additionalProperties: false validator. + $this->expectException(ValidationException::class); + new $className(['name' => 'Alice', 'extra' => 42]); + } +} diff --git a/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesTypesExtras.json b/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesTypesExtras.json new file mode 100644 index 00000000..8f8aee01 --- /dev/null +++ b/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesTypesExtras.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + } + ] +} diff --git a/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json b/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json new file mode 100644 index 00000000..b190b6f1 --- /dev/null +++ b/tests/Schema/AdditionalPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "patternProperties": { + "^p_": { + "type": "integer" + } + }, + "minProperties": 3, + "anyOf": [ + { + "minProperties": 3, + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + }, + { + "properties": { + "kind": { + "const": "X" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/AnyPropertyTest/AnyPropertyArrayConstraint.json b/tests/Schema/AnyPropertyTest/AnyPropertyArrayConstraint.json new file mode 100644 index 00000000..8913d9f5 --- /dev/null +++ b/tests/Schema/AnyPropertyTest/AnyPropertyArrayConstraint.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "property": { + "minItems": 2 + } + } +} diff --git a/tests/Schema/AnyPropertyTest/AnyPropertyObjectAndStringConstraint.json b/tests/Schema/AnyPropertyTest/AnyPropertyObjectAndStringConstraint.json new file mode 100644 index 00000000..93090100 --- /dev/null +++ b/tests/Schema/AnyPropertyTest/AnyPropertyObjectAndStringConstraint.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "property": { + "properties": { + "known": { + "type": "string" + } + }, + "additionalProperties": false, + "minLength": 3 + } + } +} diff --git a/tests/Schema/AnyPropertyTest/AnyPropertyStringConstraint.json b/tests/Schema/AnyPropertyTest/AnyPropertyStringConstraint.json new file mode 100644 index 00000000..7ffceba1 --- /dev/null +++ b/tests/Schema/AnyPropertyTest/AnyPropertyStringConstraint.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "property": { + "minLength": 3 + } + } +} diff --git a/tests/Schema/ArrayPropertyTest/ArrayPropertyWithTransformingFilter.json b/tests/Schema/ArrayPropertyTest/ArrayPropertyWithTransformingFilter.json new file mode 100644 index 00000000..c300e7a4 --- /dev/null +++ b/tests/Schema/ArrayPropertyTest/ArrayPropertyWithTransformingFilter.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } + } + } +} diff --git a/tests/Schema/AutoDetectedDraftSubschemaTest/ArrayPropertyUnevaluatedItems.json b/tests/Schema/AutoDetectedDraftSubschemaTest/ArrayPropertyUnevaluatedItems.json new file mode 100644 index 00000000..91b27479 --- /dev/null +++ b/tests/Schema/AutoDetectedDraftSubschemaTest/ArrayPropertyUnevaluatedItems.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/AutoDetectedDraftSubschemaTest/NestedObjectUnevaluatedProperties.json b/tests/Schema/AutoDetectedDraftSubschemaTest/NestedObjectUnevaluatedProperties.json new file mode 100644 index 00000000..df34ac95 --- /dev/null +++ b/tests/Schema/AutoDetectedDraftSubschemaTest/NestedObjectUnevaluatedProperties.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "properties": { + "child": { + "type": "object", + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/tests/Schema/ComposedAllOfTest/BranchMaxPropertiesReactsToUndeclaredKeys.json b/tests/Schema/ComposedAllOfTest/BranchMaxPropertiesReactsToUndeclaredKeys.json new file mode 100644 index 00000000..92b9bfdb --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/BranchMaxPropertiesReactsToUndeclaredKeys.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "other": { + "type": "string" + } + }, + "allOf": [ + { + "maxProperties": 1 + } + ] +} diff --git a/tests/Schema/ComposedAllOfTest/MutableCompositionBranchWithoutDeclaredProperties.json b/tests/Schema/ComposedAllOfTest/MutableCompositionBranchWithoutDeclaredProperties.json new file mode 100644 index 00000000..97c8795c --- /dev/null +++ b/tests/Schema/ComposedAllOfTest/MutableCompositionBranchWithoutDeclaredProperties.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "allOf": [ + { + "minProperties": 1 + } + ] +} diff --git a/tests/Schema/Issues/72/VacuousBranchWarning.json b/tests/Schema/Issues/72/VacuousBranchWarning.json index 5c82d46f..2a41217f 100644 --- a/tests/Schema/Issues/72/VacuousBranchWarning.json +++ b/tests/Schema/Issues/72/VacuousBranchWarning.json @@ -84,6 +84,10 @@ }, {} ] + }, + "notBranchInheritingParentType": { + "type": "string", + "not": {} } } } diff --git a/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfArrayBranch.json b/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfArrayBranch.json new file mode 100644 index 00000000..b14e8e3f --- /dev/null +++ b/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfArrayBranch.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + { + "type": "array", + "items": [ + { + "type": "string" + } + ] + } + ] + } + } +} diff --git a/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfObjectStringBranch.json b/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfObjectStringBranch.json new file mode 100644 index 00000000..0b840473 --- /dev/null +++ b/tests/Schema/MultiTypePropertyTest/MultiTypePropertyWithOneOfObjectStringBranch.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "property": { + "type": [ + "object", + "string" + ], + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + { + "type": "string", + "minLength": 3 + } + ] + } + } +} diff --git a/tests/Schema/PatternPropertiesTest/AdditionalPropertiesSchemaWithSiblingPattern.json b/tests/Schema/PatternPropertiesTest/AdditionalPropertiesSchemaWithSiblingPattern.json new file mode 100644 index 00000000..23a4438e --- /dev/null +++ b/tests/Schema/PatternPropertiesTest/AdditionalPropertiesSchemaWithSiblingPattern.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "patternProperties": { + "^x_": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/A.json b/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/A.json new file mode 100644 index 00000000..0aa7923e --- /dev/null +++ b/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/A.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "anyOf": [ + { + "type": "object", + "properties": { + "exclusiveProp": { + "type": "string", + "default": "fromA" + } + } + }, + { + "$ref": "B.json" + } + ] +} diff --git a/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/B.json b/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/B.json new file mode 100644 index 00000000..7e6872a6 --- /dev/null +++ b/tests/Schema/ReferencePropertyTest/MutuallyReferencingAnyOfRootCompositions/B.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "anyOf": [ + { + "type": "object", + "properties": { + "exclusiveProp": { + "type": "string", + "default": "fromB" + } + } + }, + { + "$ref": "A.json" + } + ] +} diff --git a/tests/Schema/TupleArrayPropertyTest/TupleArrayWithTransformingFilter.json b/tests/Schema/TupleArrayPropertyTest/TupleArrayWithTransformingFilter.json new file mode 100644 index 00000000..f7e04b22 --- /dev/null +++ b/tests/Schema/TupleArrayPropertyTest/TupleArrayWithTransformingFilter.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + }, + { + "type": "string" + } + ] + } + } +} diff --git a/tests/Schema/UnevaluatedItemsMutabilityTest/AllOfTupleBranches.json b/tests/Schema/UnevaluatedItemsMutabilityTest/AllOfTupleBranches.json new file mode 100644 index 00000000..86f111d7 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsMutabilityTest/AllOfTupleBranches.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + }, + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/AllOfTupleBranches.json b/tests/Schema/UnevaluatedItemsValidatorTest/AllOfTupleBranches.json new file mode 100644 index 00000000..86f111d7 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/AllOfTupleBranches.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + }, + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ArrayAndObjectPropertyCompositionsCoexist.json b/tests/Schema/UnevaluatedItemsValidatorTest/ArrayAndObjectPropertyCompositionsCoexist.json new file mode 100644 index 00000000..2eefed8b --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ArrayAndObjectPropertyCompositionsCoexist.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": false + }, + "meta": { + "type": "object", + "allOf": [ + { + "properties": { + "kind": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json b/tests/Schema/UnevaluatedItemsValidatorTest/BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json new file mode 100644 index 00000000..95eecab7 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/BranchUnevaluatedItemsIgnoresSiblingTupleClaim.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "allOf": [ + { + "unevaluatedItems": false + } + ] + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json b/tests/Schema/UnevaluatedItemsValidatorTest/CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json new file mode 100644 index 00000000..8df5b836 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/CompositionNestedTwoLevelsDeepCreditsOuterAccumulator.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "allOf": [ + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + ] + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ContainsMinContainsZeroBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/ContainsMinContainsZeroBranch.json new file mode 100644 index 00000000..081ef75c --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ContainsMinContainsZeroBranch.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "oneOf": [ + { + "contains": { + "type": "integer" + }, + "minContains": 0 + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ContainsOnlyBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/ContainsOnlyBranch.json new file mode 100644 index 00000000..9f7ecb2d --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ContainsOnlyBranch.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "oneOf": [ + { + "contains": { + "type": "integer" + } + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/IfThenElseArrayUnevaluatedItems.json b/tests/Schema/UnevaluatedItemsValidatorTest/IfThenElseArrayUnevaluatedItems.json new file mode 100644 index 00000000..87b374e8 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/IfThenElseArrayUnevaluatedItems.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "if": { + "items": [ + { + "const": "typed" + } + ] + }, + "then": { + "items": [ + { + "const": "typed" + }, + { + "type": "string" + } + ] + }, + "else": { + "items": [ + { + "type": "integer" + } + ] + }, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsInBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsInBranch.json new file mode 100644 index 00000000..ee64468f --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsInBranch.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "unevaluatedItems": { + "type": "string" + } + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsOnlyInBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsOnlyInBranch.json new file mode 100644 index 00000000..41a4373f --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InnerUnevaluatedItemsOnlyInBranch.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "unevaluatedItems": { + "type": "string" + } + } + ] + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json new file mode 100644 index 00000000..99511a5b --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedItemsType.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": 42 + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json new file mode 100644 index 00000000..82cbb4d5 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/InvalidUnevaluatedPropertiesType.json @@ -0,0 +1,4 @@ +{ + "type": "object", + "unevaluatedProperties": 42 +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsFalseWithUnevaluatedFalse.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsFalseWithUnevaluatedFalse.json new file mode 100644 index 00000000..8a6cd69c --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsFalseWithUnevaluatedFalse.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": false, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsNeitherBooleanTupleNorSchema.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsNeitherBooleanTupleNorSchema.json new file mode 100644 index 00000000..f63010bb --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsNeitherBooleanTupleNorSchema.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": null, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsPlusContainsBranch.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsPlusContainsBranch.json new file mode 100644 index 00000000..3f367b4b --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsPlusContainsBranch.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "oneOf": [ + { + "items": [ + { + "type": "string" + } + ], + "contains": { + "type": "integer" + } + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedFalse.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedFalse.json new file mode 100644 index 00000000..3d83d142 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedFalse.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "integer" + }, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedSchema.json new file mode 100644 index 00000000..035eb483 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/ItemsSchemaWithUnevaluatedSchema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "integer" + }, + "unevaluatedItems": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/MultipleCompositionsOnProperty.json b/tests/Schema/UnevaluatedItemsValidatorTest/MultipleCompositionsOnProperty.json new file mode 100644 index 00000000..f3c5d28b --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/MultipleCompositionsOnProperty.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + } + ], + "oneOf": [ + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/NestedCompositionInsideArrayBranchSilentlyDropped.json b/tests/Schema/UnevaluatedItemsValidatorTest/NestedCompositionInsideArrayBranchSilentlyDropped.json new file mode 100644 index 00000000..eed84bc3 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/NestedCompositionInsideArrayBranchSilentlyDropped.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "oneOf": [ + { + "unevaluatedItems": { + "type": "string" + } + } + ] + } + ] + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsFalse.json b/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsFalse.json new file mode 100644 index 00000000..59b3f72e --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsFalse.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsSchema.json b/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsSchema.json new file mode 100644 index 00000000..b8750177 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/NoOtherConstraintsSchema.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": { + "type": "string" + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/OneOfDifferentTupleLengths.json b/tests/Schema/UnevaluatedItemsValidatorTest/OneOfDifferentTupleLengths.json new file mode 100644 index 00000000..5f9914d6 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/OneOfDifferentTupleLengths.json @@ -0,0 +1,34 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "oneOf": [ + { + "items": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + { + "items": [ + { + "type": "integer" + }, + { + "type": "integer" + }, + { + "type": "integer" + } + ] + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SchemaFormWithTransformingFilter.json b/tests/Schema/UnevaluatedItemsValidatorTest/SchemaFormWithTransformingFilter.json new file mode 100644 index 00000000..1e7a9055 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SchemaFormWithTransformingFilter.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "unevaluatedItems": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SelfReferencingArrayComposition.json b/tests/Schema/UnevaluatedItemsValidatorTest/SelfReferencingArrayComposition.json new file mode 100644 index 00000000..270241cd --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SelfReferencingArrayComposition.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "definitions": { + "recursive": { + "type": "array", + "allOf": [ + { + "$ref": "#/definitions/recursive" + } + ], + "unevaluatedItems": false + } + }, + "properties": { + "tags": { + "$ref": "#/definitions/recursive" + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SiblingContains.json b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingContains.json new file mode 100644 index 00000000..67368637 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingContains.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "contains": { + "type": "integer" + }, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SiblingItemsTrue.json b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingItemsTrue.json new file mode 100644 index 00000000..bd1b9d59 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingItemsTrue.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": true, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json new file mode 100644 index 00000000..8641906d --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleAndUnevaluatedItemsBothWithTransformingFilter.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } + ], + "unevaluatedItems": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Y-m-d" + } + ] + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleItems.json b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleItems.json new file mode 100644 index 00000000..b38c031f --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/SiblingTupleItems.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "string" + } + ], + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalFalseWithUnevaluatedFalse.json b/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalFalseWithUnevaluatedFalse.json new file mode 100644 index 00000000..224ce728 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/TupleAdditionalFalseWithUnevaluatedFalse.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": [ + { + "type": "integer" + } + ], + "additionalItems": false, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json b/tests/Schema/UnevaluatedItemsValidatorTest/TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json new file mode 100644 index 00000000..0e0add71 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/TupleItemsPlusUnevaluatedItemsWithTransformingFilter.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "allOf": [ + { + "items": [ + { + "type": "string" + } + ] + } + ], + "unevaluatedItems": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedFalseWithUniqueItems.json b/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedFalseWithUniqueItems.json new file mode 100644 index 00000000..76d79760 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedFalseWithUniqueItems.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "uniqueItems": true, + "unevaluatedItems": false + } + } +} diff --git a/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedItemsOnNonArrayProperty.json b/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedItemsOnNonArrayProperty.json new file mode 100644 index 00000000..f5c90b20 --- /dev/null +++ b/tests/Schema/UnevaluatedItemsValidatorTest/UnevaluatedItemsOnNonArrayProperty.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "string", + "unevaluatedItems": false + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalFalseWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalFalseWithUnevaluatedSchema.json new file mode 100644 index 00000000..31817051 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalFalseWithUnevaluatedSchema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalSchemaWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalSchemaWithUnevaluatedSchema.json new file mode 100644 index 00000000..291aa71c --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalSchemaWithUnevaluatedSchema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + }, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalTrueWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalTrueWithUnevaluatedSchema.json new file mode 100644 index 00000000..eb9c7529 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AdditionalTrueWithUnevaluatedSchema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": true, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AllOfBranchOwnsProperty.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AllOfBranchOwnsProperty.json new file mode 100644 index 00000000..ae9190c0 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/AllOfBranchOwnsProperty.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "allOf": [ + { + "properties": { + "branchOwned": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsExtras.json new file mode 100644 index 00000000..0b6485e4 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchAdditionalPropertiesClaimsExtras.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + } + ], + "unevaluatedProperties": { + "type": "string" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json new file mode 100644 index 00000000..b190b6f1 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchFlipOnRemove.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "patternProperties": { + "^p_": { + "type": "integer" + } + }, + "minProperties": 3, + "anyOf": [ + { + "minProperties": 3, + "properties": { + "kind": { + "type": "string" + } + }, + "additionalProperties": { + "type": "integer" + } + }, + { + "properties": { + "kind": { + "const": "X" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchMinPropertiesClaimsExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchMinPropertiesClaimsExtras.json new file mode 100644 index 00000000..28cdb640 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/BranchMinPropertiesClaimsExtras.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "allOf": [ + { + "minProperties": 3 + } + ], + "unevaluatedProperties": { + "type": "string" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/KindDiscriminatorTypedExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/KindDiscriminatorTypedExtras.json new file mode 100644 index 00000000..b1cffbf6 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/KindDiscriminatorTypedExtras.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ], + "oneOf": [ + { + "properties": { + "kind": { + "const": "X" + } + } + }, + { + "properties": { + "kind": { + "const": "Y" + } + }, + "additionalProperties": { + "type": "integer" + } + } + ], + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/MinPropertiesAndUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/MinPropertiesAndUnevaluatedSchema.json new file mode 100644 index 00000000..e9b3b894 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/MinPropertiesAndUnevaluatedSchema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "minProperties": 3, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json new file mode 100644 index 00000000..9419d3ad --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NestedPatternOwned.json @@ -0,0 +1,42 @@ +{ + "type": "object", + "unevaluatedProperties": { + "type": "integer" + }, + "allOf": [ + { + "patternProperties": { + "^b_": { + "type": "string" + } + } + }, + { + "oneOf": [ + { + "properties": { + "nested": { + "type": "string" + } + } + } + ] + }, + { + "if": { + "properties": { + "kind": { + "const": "a" + } + } + }, + "then": { + "properties": { + "onlyWhenA": { + "type": "string" + } + } + } + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NoAdditionalUnevaluatedFalse.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NoAdditionalUnevaluatedFalse.json new file mode 100644 index 00000000..59b98669 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/NoAdditionalUnevaluatedFalse.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json new file mode 100644 index 00000000..3f4fdbd2 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternAndUnevaluatedCoexist.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "patternProperties": { + "^s/~": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternWithUnevaluatedFalse.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternWithUnevaluatedFalse.json new file mode 100644 index 00000000..f6c201e7 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/PatternWithUnevaluatedFalse.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "patternProperties": { + "^x_": { + "type": "integer" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtras.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtras.json new file mode 100644 index 00000000..33998863 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtras.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithFilter.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithFilter.json new file mode 100644 index 00000000..b461c018 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithFilter.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "string", + "filter": "trim" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithRange.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithRange.json new file mode 100644 index 00000000..228d28c3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithRange.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer", + "maximum": 100 + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithTransformingFilter.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithTransformingFilter.json new file mode 100644 index 00000000..dd5a1a21 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/TypedExtrasWithTransformingFilter.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "string", + "filter": [ + { + "filter": "dateTime", + "outputFormat": "Ymd" + } + ] + } +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UnevaluatedFalse.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UnevaluatedFalse.json new file mode 100644 index 00000000..59b98669 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UnevaluatedFalse.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UntypedUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UntypedUnevaluatedSchema.json new file mode 100644 index 00000000..79a881b0 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesAccessorPostProcessorTest/UntypedUnevaluatedSchema.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": {} +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/AllOfNestedBranches.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AllOfNestedBranches.json new file mode 100644 index 00000000..9d3ecb68 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AllOfNestedBranches.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "bar": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfOverlappingCoverage.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfOverlappingCoverage.json new file mode 100644 index 00000000..75f9f1b3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfOverlappingCoverage.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": ["kind"], + "anyOf": [ + { + "properties": { + "kind": { + "const": "primary" + }, + "shared": { + "type": "integer" + } + } + }, + { + "properties": { + "kind": { + "type": "string" + }, + "shared": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfSoleCoverer.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfSoleCoverer.json new file mode 100644 index 00000000..16e2fc16 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/AnyOfSoleCoverer.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": ["kind"], + "anyOf": [ + { + "properties": { + "kind": { + "const": "x" + }, + "xOnly": { + "type": "integer" + } + } + }, + { + "properties": { + "kind": { + "const": "y" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/IfThenElseDiscriminator.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/IfThenElseDiscriminator.json new file mode 100644 index 00000000..f0a12148 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/IfThenElseDiscriminator.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "properties": { + "mode": { + "type": "string" + } + }, + "required": ["mode"], + "if": { + "properties": { + "mode": { + "const": "on" + } + } + }, + "then": { + "properties": { + "onlyWhenOn": { + "type": "integer" + } + } + }, + "else": { + "properties": { + "onlyWhenOff": { + "type": "integer" + } + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminator.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminator.json new file mode 100644 index 00000000..07562027 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminator.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": ["kind"], + "oneOf": [ + { + "properties": { + "kind": { + "const": "a" + }, + "alphaOnly": { + "type": "integer" + } + } + }, + { + "properties": { + "kind": { + "const": "b" + }, + "betaOnly": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminatorConstrainedString.json b/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminatorConstrainedString.json new file mode 100644 index 00000000..ed80d8e5 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesMutabilityTest/OneOfKindDiscriminatorConstrainedString.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string", + "minLength": 3 + } + }, + "required": ["kind"], + "oneOf": [ + { + "properties": { + "kind": { + "const": "aaa" + }, + "alphaOnly": { + "type": "integer" + } + } + }, + { + "properties": { + "kind": { + "const": "bb" + }, + "betaOnly": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseDeadCode.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseDeadCode.json new file mode 100644 index 00000000..e1576f34 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseDeadCode.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseWithUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseWithUnevaluatedSchema.json new file mode 100644 index 00000000..31817051 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalFalseWithUnevaluatedSchema.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalPropertiesShortcut.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalPropertiesShortcut.json new file mode 100644 index 00000000..c46ad6c3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalPropertiesShortcut.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalSchemaDeadCode.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalSchemaDeadCode.json new file mode 100644 index 00000000..c46ad6c3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalSchemaDeadCode.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalTrueDeadCode.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalTrueDeadCode.json new file mode 100644 index 00000000..ccefbd8d --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalTrueDeadCode.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": true, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalValueSubschemaUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalValueSubschemaUnevaluated.json new file mode 100644 index 00000000..a800151f --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AdditionalValueSubschemaUnevaluated.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "integer" + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AllOfBranchesCoverEvaluation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AllOfBranchesCoverEvaluation.json new file mode 100644 index 00000000..9d3ecb68 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AllOfBranchesCoverEvaluation.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "bar": { + "type": "integer" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AlwaysTrueBranchContributesNoEvaluatedKeys.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AlwaysTrueBranchContributesNoEvaluatedKeys.json new file mode 100644 index 00000000..0a7db1a7 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AlwaysTrueBranchContributesNoEvaluatedKeys.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + true, + { + "properties": { + "kind": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfBranchUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfBranchUnsupportedDownPropagation.json new file mode 100644 index 00000000..4e9f0aaa --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfBranchUnsupportedDownPropagation.json @@ -0,0 +1,14 @@ +{ + "title": "AnyOfBranchUnsupportedDownPropagation", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "anyOf": [ + { + "unevaluatedProperties": false + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfSelectsSuccessfulBranches.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfSelectsSuccessfulBranches.json new file mode 100644 index 00000000..c30c06aa --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AnyOfSelectsSuccessfulBranches.json @@ -0,0 +1,26 @@ +{ + "type": "object", + "anyOf": [ + { + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ] + }, + { + "properties": { + "value": { + "type": "integer" + } + }, + "required": [ + "value" + ] + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/AuthorDeclaredTypeBranchStaysSilent.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/AuthorDeclaredTypeBranchStaysSilent.json new file mode 100644 index 00000000..11203a9a --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/AuthorDeclaredTypeBranchStaysSilent.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "type": "object" + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchLevelAdditionalProperties.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchLevelAdditionalProperties.json new file mode 100644 index 00000000..a1fdd446 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchLevelAdditionalProperties.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "anyOf": [ + { + "properties": { + "kind": { + "const": "X" + } + }, + "required": [ + "kind" + ], + "additionalProperties": { + "type": "integer" + } + }, + { + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ] + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json new file mode 100644 index 00000000..497aa6f3 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations.json @@ -0,0 +1,14 @@ +{ + "title": "BranchOnlyUnevaluatedPropertiesIgnoresOuterDeclarations", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "unevaluatedProperties": false + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchPatternPropertiesClaim.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchPatternPropertiesClaim.json new file mode 100644 index 00000000..85d3a9bc --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchPatternPropertiesClaim.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "allOf": [ + { + "type": "object", + "patternProperties": { + "^x-": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchUnevaluatedTrueClaims.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchUnevaluatedTrueClaims.json new file mode 100644 index 00000000..65117674 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/BranchUnevaluatedTrueClaims.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "foo": { + "type": "string" + } + }, + "allOf": [ + { + "unevaluatedProperties": true + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/ContradictoryUnevaluatedSchema.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/ContradictoryUnevaluatedSchema.json new file mode 100644 index 00000000..2526eb06 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/ContradictoryUnevaluatedSchema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "allOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/DefaultsNotEvaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/DefaultsNotEvaluated.json new file mode 100644 index 00000000..277087be --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/DefaultsNotEvaluated.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "timeout": { + "type": "integer", + "default": 30 + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/DenyAdditionalDeadCode.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/DenyAdditionalDeadCode.json new file mode 100644 index 00000000..59b98669 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/DenyAdditionalDeadCode.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/DependentSchemasWithUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/DependentSchemasWithUnevaluated.json new file mode 100644 index 00000000..7873e0d1 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/DependentSchemasWithUnevaluated.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "dependentSchemas": { + "kind": { + "properties": { + "extra": { + "type": "integer" + } + } + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/EmptyAllOf.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/EmptyAllOf.json new file mode 100644 index 00000000..7d1a67a0 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/EmptyAllOf.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalRefBranch.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalRefBranch.json new file mode 100644 index 00000000..c40757e2 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalRefBranch.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "$ref": "../UnevaluatedPropertiesValidatorTest_external/externalBranch.json#/definitions/extension" + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalSchemaPlaceholderBranch.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalSchemaPlaceholderBranch.json new file mode 100644 index 00000000..28b1e45e --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/ExternalSchemaPlaceholderBranch.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "$ref": "../UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json" + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenBranchUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenBranchUnsupportedDownPropagation.json new file mode 100644 index 00000000..580b51cb --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenBranchUnsupportedDownPropagation.json @@ -0,0 +1,22 @@ +{ + "title": "IfThenBranchUnsupportedDownPropagation", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "if": { + "properties": { + "kind": { + "const": "a" + } + } + }, + "then": { + "unevaluatedProperties": false + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenElseEvaluation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenElseEvaluation.json new file mode 100644 index 00000000..33d63c1f --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/IfThenElseEvaluation.json @@ -0,0 +1,33 @@ +{ + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "if": { + "properties": { + "kind": { + "const": "A" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "properties": { + "valueA": { + "type": "string" + } + } + }, + "else": { + "properties": { + "valueB": { + "type": "integer" + } + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/NestedUnevaluatedInBranchPropagatesClaims.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/NestedUnevaluatedInBranchPropagatesClaims.json new file mode 100644 index 00000000..92c32212 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/NestedUnevaluatedInBranchPropagatesClaims.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/NoExtraPropertiesAllowed.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/NoExtraPropertiesAllowed.json new file mode 100644 index 00000000..59b98669 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/NoExtraPropertiesAllowed.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchContributesNothing.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchContributesNothing.json new file mode 100644 index 00000000..a991b5ff --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchContributesNothing.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "foo": { + "type": "string" + } + }, + "not": { + "properties": { + "forbidden": { + "type": "integer" + } + }, + "required": [ + "forbidden" + ] + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchExemptFromUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchExemptFromUnsupportedDownPropagation.json new file mode 100644 index 00000000..d30a36aa --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/NotBranchExemptFromUnsupportedDownPropagation.json @@ -0,0 +1,12 @@ +{ + "title": "NotBranchExemptFromUnsupportedDownPropagation", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "not": { + "unevaluatedProperties": false + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/OneOfBranchUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/OneOfBranchUnsupportedDownPropagation.json new file mode 100644 index 00000000..ea71cebf --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/OneOfBranchUnsupportedDownPropagation.json @@ -0,0 +1,14 @@ +{ + "title": "OneOfBranchUnsupportedDownPropagation", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "oneOf": [ + { + "unevaluatedProperties": false + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternPropertiesEvaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternPropertiesEvaluated.json new file mode 100644 index 00000000..5b8e94a2 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternPropertiesEvaluated.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "patternProperties": { + "^x_": { + "type": "string" + } + }, + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternValueSubschemaUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternValueSubschemaUnevaluated.json new file mode 100644 index 00000000..4ebd675d --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/PatternValueSubschemaUnevaluated.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "patternProperties": { + "^p_": { + "type": "object", + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/PropertyNamesFailsFirst.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/PropertyNamesFailsFirst.json new file mode 100644 index 00000000..a593fed1 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/PropertyNamesFailsFirst.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "propertyNames": { + "pattern": "^[a-z]+$" + }, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/RecursiveSelfReference.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/RecursiveSelfReference.json new file mode 100644 index 00000000..a2a4faad --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/RecursiveSelfReference.json @@ -0,0 +1,22 @@ +{ + "$defs": { + "node": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "child": { + "$ref": "#/$defs/node" + } + }, + "unevaluatedProperties": false + } + }, + "type": "object", + "properties": { + "root": { + "$ref": "#/$defs/node" + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/RefResolvedBranchContributesAnnotations.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/RefResolvedBranchContributesAnnotations.json new file mode 100644 index 00000000..df2849f5 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/RefResolvedBranchContributesAnnotations.json @@ -0,0 +1,27 @@ +{ + "$defs": { + "extension": { + "type": "object", + "properties": { + "foo": { + "type": "string" + }, + "bar": { + "type": "integer" + } + } + } + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "allOf": [ + { + "$ref": "#/$defs/extension" + } + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/RequiredPropertyFailsFirst.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/RequiredPropertyFailsFirst.json new file mode 100644 index 00000000..978166e4 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/RequiredPropertyFailsFirst.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "unevaluatedProperties": false +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/SchemaFormUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/SchemaFormUnevaluated.json new file mode 100644 index 00000000..33998863 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/SchemaFormUnevaluated.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "type": "integer" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/SiblingBranchUnsupportedDownPropagation.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/SiblingBranchUnsupportedDownPropagation.json new file mode 100644 index 00000000..57c8f476 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/SiblingBranchUnsupportedDownPropagation.json @@ -0,0 +1,16 @@ +{ + "title": "SiblingBranchUnsupportedDownPropagation", + "type": "object", + "allOf": [ + { + "unevaluatedProperties": false + }, + { + "properties": { + "other": { + "type": "string" + } + } + } + ] +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/UnevaluatedIsRef.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/UnevaluatedIsRef.json new file mode 100644 index 00000000..fad4c1be --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/UnevaluatedIsRef.json @@ -0,0 +1,16 @@ +{ + "$defs": { + "integerValue": { + "type": "integer" + } + }, + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "unevaluatedProperties": { + "$ref": "#/$defs/integerValue" + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest/UntypedNestedSchemaWithUnevaluated.json b/tests/Schema/UnevaluatedPropertiesValidatorTest/UntypedNestedSchemaWithUnevaluated.json new file mode 100644 index 00000000..3509a382 --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest/UntypedNestedSchemaWithUnevaluated.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "child": { + "properties": { + "known": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json b/tests/Schema/UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json new file mode 100644 index 00000000..eef5baef --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest_external/definitionsOnlyPlaceholderTarget.json @@ -0,0 +1,7 @@ +{ + "definitions": { + "unused": { + "type": "string" + } + } +} diff --git a/tests/Schema/UnevaluatedPropertiesValidatorTest_external/externalBranch.json b/tests/Schema/UnevaluatedPropertiesValidatorTest_external/externalBranch.json new file mode 100644 index 00000000..9736250c --- /dev/null +++ b/tests/Schema/UnevaluatedPropertiesValidatorTest_external/externalBranch.json @@ -0,0 +1,11 @@ +{ + "definitions": { + "extension": { + "properties": { + "external": { + "type": "string" + } + } + } + } +} diff --git a/tests/Utils/Json/JsonPointerLocatorTest.php b/tests/Utils/Json/JsonPointerLocatorTest.php index 4c6e27de..a842da0d 100644 --- a/tests/Utils/Json/JsonPointerLocatorTest.php +++ b/tests/Utils/Json/JsonPointerLocatorTest.php @@ -4,7 +4,7 @@ namespace PHPModelGenerator\Tests\Utils\Json; -use PHPModelGenerator\Model\SchemaDefinition\JsonSchema; +use PHPModelGenerator\Utils\JsonSchema; use PHPModelGenerator\Utils\Json\JsonPointerLocator; use PHPUnit\Framework\TestCase;