Skip to content

fix(compilers/openapi): honor every null spelling at $ref sites - #101

Merged
OmarAlJarrah merged 5 commits into
mainfrom
fix/openapi-31-null-at-ref-sites
Jul 27, 2026
Merged

fix(compilers/openapi): honor every null spelling at $ref sites#101
OmarAlJarrah merged 5 commits into
mainfrom
fix/openapi-31-null-at-ref-sites

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

OpenAPI lowering normalizes "admits null" onto the enclosing TypeRef.Nullable bit rather than into
the type node (ir-design.md §3.3, invariant 8). Three source spellings mean null:

{type: string, nullable: true}          # 3.0
{type: [string, "null"]}                # 3.1
{oneOf: [{type: string}, {type: null}]} # either

refNullable — the function that computes that bit at a $ref use site — recognized only the first.
The other two were silently dropped at every reference, with no diagnostic, so generated SDKs would
treat such fields as non-nullable.

The bit is lost rather than merely relocated. A $ref target interned at its own TypeID (a model, a
union) has the TypeRef its definition site produced discarded by lowerComponentSchema
(if owned { return }), so nothing downstream can recover it. For a multi-branch union the loss is
total: oneOf: [string, integer, "null"] strips the null branch out of the variants and drops the
enclosing bit, so the IR ends up with no record that the type admits null at all — an invariant-2
(lossless by default) violation.

The fix introduces schemaAdmitsNull and routes both the reference site and its resolved target
through it, alongside the union lowering site in compose.go that already computed the same thing
inline — so the two places that produce a Nullable bit can no longer drift apart.

A null branch counts only where the union is the type. Structural siblings intersect with it (JSON
Schema conjoins keywords), so {type: object, oneOf: [{type: string}, {type: null}]} admits neither
string nor null, and {enum: [open, closed], oneOf: [{type: string}, {type: null}]} cannot be null at
all. schemaBody already takes that view inline — it reads nullability with schemaHasNull on the
union-siblings path and preserves the union verbatim under Extensions — so the $ref site now
matches it rather than over-reporting.

Measured before/after, with the 3.0 spelling for contrast:

Case 3.0 before after
direct $ref to a nullable object component
chained $ref (Owner.p → Mid → Base)
$ref to a nullable non-component sub-schema
nullable array / scalar component via $ref
ref-site sibling nullability
$ref to a collapsed oneOf: [X, null] component
$ref to a multi-branch union with a null branch
$ref to an anyOf: [X, null] component
$ref to a null branch intersected by structural siblings non-null non-null

Chain-following is the resolver library's (GetResolvedSchema) and was already exercised by the 3.0
path, so this adds no new recursion and needs no new bound. effectiveTypes, lowerComponentSchema
and the composition lowering are untouched.

Also in this PR

Two cleanups the fix argued for, neither changing behaviour:

  • schemaBody computed its Nullable bit with schemaHasNull while the $ref site and the union
    lowering used schemaAdmitsNull. The two agree — the sibling guard switches the null-branch term off
    on the union-siblings path, and the plain path has no branches to weigh — so every golden in the
    corpus stays byte-identical. Routing all of them through one predicate is what removes the failure
    mode this PR is about: a site computing nullability its own way is how a spelling gets honoured in
    one position and dropped in another.
  • The whitebox tests hand-wrote the t/openapi/components/schemas/ prefix at 49 sites; they now build
    IDs with a componentID helper, and the conformance suite's two hand-rolled property-by-wire-name
    loops use the propsByWire helper the whitebox package already had.

Test plan

  • TestSchema_RefNullableAcrossSpellings: 18 table cases covering all three spellings across direct,
    chained, sub-schema, array/scalar/union and ref-site-sibling shapes, each with a negative control
    (plain target, non-null ref-site type array, union with no null branch, union intersected by a
    structural body, enum with a null-branch sibling). Every case asserts the resolved target as well as
    the bit. Verified load-bearing by reverting each production change in turn: the widening's cases fail
    without it, the intersection guard's negative controls fail without it, and every 3.0 case passes
    throughout.
  • TestSchema_RefNullableMatchesInlineForUnionSiblings builds one body string and places it at both a
    $ref target and an inline property, pinning that the two spellings agree — the specific way a
    ref-site recomputation can drift from the inline rule.
  • TestSchema_RefNullableAtNonPropertyPosition covers a $ref used as a list element, since the bit
    has to reach every schema position, not just model properties.
  • Conformance corpus case nullable-31-ref covers both the type-array and union spellings, and pins
    that the union keeps exactly its two non-null variants — the null branch lifts to the ref rather
    than becoming a variant.
  • No pre-existing golden changed; no other corpus spec uses a null union branch or $refs a
    null-type-array component.
  • gofmt -l, go build ./..., go vet ./..., golangci-lint run (0 issues), go test ./... clean.

