Filter composition ordering#128
Open
wol-soft wants to merge 12 commits into
Open
Conversation
Introduces the branch classifier that will drive the static rejection (Phase 2) and priority reassignment (Phase 3) of composition validators around transforming filters. Changes: - Draft::getTypesForKeyword(string $keyword): string[] — maps a JSON Schema keyword to the Draft type names whose modifier/factory list carries that keyword, enabling type-space lookup without hard-coded keyword tables. - AbstractValidatorFactory::getKey(): ?string — exposes the schema key that was already stored via setKey(), providing the backlink needed in Phase 3 to trace a live validator back to its source keyword. - TypeSpace enum (Input / Output / Mixed / Empty) — classification result type. - CompositionBranchClassifier — classifies a single composition branch relative to a transforming filter's input/output type-spaces. Uses the Draft registry for extensibility; resolves class-name output types to the 'object' draft space; liberal policy defaults ambiguous branches to Input. - Tests: data-provider-driven Draft::getTypesForKeyword coverage in DraftTest; full unit test suite for CompositionBranchClassifier covering all TypeSpace outcomes, every keyword category, nested compositions, and custom Draft extensibility. - CLAUDE.md: note that .claude/ files must never be added to git. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduce CompositionCompatibilityChecker with two public responsibilities: checkTransformingFilterCompositionConflicts: validates that composition keywords directly on a filtered property do not create unresolvable type-space conflicts with a transforming filter (allOf Mixed branch, anyOf/oneOf cross-space branches, not Mixed inner schema, if/then/else cross-space sub-schemas). checkTransformingFilterRootCompositionConflicts: enforces R-4 — throws when a root-level composition branch constrains the filtered subproperty with output-type-space constraints. Both are wired into FilterProcessor and exercised by the new rejectedCompositionProvider / acceptedCompositionProvider test pairs. Add checkForFilterInBranches to AbstractCompositionValidatorFactory to enforce R-7 (filter keyword inside a composition branch) universally across all five factories, independently of whether an outer transforming filter is present. The check runs after inheritPropertyType so that branches inheriting type:object from their parent are correctly exempt from the filter-in-properties scan, avoiding a false-positive SchemaException for those schemas. Code-quality fixes from review: - Exception messages use "for property %s in file %s" inline style; redundant branch index dropped from the not variant - Class constants in CompositionCompatibilityChecker and CompositionBranchClassifier carry explicit array type annotations - CompositionCompatibilityChecker class docblock condensed to one line with detail moved to the individual method docblocks - checkPropertyComposition renamed to checkTransformingFilterCompositionConflicts and checkRootComposition renamed to checkTransformingFilterRootCompositionConflicts for clarity Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Schema validators whose Draft-registered types are a subset of the transforming filter's output type-space (e.g. 'minimum' for a string→int filter) are left to run post-transform. All other schema validators with a known source keyword (e.g. 'pattern', 'minLength') are moved to priority P-1 so they execute against the raw input value before the filter runs. Infrastructure added: - Validator: sourceKey field + getSourceKey/setSourceKey/setPriority - PropertyInterface/Property/PropertyProxy: optional sourceKey parameter on addValidator(), used when transferring validators from multi-type sub-properties so the source key survives the wrapper recreation - PropertyFactory.applyModifiers(): after each AbstractValidatorFactory modifier runs, tags newly-added Validator wrappers with the factory's schema key via setSourceKey(). Tagging must cover all Draft-registered validators because the filter-keyword combination is not known at tagging time — classification happens later in FilterProcessor when it knows the transforming filter's type signature - CompositionBranchClassifier: classifySchemaKey(string) public method - FilterProcessor: reassignValidatorPriorities() called immediately after the transforming filter is registered; skips FilterValidator, AbstractComposedPropertyValidator (Phase 4), and validators with no source key; leaves Output-classified validators post-transform; moves all others to priority P-1 Tests: ValidatorPriorityWithTransformingFilter schema + two new test methods covering the pre/post-transform split and a regression guard for non-transforming filters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split composition validators around the transforming filter boundary: input-space branches run pre-transform against the raw value; output-space branches run post-transform against the filtered value. FilterPreTransformGuardValidator wraps any input-space composition validator with a skip guard that returns early when the value is already in the filter's output type-space (R-8 already-transformed passthrough). ComposedPropertyValidator gains createSubsetValidator() to split a mixed-space allOf into a pre-filter input-subset and a post-filter output-subset, each rendered as its own extracted method. FilterProcessor.reassignValidatorPriorities() is decomposed into three focused private methods (classifyValidatorAdjustments, wrapInputSpaceGuards, splitMixedSpaceAllOf) and its accumulator arrays use named keys. The $filterList parameter on process() and normalizeFilterList() is narrowed from mixed to string|array. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FormatValidator.getCheck() now prepends is_string($value) && to its generated check, matching the existing pattern of every other string-space validator (pattern, minLength, maxLength). Without the guard, a property with type [string, integer], a format validator, and a transforming filter would call FormatValidatorFromRegEx::validate(int) under strict types when an already-transformed integer was passed, throwing TypeError instead of the expected post-transform validation exception. Adds testFormatValidatorOnMultiTypePropertyDoesNotFireForAlreadyTransformedValue and its schema MultiTypeFormatWithTransformingFilter.json to cover the four cases: valid string input, invalid string input (FormatException), already- transformed negative int (MinimumException, not TypeError), and already- transformed valid int (passes). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves conflicts in FilterProcessor.php (import section) and FilterTest.php (end of file). Master added self/static return-type filter support and the contentMediaType/contentEncoding Draft 7 feature; our branch added the Phase 5 FormatValidator is_string guard. Dropped master's external FormatValidator wrapping in addTransformedValuePassThrough since the guard now lives in FormatValidator.getCheck() directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
testAllOfPropertyWithMixedAcceptTransformingFilter previously used ReflectionClass to verify the setter's type hint as a proxy for the output type being wired correctly. Replace it with three direct runtime assertions: - Valid string input passes the input-space allOf and is transformed to DateTime by the filter. - Integer input via the constructor (which bypasses the setter type hint) reaches the allOf validator directly and produces AllOfException with the full composition error message. - An already-constructed DateTime is accepted as-is via the R-8 passthrough, skipping the pre-transform pipeline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…se 7
Property-level composition type semantics:
- allOf on a property now uses intersection semantics: conflicting branch types
throw SchemaException; int|number resolves to int via the integer-subtype-of-
number rule, now centralised in the new TypeIntersection::compute utility
- anyOf/oneOf with a truly untyped branch ({}) correctly leaves the property as
mixed instead of wrongly adding nullable=true
- Property-level if/then/else gains type widening (union of then+else types when
the parent has no declared type) and conflict detection (SchemaException when
the parent type is incompatible with both branches); object-level if/then/else
with nested schemas is correctly excluded from both paths
- TypeIntersection::compute extracted as a standalone PHPModelGenerator\Utils
utility, replacing PropertyMerger::computeDeclaredIntersection
- Dead data pruned from ComposedAllOfTest data providers
Filter / composition ordering - phase 7:
- Updated filter.rst with a new "Composition with transforming filters" section
covering branch classification into Input/Output type-spaces, accepted and
rejected composition combinations, and correct type-keyword usage
- Fixed CompositionBranchClassifier: the type keyword now always classifies as
Input - it validates raw JSON input and must never be routed against output
types (a branch {type: integer} under a stringToInt filter must reject the
raw string pre-transform, not pass it post-transform)
- Fixed FilterProcessor::classifyComposedValidatorBranches to look up original
pre-inheritPropertyType branch schemas, preventing branches that inherited a
multi-type parent type from being misclassified as Mixed after injection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add B.3/C.4 rejection provider entries and 5 schema fixtures for filter/composition coverage gaps - Add A.3, B.5, C.1, C.6 test methods covering if/then-only pre-transform, empty allOf branch no-op, root-level input-space constraint, and non-transforming filter composition variants with exception message assertions - Convert all old-style (column-0) heredoc closing markers to PHP 7.3+ indented style across 16 test files - Move inline <<<MARKER openers to their own line when used as function arguments - Place trailing commas on the same line as heredoc closing markers - Add missing trailing commas after heredoc closing markers in array literals Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The monolithic Basic/FilterTest (~3050 lines) is replaced by eight
focused classes under tests/Filter/, each with its own schema directory:
- FilterConfigurationTest — registration, lookup, invalid callbacks
- BuiltInFilterTest — trim filter, type rejection, length validation
- CustomFilterTest — custom callables, multiple filters, array filter,
ValidateOptionsInterface
- TransformingFilterTest — dateTime filter, serialization, unsupported
scenarios, scalar transforms, enum interaction,
self/static return types
- FilterTypeCompatibilityTest — overlap rules, untyped properties, union type
hints, bypass formulas, callable reflection errors
- FilterChainTest — chain ordering, skip logic, incompatibility
rejection, mixed-return and accept-all follow-up
- FilterCompositionStaticTest — generation-time rejection/acceptance of
filter+composition combinations
- FilterCompositionRuntimeTest — runtime pre/post-transform ordering for all
composition types, validator priority, format
validator skip guard
AbstractFilterTestCase holds shared helper factories (getCustomFilter,
getCustomTransformingFilter) and static callables used by multiple classes.
SelfReturningFilterCallable and StaticReturningFilterCallable are moved to
the new namespace. Schema files are distributed into per-class directories;
five schemas used by two classes are duplicated; four orphaned schemas with
no referencing test are deleted.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Coverage Report for CI Build 26169686326Coverage decreased (-0.07%) to 98.483%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
…nch checks
IfValidatorFactory previously required both then and else branches to conflict
with the parent before throwing, and stripped null from parent type names — so a
null-typed branch against a non-null parent was silently accepted. Now each branch
is checked independently and null from isNullable() is included in the parent set.
getBranchTypeNames() distinguishes null-typed branches ({type: null}) from truly
untyped branches via getTypeHint(), so null-typed branches correctly intersect (or
conflict) with the parent type. CLAUDE.md extended with review-learning policy and
line-number-in-docblock rule. New test coverage for all affected edge cases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d tests - Remove unreachable !is_array() guards in CompositionBranchClassifier: the not branch always receives an array (NotValidatorFactory wraps it), and non-array allOf/anyOf/oneOf values crash before the classifier runs. - Drop redundant testOutputTypeNotExtendedWhenReturnTypeAlreadyInBaseType: the applyOutputType early-return path is already exercised by the existing testOutputSpaceNotCompositionRunsPostTransform which uses the same type:["string","integer"] + stringToInt schema shape. - Extend testNoExtendedInstanceOfCheckWhenAllObjectBranchesAreNonEmpty with an unparseable-string assertion: 'Hello' passes the input-space anyOf (via type:string) but fails the dateTime filter, throwing InvalidFilterValueException. - Move testFilterOnObjectTypedPropertyWithNestedSchema to FilterTypeCompatibilityTest (no composition in that schema) and add a real runtime assertion: array input is instantiated to the inner class by ObjectInstantiationDecorator, then the filter converts it to DateTime. - Add static test cases for root-level then/else constraining a filtered subproperty with output-type-space keywords, a filter inside an if sub-schema within an allOf branch, and root-level not with an input-space keyword on a filtered subproperty. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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
This PR fixes the ordering of validators relative to transforming filters and adds static
rejection of composition/filter combinations that cannot produce correct runtime behavior.
Without this fix, a property with a transforming filter (e.g.
string -> DateTime) and acomposition keyword (e.g.
allOf,anyOf,if/then/else) would run all validatorspost-transform -- meaning type constraints meant to apply to the raw input value were
silently evaluated against the already-converted output. The reverse was true for output-space
constraints when no guard prevented them from firing pre-transform.
What changed
Phase 1 - Branch classifier and Draft registry extension
Draft::getTypesForKeyword(string $keyword): string[]maps any JSON Schema keyword to theDraft type names whose modifier/factory list carries it, enabling type-space lookup without
hard-coded keyword tables.
AbstractValidatorFactory::getKey(): ?stringexposes the source keyword stored on eachvalidator factory, providing the backlink needed to trace a live validator to its origin.
TypeSpaceenum (Input/Output/Mixed/Empty) as the classification result type.CompositionBranchClassifierclassifies a single composition branch relative to atransforming filter's input/output type-spaces. Uses the Draft registry for extensibility;
defaults ambiguous branches to
Input(conservative policy).Phase 2 - Static compatibility checking
CompositionCompatibilityCheckerwith two responsibilities:type-space conflicts (
allOfMixed branch, cross-spaceanyOf/oneOfbranches,Mixed
not, cross-spaceif/then/elsesub-schemas).with output-type-space constraints (R-4).
checkForFilterInBranchesadded toAbstractCompositionValidatorFactoryto reject thefilterkeyword inside any composition branch (R-7) universally across all five factories.Phase 3 - Validator priority reassignment
Validatorgains asourceKeyfield so any validator wrapper can be traced back to itsoriginating JSON Schema keyword.
PropertyFactory.applyModifiers()tags newly-added validators with the factory's schema keyimmediately after each
AbstractValidatorFactoryruns.FilterProcessor::reassignValidatorPriorities()moves input-space validators (those whosesource keyword belongs to the filter's input type-space) to priority P-1, so they fire
against the raw value before the filter runs. Output-space validators remain post-transform.
Phase 4 - Composition runtime integration
branches run pre-transform; output-space branches run post-transform.
FilterPreTransformGuardValidatorwraps any input-space composition validator with a skipguard that returns early when the value is already in the filter's output type-space
(already-transformed passthrough).
ComposedPropertyValidatorgainscreateSubsetValidator()to split a mixed-spaceallOfinto a pre-filter input-subset and a post-filter output-subset.
FilterProcessordecomposed into three focused private methods(
classifyValidatorAdjustments,wrapInputSpaceGuards,splitMixedSpaceAllOf).Phase 5 - FormatValidator type guard
FormatValidator.getCheck()now prependsis_string($value) &&to its generated check,matching the existing pattern of every other string-space validator (
pattern,minLength,maxLength). Without this guard, a multi-type property with a format validator and atransforming filter would call
FormatValidatorFromRegEx::validate(int)under strict typeswhen an already-transformed integer was passed, throwing
TypeErrorinstead of the expectedpost-transform validation exception.
Phase 6 - Test quality
ReflectionClassworkaround intestAllOfPropertyWithMixedAcceptTransformingFilterwith three direct runtime assertions: valid string input -> transform ->
DateTime;integer input ->
AllOfException; already-constructedDateTime-> passthrough.Phase 7 - Property-level composition type semantics and documentation
allOfon a property now uses intersection semantics: conflicting branch types throwSchemaException;int|numberresolves tointvia the integer-subtype-of-number rule,centralised in the new
TypeIntersection::computeutility.anyOf/oneOfwith a truly untyped branch ({}) correctly leaves the property asmixedinstead of wrongly adding
nullable=true.if/then/elsegains type widening (union ofthen+elsetypes when theparent has no declared type) and conflict detection (
SchemaExceptionwhen the parent typeis incompatible with both branches).
TypeIntersection::computeextracted as a standalonePHPModelGenerator\Utilsutility.filter.rstupdated with a new "Composition with transforming filters" section coveringbranch classification, accepted and rejected combinations, and correct
typekeyword usage.CompositionBranchClassifier:typekeyword now always classifies asInput-- itvalidates raw JSON input and must never be routed against output types.
Fix - if/then/else branch conflict detection
IfValidatorFactorypreviously required both branches to conflict with the parent beforethrowing, and stripped
nullfrom parent type names -- so a null-typed branch against anon-null parent was silently accepted.
isNullable()is included in the parent set.getBranchTypeNames()distinguishes null-typed branches ({type: null}) from truly untypedbranches via
getTypeHint(), so null-typed branches correctly intersect (or conflict) withthe parent type.
Test suite refactor - FilterTest split
Basic/FilterTest(~3050 lines) is replaced by eight focused classes undertests/Filter/, each covering one concern: configuration, built-in filters, custom filters,transforming filters, type compatibility, filter chains, static composition checks, and
runtime composition ordering.
Test plan
./vendor/bin/phpunit-- full suite green./vendor/bin/phpunit tests/Filter/-- all eight focused filter test classes pass./vendor/bin/phpunit tests/ComposedValue/-- composition tests unaffectedSchemaExceptionfor all rejected filter+compositioncombinations enumerated in
rejectedCompositionProvidercomposition types in
FilterCompositionRuntimeTestif/then/elsenull-typed branch conflict detection tested for all new edge casesGenerated with Claude Code