Skip to content

Keep the declaration of the main schema for properties of dependency schemas - #197

Merged
wol-soft merged 4 commits into
masterfrom
claude/dependency-schema-properties-keep-main-declaration
Oct 21, 2026
Merged

wol-soft merged 4 commits into
masterfrom
claude/dependency-schema-properties-keep-main-declaration

Conversation

@wol-soft

@wol-soft wol-soft commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

Found while checking how filters in schema dependencies behave (follow-up of #193); the bug exists on master.

The properties of a schema dependency ("dependencies": {"credit_card": {"properties": {...}}}) are transferred to the base schema as untyped, unvalidated copies. The transfer happens while the properties of the base schema are processed. The declaration which is registered first wins, so if the main schema declares the same property after the property that carries the dependency, the copy of the dependency schema silently replaces it: type, validators and filters of the main declaration are lost (and the getter becomes mixed). The result depended on the key order of properties:

{ "type": "object",
  "properties": {
    "credit_card": { "type": "integer" },
    "billing_address": { "type": "string", "minLength": 3, "filter": "trim" }
  },
  "dependencies": { "credit_card": { "properties": { "billing_address": { "type": "string" } },
                                      "required": ["billing_address"] } } }

With credit_card first, new Model(['billing_address' => ' ab ']) was accepted and the value was not trimmed. With billing_address first the declaration was kept.

Filters in dependency schemas

The behaviour for filters in a dependency schema is already consistent with the condition of an if (see #193): the filter runs inside the dependency schema, so the validators of the property see the filtered value and a failing filter fails the dependency, but the base model returns the value as provided (the schema dependency docs state that the transferred properties don't validate). No rejection is needed: unlike the anyOf case nothing is applied ambiguously, as the transferred copy never carries a filter. This PR documents the rule and pins it with a test. A property which should be returned filtered has to be declared (with the filter) in the main schema, which now works independent of the property order.

Changes

The precedence between a declared and a transferred property is decided by PropertyMerger, the generic mechanism Schema::addProperty() already uses when a name is registered twice:

  • PropertyMerger::merge() gets the parameter $incomingIsRoot and now returns whether $incoming replaces $existing. A property which the schema declares itself takes precedence over a property which was only transferred from a composition branch or a dependency schema ($isRootRegistered is false), independent of the registration order. The opposite order was already covered by guardRootPrecedence() (root wins over branches). Existing callers ignore the return value.
  • Schema::addProperty() lets the incoming property take over the slot when the merger says so (replaceRegisteredProperty()). The slot keeps its position, so the generated class and the serialization order don't change. The onAllPropertiesResolved bookkeeping is adjusted for a replacement which is not resolved yet ($ref).
  • PropertyDependencyTrait registers the copies as transferred properties (SchemaDependencyValidator as the transferring component) instead of as properties of the base schema; this is what made them win before.
  • Docs: note in the schema dependencies section of object.rst.

Design decisions

  • First version of this PR (not kept): a Schema-internal list of dependency copies which addProperty() replaced before the merger. It bypassed PropertyMerger and only covered dependency schemas; the precedence rule belongs next to guardRootPrecedence().
  • Rejected: merging the declared property into the transferred copy inside the merger. The merger only adjusts types, validators and decorators of the declared property would stay on the incoming property object, so the slot has to hold the incoming property.
  • Rejected: transferring the dependency properties after all properties of the base schema are registered. Simpler, but the transferred properties move to the end of the class and of the serialized output; GeneratedCodeFormattingTest pins the current position and I did not want to change the order for existing users.
  • Rejected: rejecting filters inside a dependency schema with a SchemaException. The filter has a defined effect (it runs as part of the validation) and nothing is dropped silently, unlike the filtered properties of an anyOf branch.

Test plan

  • SchemaDependencyTest::testPropertyOfTheMainSchemaWinsOverTheDeclarationOfADependencySchema (both property orders; fails on master for the order that lost the declaration) and testFilterInDependencySchemaRunsInsideTheDependencySchemaOnly.
  • SchemaPropertyPrecedenceTest (unit): a declared property replaces a transferred one and keeps its position, a transferred property doesn't replace a declared one, the resolution bookkeeping with an unresolved replacement (mutation-checked).
  • Full suite on PHP 8.4.26 in per-directory chunks: green except the 4 ReferencePropertyTest cases which fetch raw.githubusercontent.com (blocked in the sandbox). phpcs clean on the changed files. Also probed a $ref main declaration after the dependant property.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs

…schemas

The properties of a schema dependency were transferred to the base schema
while the properties were processed. A property which the dependency schema
redeclares and which the main schema declares after the dependant property
lost its declaration (type, validators, filters) to the unvalidated copy of
the dependency schema. The transfer now happens after the properties of the
base schema are registered.

Document that filters of a dependency schema run inside the dependency
schema only and pin it with a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Replace the deferred transfer by a replaceable registration: the copy of a
property of a dependency schema keeps its place in the property order (the
generated class and the serialization list it where the dependency schema
transferred it) and is replaced in place by the declaration of the base
schema if that is registered later.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
@coveralls

coveralls commented Oct 14, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39629000301

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.04%) to 98.932%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 25 of 25 lines across 3 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 8051
Covered Lines: 7965
Line Coverage: 98.93%
Coverage Strength: 1665.87 hits per line

💛 - Coveralls

…rtyMerger

Schema::addProperty special-cased the copies of dependency schemas before
the merger. The precedence rule is now part of PropertyMerger::merge(): a
property which the schema declares itself replaces a property which was only
transferred from a composition branch or a dependency schema, independent of
the registration order (merge() returns whether the incoming property
replaces the existing one). The dependency copies are registered as
transferred properties instead of being tracked in a separate list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
@wol-soft
wol-soft merged commit 2ed812d into master Oct 21, 2026
4 checks passed
@wol-soft
wol-soft deleted the claude/dependency-schema-properties-keep-main-declaration branch October 21, 2026 15:49
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.

3 participants