Closes #28

A component that spells nullability the 3.1 way (`type: [object, "null"]`)
lost that bit at every `$ref` use site: the referencing `TypeRef.Nullable`
came out false, while the equivalent 3.0 spelling (`nullable: true`) came
out true. Generated SDKs would treat such fields as non-nullable.

refNullable inspected only the 3.0 `nullable` keyword on the reference site
and on the resolved target. Under 3.1 the null member is stripped into the
type node by effectiveTypes, so the bit is computed only on the TypeRef
returned at the definition site — which lowerComponentSchema discards for
interned components. Use schemaHasNull on both sites instead, the same
dialect-agnostic helper the rest of the lowering already uses.

The gap was wider than a direct ref: chained refs, refs to non-component
sub-schemas, and nullable array/scalar components all dropped the bit under
3.1 while working under 3.0. All of them are now covered by tests, along
with a conformance corpus case pinning the ref-site behaviour.

Closes #28
A $ref use site has to recompute nullability, because a target interned at its
own TypeID has the TypeRef its definition produced discarded by
lowerComponentSchema. That recomputation understood only the two keyword
spellings, so a target spelling nullability as a union null branch lost the bit
at every reference. For a multi-branch union the loss is total: the null branch
is stripped out of the variants and the enclosing bit is dropped, so nothing in
the IR records that the type admits null.

Add schemaAdmitsNull and route both the reference site and the union lowering
through it, so the two places that compute a Nullable bit cannot drift apart. A
null branch counts only where the union is the type itself; structural siblings
intersect with it, so {type: object, oneOf: [string, null]} admits neither
string nor null — the same view schemaBody already takes inline, which keeps a
$ref and an inline copy of one body from disagreeing.

Harden the ref-nullability tests alongside: assert the resolved target on every
case, add a negative control per shape, pin ref/inline agreement, and cover a
non-property (list element) position.
@OmarAlJarrah OmarAlJarrah changed the title fix(compilers/openapi): honor 3.1 type-array null at $ref sites fix(compilers/openapi): honor every null spelling at $ref sites Jul 27, 2026
schemaAdmitsNull ran hasUnionSiblings before oneOfAnyOfHasNull. The former ends
in len(effectiveTypes(s)) > 0, and effectiveTypes allocates a slice only to take
its length, so every $ref site paid that allocation twice even though almost no
schema carries a union at all. Swapping the operands is behaviour-identical —
both predicates are pure — and reaches hasUnionSiblings only when a null branch
actually exists, which is also the only case its own doc comment describes.
…redicate

schemaBody read nullability with schemaHasNull while the $ref site and the union
lowering used schemaAdmitsNull. The two agree — on the union-siblings path the
null-branch term is switched off by the sibling guard, and on the plain path
there are no branches to weigh — so this changes no behaviour, and every golden
in the corpus is byte-identical.

It is the disagreement itself that is worth removing: nullability is spelled
three ways and lifted onto the TypeRef by four different call sites, so a site
computing it with its own predicate is how a spelling comes to be honoured in
one position and dropped in another. One predicate leaves nowhere for that to
happen, and a fourth spelling would land in a single function.
The whitebox tests hand-wrote the "t/openapi/components/schemas/" prefix at 49
sites, so a change to component ID derivation would have to be chased across
three files by hand. Route them through componentID, and fold the two
hand-rolled property-by-wire-name loops in the conformance suite into the
propsByWire helper the whitebox package already had.
@OmarAlJarrah
OmarAlJarrah merged commit 78276bd into main Jul 27, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-31-null-at-ref-sites branch July 27, 2026 16:11
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.1 type: [T, "null"] nullability is lost at every $ref site

1 participant