Skip to content

fix: Merge $ref sibling nullable with OR so 3.0 nullable targets keep null (#64) - #65

Open
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/64-nullable-ref-sibling-merge
Open

fix: Merge $ref sibling nullable with OR so 3.0 nullable targets keep null (#64)#65
shadowhand wants to merge 1 commit into
duyler:mainfrom
shadowhand:fix/64-nullable-ref-sibling-merge

Conversation

@shadowhand

Copy link
Copy Markdown

Fixes #64.

The bug

ScalarSiblingMerger::merge() combined the $ref stub's nullable flag with the resolved target's using a logical AND:

'nullable' => $sibling->nullable && $resolved->nullable,

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 — making a very common OpenAPI 3.0 arrangement unusable:

NullableString:
  type: string
  nullable: true
Thing:
  type: object
  properties:
    p:
      $ref: '#/components/schemas/NullableString'   # null was rejected

The fix

One line — the flag now merges with OR. Per OpenAPI 3.0 a nullable sibling next to $ref can only widen the target; there is no way to spell "narrow this to non-nullable", so nullable: false is indistinguishable from an omitted flag and must never remove the target's nullability. This also makes nullable consistent with how the same class merges every other scalar (mergeFormat, mergeNullableIdentical: "sibling wins if set, otherwise the resolved value").

ScalarSiblingMerger is the only merge implementation — Schema::withSibling() and BoundSiblingMerger both 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: true at the referring site works around the bug. That only holds when the target is also nullable. A nullable: true sibling against a non-nullable target evaluated true && false and 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() aligning nullable with "§8.2.3 ALL OF semantics (SPEC-02B)". That reading is defensible for a real allOf, but it cannot survive a false default: 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 why nullable: false cannot narrow, rather than simply flipping the expectation.

I also considered making Schema::$nullable tri-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 a nullable sibling. 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, $ref restating nullable, 3.1 type: [string, 'null'] union) plus three guards: a nullable sibling widening a non-nullable target, a bare $ref to a non-nullable target still rejecting null, and a bare $ref to a nullable target still rejecting a wrong-typed value.
  • tests/Unit/Schema/Model/Internal/ScalarSiblingMergerTest.php — merge-level coverage for all four nullable combinations, including that neither side nullable still yields false.

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.
  • The issue's verbatim reproduction script prints PASS for all four schemas; stashing only the one-line change reproduces RefProp 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.

…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.
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.

OpenAPI 3.0 nullable is discarded when a schema is reached through $ref (sibling merge uses AND)

1 participant