fix: Merge $ref sibling nullable with OR so 3.0 nullable targets keep null (#64) - #65
Open
shadowhand wants to merge 1 commit into
Open
fix: Merge $ref sibling nullable with OR so 3.0 nullable targets keep null (#64)#65shadowhand wants to merge 1 commit into
nullable with OR so 3.0 nullable targets keep null (#64)#65shadowhand wants to merge 1 commit into
Conversation
…ep null (duyler#64) `ScalarSiblingMerger::merge()` combined the `$ref` stub's `nullable` flag with the resolved target's using a logical AND. `Schema::$nullable` defaults to `false` and a bare `{$ref: ...}` node never sets it, so every plain `$ref` evaluated `false && true` and discarded the target's `nullable: true`. The resolved schema kept its `type: string` but lost its nullability, and `TypeValidator` then rejected a legitimate `null`. The flag now merges with OR. Per OpenAPI 3.0 a `nullable` sibling next to `$ref` can only widen the target — there is no spelling for "narrow this to non-nullable" — which also makes `nullable` consistent with how the same class merges every other scalar ("sibling wins if set, otherwise the resolved value"). Three existing tests asserted the AND behaviour and were updated to the widening semantics; the sibling-false cases now document that `nullable: false` is indistinguishable from an omitted flag and so cannot remove the target's nullability.
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.
Fixes #64.
The bug
ScalarSiblingMerger::merge()combined the$refstub'snullableflag with the resolved target's using a logical AND:Schema::$nullabledefaults tofalseand a bare{$ref: ...}node never sets it, so every plain$refevaluatedfalse && trueand discarded the target'snullable: true. The resolved schema kept itstype: stringbut lost its nullability, andTypeValidatorthen rejected a legitimatenull— making a very common OpenAPI 3.0 arrangement unusable:The fix
One line — the flag now merges with OR. Per OpenAPI 3.0 a
nullablesibling next to$refcan only widen the target; there is no way to spell "narrow this to non-nullable", sonullable: falseis indistinguishable from an omitted flag and must never remove the target's nullability. This also makesnullableconsistent with how the same class merges every other scalar (mergeFormat,mergeNullableIdentical: "sibling wins if set, otherwise the resolved value").ScalarSiblingMergeris the only merge implementation —Schema::withSibling()andBoundSiblingMergerboth route through it, and no compiled path carries its own copy — so this is the single fix point.A second manifestation, not in the issue report
The issue notes that restating
nullable: trueat the referring site works around the bug. That only holds when the target is also nullable. Anullable: truesibling against a non-nullable target evaluatedtrue && falseand was likewise erased, so the sibling could not widen anything. Both cases are covered by the new tests.Heads-up: this reverses a documented decision
The AND was deliberate — the 0.7.0 changelog records it as
SchemaSiblingMerger::merge()aligningnullablewith "§8.2.3 ALL OF semantics (SPEC-02B)". That reading is defensible for a realallOf, but it cannot survive afalsedefault: with no way to distinguish "unset" from "explicitly false", AND makes the dominant case — a bare$ref— unusable.Three existing tests asserted the AND behaviour and are updated to the widening semantics (
ScalarSiblingMergerTest,SchemaSiblingMergerTest,PostRefactorBehavioralSnapshotTest). The sibling-false cases now document whynullable: falsecannot narrow, rather than simply flipping the expectation.I also considered making
Schema::$nullabletri-state (?bool) so "omitted" and "explicitly false" become distinguishable, which would let AND work honestly. I rejected it: it touches the 56-parameter constructor and every validator call site, and buys nothing, since OpenAPI 3.0 has no narrowing semantics for anullablesibling. Happy to take that route instead if you would rather keep intersection semantics.Tests
Written test-first; both new tests were watched failing against the AND before the fix landed.
tests/Integration/NullableRefSiblingTest.php(new) — the issue's four cases (inline nullable, bare$ref,$refrestatingnullable, 3.1type: [string, 'null']union) plus three guards: a nullable sibling widening a non-nullable target, a bare$refto a non-nullable target still rejectingnull, and a bare$refto a nullable target still rejecting a wrong-typed value.tests/Unit/Schema/Model/Internal/ScalarSiblingMergerTest.php— merge-level coverage for all fournullablecombinations, including that neither side nullable still yieldsfalse.Verification
vendor/bin/phpunit— 7140 tests, 14726 assertions, all pass. (The 2 deprecation notices are pre-existing and from unrelated tests.)vendor/bin/psalm— no errors.vendor/bin/php-cs-fixer fix --dry-run— 0 of 878 files need fixing.vendor/bin/rector --dry-run— clean.PASSfor all four schemas; stashing only the one-line change reproducesRefProp Schema validation failed.No inline comments were added to
src/per the current convention; the rationale lives in the changelog entry and the tests. Glad to add a// §N exemption:-style note at the merge site if you would prefer the reasoning to sit next to the code.