Skip to content

Filter composition ordering#128

Open
wol-soft wants to merge 12 commits into
masterfrom
filter-composition-ordering
Open

Filter composition ordering#128
wol-soft wants to merge 12 commits into
masterfrom
filter-composition-ordering

Conversation

@wol-soft
Copy link
Copy Markdown
Owner

@wol-soft wol-soft commented May 15, 2026

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 a
composition keyword (e.g. allOf, anyOf, if/then/else) would run all validators
post-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 the
    Draft type names whose modifier/factory list carries it, enabling type-space lookup without
    hard-coded keyword tables.
  • AbstractValidatorFactory::getKey(): ?string exposes the source keyword stored on each
    validator factory, providing the backlink needed to trace a live validator to its origin.
  • TypeSpace enum (Input / Output / Mixed / Empty) as the 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;
    defaults ambiguous branches to Input (conservative policy).

Phase 2 - Static compatibility checking

  • CompositionCompatibilityChecker with two responsibilities:
    • Validates that composition keywords on a filtered property do not create unresolvable
      type-space conflicts (allOf Mixed branch, cross-space anyOf/oneOf branches,
      Mixed not, cross-space if/then/else sub-schemas).
    • Enforces that root-level composition branches do not constrain the filtered sub-property
      with output-type-space constraints (R-4).
  • checkForFilterInBranches added to AbstractCompositionValidatorFactory to reject the
    filter keyword inside any composition branch (R-7) universally across all five factories.

Phase 3 - Validator priority reassignment

  • Validator gains a sourceKey field so any validator wrapper can be traced back to its
    originating JSON Schema keyword.
  • PropertyFactory.applyModifiers() tags newly-added validators with the factory's schema key
    immediately after each AbstractValidatorFactory runs.
  • FilterProcessor::reassignValidatorPriorities() moves input-space validators (those whose
    source 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

  • Composition validators are split around the transforming filter boundary: input-space
    branches run pre-transform; output-space branches run post-transform.
  • 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
    (already-transformed passthrough).
  • ComposedPropertyValidator gains createSubsetValidator() to split a mixed-space allOf
    into a pre-filter input-subset and a post-filter output-subset.
  • FilterProcessor decomposed into three focused private methods
    (classifyValidatorAdjustments, wrapInputSpaceGuards, splitMixedSpaceAllOf).

Phase 5 - FormatValidator type guard

  • 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 this guard, a multi-type property with 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.

Phase 6 - Test quality

  • Replaced a ReflectionClass workaround in testAllOfPropertyWithMixedAcceptTransformingFilter
    with three direct runtime assertions: valid string input -> transform -> DateTime;
    integer input -> AllOfException; already-constructed DateTime -> passthrough.

Phase 7 - Property-level composition type semantics and documentation

  • 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,
    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).
  • TypeIntersection::compute extracted as a standalone PHPModelGenerator\Utils utility.
  • filter.rst updated with a new "Composition with transforming filters" section covering
    branch classification, accepted and rejected combinations, and correct type keyword usage.
  • CompositionBranchClassifier: type keyword now always classifies as Input -- it
    validates raw JSON input and must never be routed against output types.

Fix - if/then/else branch conflict detection

  • IfValidatorFactory previously required both 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.
  • Each branch is now checked independently; 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.

Test suite refactor - FilterTest split

  • The monolithic Basic/FilterTest (~3050 lines) is replaced by eight focused classes under
    tests/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 unaffected
  • Static rejection tests confirm SchemaException for all rejected filter+composition
    combinations enumerated in rejectedCompositionProvider
  • Runtime ordering tests confirm pre/post-transform validator sequencing for all
    composition types in FilterCompositionRuntimeTest
  • if/then/else null-typed branch conflict detection tested for all new edge cases

Generated with Claude Code

wol-soft and others added 10 commits April 20, 2026 09:51
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>
@coveralls
Copy link
Copy Markdown

coveralls commented May 15, 2026

Coverage Report for CI Build 26169686326

Coverage decreased (-0.07%) to 98.483%

Details

  • Coverage decreased (-0.07%) from the base build.
  • Patch coverage: 19 uncovered changes across 6 files (771 of 790 lines covered, 97.59%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/PropertyProcessor/Filter/FilterProcessor.php 220 212 96.36%
src/PropertyProcessor/Filter/CompositionCompatibilityChecker.php 152 146 96.05%
src/Model/Property/PropertyProxy.php 2 0 0.0%
src/Model/Validator/Factory/Composition/IfValidatorFactory.php 69 68 98.55%
src/Model/Validator/FilterValidator.php 44 43 97.73%
src/PropertyProcessor/Filter/CompositionBranchClassifier.php 90 89 98.89%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 5538
Covered Lines: 5454
Line Coverage: 98.48%
Coverage Strength: 594.2 hits per line

💛 - Coveralls

wol-soft and others added 2 commits May 20, 2026 12:28
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants