Repository navigation
Run filters of branch properties only while their branch is active - #193
Merged
Merged
Conversation
A filter declared on a property inside a root-level then/else branch is copied onto the root property by cloneTransferredProperty() and runs unconditionally, even while the branch is inactive. The test asserts the correct behaviour and currently fails (an inactive else filter still trims the value). The fix direction is still to be decided. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
… anyOf/allOf rejection The filter of a property declared inside a root-level composition branch currently runs for every input because the transferred copy on the parent class keeps the FilterValidator. The new FilterCompositionBranchScopeTest asserts the intended behaviour: a filter only affects the value while its branch is active (if/then/else, oneOf, including a transforming filter), and a filtered property declared directly in an anyOf or allOf branch is rejected at generation time. These tests are expected to fail until the implementation lands. Tests that pinned the old behaviour are adjusted: - the playlist test now expects the title to stay untouched while its then branch is inactive - the nested-compositions schema no longer filters the anyOf branch property (the original shape is covered as a rejection) - the accepted row for a filtered property in a root allOf branch moves to the rejection tests Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Pins what works today as regression guards: a property-level anyOf with a single object branch (the shape accepted by the static tests), and property-level anyOf (merged class) and oneOf with several object branches, where only the matching branch may filter the value. Adds the property-level conditional case, which goes through the same branch transfer into a parent class as a root-level conditional and therefore shows the same leak; it fails until the implementation lands. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
A single filtered property in an allOf branch works today and keeps working (new regression test). allOf is rejected only when a property is filtered more than once (several branches, or the parent schema plus a branch), because the first filter wins and the others are silently dropped. A filtered property in an anyOf branch is rejected. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
A oneOf branch could own a filtered property, but its value would have to be forwarded through the enclosing anyOf, where several branches can match. It is rejected with the same message as a filter declared in an anyOf branch. Fails until the implementation lands. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…arker and branch key map FilterValidator can now return a copy which still describes the filter but is not rendered into the validation code, so a property which receives the filtered value from the branch owning the filter still exposes the filter to the output-type and serialization post processors. Composed validators expose getBranchFilteredKeyMap(), the properties for which the active branch of a oneOf or if/then/else (or a value forwarded through an allOf) hands the filtered value over to the containing schema. No generated code changes yet; the runtime consumption follows. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…ranch A filter on a property of the if condition keeps running inside the condition but must not be applied to the parent. A property which has its own filter and is filtered again in a then or oneOf branch is rejected like the allOf duplicates; the duplicate-filter message is unified. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
The filter of a property declared in a then, else or oneOf branch ran on the parent class for every input because the transferred copy kept the executing FilterValidator. The branch class now owns the filter and the active branch hands the filtered value to the parent: - the transferred copy keeps a non-executing description of the filter - the compositions publish the filtered values of the active branch from the modified values they already compute (also through an allOf) - a substitution validator takes the published value in front of the other validators of the property; without a published value the value stays as provided - when a setter switches the active branch, dependent properties are validated again - a filter on a property of the if condition keeps running inside the condition but is neither applied to the parent nor published Transforming filters (serializer type guard) and the generation time rejections follow. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
A property whose transforming filter is owned by a then, else or oneOf branch may hold the value as provided (branch inactive) or the transformed value: - the generated serializer only serializes a value of the output type of a filter and returns any other value as it is - the output type of the property is the raw type plus the types returned by the filters of its branches, also when then and else transform the same property with different filters - the transferred copy of a branch property carries the widened output type so merging the copies of several branches keeps the raw type Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
A filtered property declared in a branch of an anyOf is rejected: several branches can match, so the filtered value cannot be attributed to a single branch. This includes a oneOf branch below an anyOf. A property which is filtered more than once by declarations that can apply to the same value is rejected as well: the schema itself plus a branch, several allOf branches or the branches of different compositions. The filters have no defined order and only the first declaration used to take effect while the others were silently dropped. The branches of one oneOf or if/then/else exclude each other and may filter the same property differently. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Replaces the note that a filter of a conditional branch property also runs while the branch is inactive with the rules of the filter scope: which compositions can own a filtered property, the if condition, the rejection of anyOf and of a property which is filtered more than once, and transforming filters (type hints and serialization). Adds notes to the anyOf, allOf and if pages. Adds tests for a conditional nested in a oneOf branch, a oneOf nested in a then branch and two compositions which filter the same property. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Coverage Report for CI Build 39933508518Coverage increased (+0.2%) to 99.18%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
- Keep the filter declarations of the branches in a WeakMap keyed by schema and read the filters of the schema itself fresh on every check (deferred while the property is unresolved), so a property declared via $ref is covered and a recycled object id can't inherit foreign declarations - Share the output type of a branch-owned transforming filter between the transfer step and the output type post processor - Compute the filtered key map once per render step - Try the serializer of a filter without a return type last so it can't hide the serializers of the other filters - Add isAllOf()/isAnyOf() and FilterValidator::of() instead of repeating the checks Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
- A filtered property in an anyOf is accepted when every branch declares it with the same filters: the outcome doesn't depend on the matching branch. Any other filtered property in an anyOf branch is still rejected. - The compositions stage the filtered values of the active branches and the constructor, setters and populate() adopt them only after the update succeeded (dependent properties are validated again at that point). A failing setter or populate() no longer leaves the values of the branch it tried to activate behind. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Drop the then/oneOf-overrides-root rows of the filtered-more-than-once provider (the same assertion path as the allOf rows), the positive branch-filtered key map tests (covered by the runtime tests) and two FilterValidator unit tests that the integration tests already cover. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Schema::addProperty registers by name immediately, so the transferred branch property is always registered and the null check in takeOverBranchFilteredValue could never trigger. The validator's getCheck() was only reachable through a unit test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…ilter scoping - populate() passed all provided keys as processed to the commit, but skips values equal to the current attribute: a re-passed filtered property was excluded from the dependent re-validation while the holder changed. Only the keys populate() processed count now. - The commit addressed a property whose name starts with a digit by its unprefixed attribute; use the class attribute. - Move the filter scoping code out of SchemaProcessor into Utils\BranchFilterScope. - Docs: the dateTime example declares the property in the parent so the documented type hints are accurate, mention the mixed hint of a branch-only property and the atomic-update exception. - Tests: populate re-passing the filtered property, digit-leading property name, immutable models. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
allOf and oneOf answer a component from its cached result when none of its properties is part of the update. Such a component publishes no value, and staging null for its keys dropped the committed filtered value of the unchanged active branch (oneOf: updating a property of an inactive branch; allOf: updating one of two conditionals). Compositions now track the evaluated components and only stage keys whose components were evaluated. As the staged slots are no longer always overwritten, populate() resets the staging at its start so a failed populate cannot leak a stale value into a later one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
This was referenced Oct 14, 2026
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Identical schemas are mapped to one class by a signature which sorted every list. Schemas which only differ in the order of their filters or of the items of a tuple were therefore mapped to the same class, and the second one validated with the tuple of the first. Only lists which are sets are compared order-insensitive now (type, required, enum, the dependants of a property dependency), so the deduplication stays as broad as possible. Every other list keeps its order, values (const, default, examples, enum items) are never sorted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…f filter lists A filter which a branch executes on the schema for every input (a single allOf branch, the identical filter lists of every anyOf branch) was silently dropped if the schema declared the property without a filter: the merge kept the declaration which was registered first. The registered property now adopts the filters as if it declared them (the filters run first, its own validators check the filtered value). Several allOf branches which declare an identical filter list (filters, options and order) filter the same input; they are accepted and the filters run once. Diverging lists and values forwarded from nested compositions stay rejected as filtered more than once. Tests written by the review session which started this work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…pdate is assigned A setter or populate() which switched the active branch validated the dependent properties again after the update was already assigned. A dependent property which failed (e.g. a trimmed title violating minLength of the schema) left the model with the new branch and the old value, a state the constructor rejects. The commit is split into a prepare step, which validates the dependent properties and computes the new state without assigning anything, and an apply step. The setter prepares at the end of its before-validation hook and applies in the after hook, populate() prepares inside its rollback block and applies once the update succeeded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Then and else (or the branches of a oneOf) may transform the same property with the same filter but different options, or with different filters returning the same type. The property only kept one description per filter name and the serializer picked the first filter matching the type of the value, so a value of the other branch was serialized with the wrong filter or options. A property which can hold the values of several such filters now remembers which filter produced its value: the active branch hands over the filter together with the value (read from the nested object when the value comes from a deeper composition), the source is staged, prepared and applied together with the value, and the serializer dispatches on it before falling back to the type check. Classes without such a property are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Filter adoption by the schema property, identical allOf filter lists, the update which is rejected by a property depending on the branch, the serialization by the producing filter and the list order in the deduplication of nested classes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…ranch properties The first property referencing a definition is the property every other reference delegates to, and a clone of a reference still delegates every change to it. Transferring a branch property (scoping its filter to the branch, widening its type) and merging it into a registered property (adopting a filter, adding the branch value substitution) therefore changed the definition: other properties referencing it lost their filter, crashed on valid input or got the type and filters of the branch. A transferred reference is detached from the definition, and a registered property is replaced by a copy of its own before a branch declaration is merged into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…of an early setter return
- A property whose transforming filter is owned by a branch got the native
type 'string | DateTime | null' but the docblock only rendered the first
type name ('string|null'). The builder class derives its imports from the
docblock, so DateTime was not imported and its setter rejected a DateTime.
The docblock now lists every type.
- A setter returns early if the new value equals the current value. The
current value of a branch filtered property may be the filtered value, the
raw input kept the value provided before; a branch switch validated the
property again from that stale raw input. The early return records the
provided value as raw input.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
The filter of a matching patternProperties entry was added to a declared property behind its own filter or the filter of a composition branch, and two matching entries stacked their filters as well. Only two transforming filters were rejected. As the filters of a property have no defined order, any combination is now rejected as filtered more than once (the previous check for two transforming filters is covered by it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Copying every registered property changed its identity for every merge. Only a property which other references to a definition delegate to (the resolved property of a definition, or a proxy) has to be copied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…y the tests - BranchFilteredValueValidator is a template validator: an IIFE substitutes the value by reference (like the filter validators), so RenderHelper no longer special-cases it - the composition validators collect their set-up statements in an array - the filter identifier rendered into the generated code is the token, followed by a hash of the options if the filter has options, instead of the JSON representation - remove tests which asserted generated code or internal state, merge tests which generated the same (or an identical) schema with the same configuration Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
# Conflicts: # src/Model/Schema.php
wol-soft
pushed a commit
that referenced
this pull request
Oct 23, 2026
Resolution: the setters validate the compositions themselves instead of via the composition setter hook, so the staging of the filtered values of the active branches (#193) moves from that hook into the core setter rendering: each setter path which validates a composition resets the staging and prepares the filtered values, the setter applies them once the update succeeded. populate() prepares them after the dependency validation of the properties it doesn't update. The per-composition restore of the validation state stays removed in favour of the model state snapshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #192 (base branch
claude/sweet-wright-0c9raw, GitHub retargets tomasteronce #192 is merged). #192 still has to be merged: this PR changes parts of what #192 introduced (see "Changes to tests of #192").A filter on a property declared in a
then,elseoroneOfbranch ran on the generated parent class for every input, because the transferred copy of the branch property kept the executingFilterValidator. #192 documents this as a known limitation; this PR fixes it.Behaviour
if/then/else,oneOf: the branch class owns the filter. The active branch hands its filtered value to the parent (also through anallOfand through nested compositions); while the branch is inactive the property keeps the value as provided. A setter orpopulate()which switches the active branch validates the dependent properties again (also whenpopulate()re-passes the filtered property unchanged).populate()). A rejected setter orpopulate()leaves the model untouched (also in collect-errors mode), including the case where the dependent property rejects the update.ifcondition: the filter keeps running inside the condition, but it is neither applied to the parent nor handed over. Filters in a schema dependency behave the same way.thenandelse(or the branches of aoneOf) may use different transforming filters or the same filter with different options on one property; such a property remembers which filter produced its value and is serialized by exactly that filter (otherwise the serializer only applies to a value of the filter's output type).allOf: the filter of a single branch applies to every value. Several branches may declare an identical filter list (filters, options and order); the filters run once.allOfbranch (or the identical filter of everyanyOfbranch) applies to every value, the schema property adopts the filter (the filter runs first, the schema's validators check the filtered value). Before, such a filter was silently dropped.anyOf: several branches can match, so a filtered property is only accepted when every branch declares the property with the same filter. Every other filtered property in ananyOfbranch is rejected with aSchemaException, including aoneOfbranch nested below ananyOf.SchemaException): a property which is filtered by declarations that can apply to the same value: the schema itself plus a branch (also an identical repeat),allOfbranches with diverging filter lists or with values forwarded from nested compositions, the branches of different compositions, or a matchingpatternPropertiesfilter on a property which is filtered already (by itself, a branch or another pattern).then/elseand the branches of oneoneOfexclude each other and may filter the same property differently.$refdefinitions: a property referencing a definition is detached from the definition before it is transferred from a branch or merged with a branch declaration, so other properties referencing the same definition keep their filters, validators and types.filter, tupleitems/prefixItems, composition branches) are no longer mapped to the same class. Lists which are sets (type,required,enum, the dependants of a property dependency) are still compared order-insensitive.Implementation
FilterValidator::withoutExecution()is a non-executing copy that still describes the filter, so the output type and serialization post processors keep working.getIdentifier()identifies a filter including its options; it is rendered into the generated code as the filter token, followed by an md5 of the options if the filter has options (e.g.'dateTime_899030ba…').AbstractComposedPropertyValidator::getBranchFilteredKeyMap()/publishesFilteredValueOf()decide which branch properties hand a value over. The compositions publish the filtered values (and, where needed, the producing filter, seegetFilterSourceMap()) and stage them;_prepareBranchFilteredValues()validates the dependent properties and computes the new state without assigning,_applyBranchFilteredValues()assigns it. ABranchFilteredValueValidator(a template validator,BranchFilteredValue.phptpl, which substitutes the value by reference in an IIFE like the filter validators) takes the value in front of the other validators of the property.Utils\BranchFilterScope(used bySchemaProcessor) converts the transferred copy, lets the schema property adopt filters executed for every value, adds the substitution to the property which is registered after the merge and rejects ambiguous filters.PropertyProxy::detach();Schema::addProperty()replaces a registered property which a definition shares (a proxy, or the resolved property of a definition which every proxy delegates to, seeResolvedDefinitionsCollection::isShared()) by a copy of its own before a branch declaration is merged into it. Other registered properties keep their identity.Utils\SchemaSignatureNormalizersorts only the set-like lists before the class signature is hashed.Breaking changes (release note)
SchemaExceptionat generation time: a filtered property in ananyOfbranch (unless every branch declares the same filter), a property which is filtered more than once, and apatternPropertiesfilter matching a property which is filtered already (before, the filters were stacked; only two transforming filters were rejected, with a different message). Previously the filters were silently dropped, stacked or applied regardless of the branch.allOfbranch (or of everyanyOfbranch) is now applied to a property which the schema declares without a filter. Before, it was silently dropped.thenor only inelsechanges from?Ttomixed. A transforming filter in a branch widens the getter/setter types of the property to the raw type plus the filter's output types.filterlist, tuple items or composition branches (they were wrongly merged before).meta()->rawInput()), before the early return left the previous raw value.Review fixes
A first combined review of #192 + #193 found three problems in this PR (serialization with the wrong filter options, a partially applied update when a dependent property rejects it, a silently dropped
allOffilter) plus a pre-existing class signature bug (a tuple[string, integer]reused the class of[integer, string]), all fixed here.A second combined review (against master including #195-#197) found four more, fixed with a failing test first:
$refdefinition used in aoneOf/if/then/elsebranch was changed while the branch property was transferred: the filter was dropped, other properties referencing the definition crashed on valid input, received the branch's filter, transforming type or themixedwidening of Accept filters in nested allOf object branches; widen conditional-only properties to mixed #192.DateTimeand rejected it.patternPropertiesfilter stacked with a branch filter (now rejected together with every other stacking, see breaking change 2).Code review notes on the rendering: the substitution is rendered from a template instead of a special case in
RenderHelper, the set-up code of the compositions is collected in arrays, the rendered filter identifier is a token plus hash instead of JSON. Tests which asserted generated code or internal state were removed (covered by the executed tests), and tests generating the same or an identical schema with the same configuration were merged.Changes to tests of #192 and master
thenbranch is inactive (it previously pinned the leak).anyOfbranch property (now rejected); the original shape is covered as a rejection test.allOfbranch moved to the rejection tests (parent filter plus branch filter).PatternPropertiesTest::testMultipleTransformingFiltersForPatternPropertiesAndObjectPropertyThrowsAnExceptionasserts the "filtered more than once" message which replaced the transforming-filter-only check.ComposedIfTest::testCrossTypedThenOnly…(renamed in Accept filters in nested allOf object branches; widen conditional-only properties to mixed #192) is removed:testThenOnlyPropertyKeepsValueOfAnyTypeWhileTheBranchIsInactivecovers the same schema shape including themixedtypes.Merging with master
Master (up to #197) is merged into #192 and, through #192, into this branch. The one conflict was
Schema::addProperty()(#197 changed the merge call). Resolution: keep the copy of a shared registered property before the merge and assign the merge result as #197 does ($replacesExisting = $this->propertyMerger->merge(...), plus the$compositionProcessor === nullargument).Documentation
filter.rst("Filters on properties of object branches": adoption, identicalallOflists, atomic updates, serialization by the producing filter, pattern property filters), notes on theanyOf,allOfandifpages, the dependency schema note inobject.rst, and the list order rule for class re-usage innamespaces.rst. The docs were not built locally (Sphinx is not installed in the sandbox).Test plan
Developed test-first (the commits show it).
ReferencePropertyTestcases which fetchraw.githubusercontent.com(blocked in the sandbox).phpcs --standard=phpcs.xmlon all changed PHP files: cleanFilterCompositionBranchScopeTest,FilterCompositionRuntimeTest,ComposedIfTest,JsonSchemaSignatureTest,IdenticalNestedSchemaTest(tuple order)🤖 Generated with Claude Code
https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs