Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ func conformanceCases() []conformanceCase {
{"nullable-31-ref", assertNullable31Ref, []string{"optionality-vs-nullability"}},
{"nullable-enum-31", assertNullableEnum31, []string{"optionality-vs-nullability", "enums-string"}},
{"nullability-conjunction", assertNullabilityConjunction, []string{"optionality-vs-nullability"}},
{"null-only-union", assertNullOnlyUnion, []string{"optionality-vs-nullability"}},
{"defaults", assertDefaults, []string{"defaults"}},
{"yaml-timestamp-scalars", assertYAMLTimestampScalars, []string{"defaults", "literal-types"}},
{"constraints", assertConstraints, []string{"constraints"}},
Expand Down
61 changes: 61 additions & 0 deletions compilers/openapi/conformance_unmodeled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,67 @@ func assertKeptRaw(t *testing.T, p ir.Unmodeled, key, want string) {
assert.JSONEq(t, want, string(entry.Value))
}

// assertNullOnlyUnion covers a oneOf/anyOf whose only branches are a bare
// `type: null` schema — one branch, two, either combinator, and both
// combinators declared at once with the elected one null-only. nullUnionCollapse
// has no non-null branch to collapse a set like this onto, so without a guard
// for this shape lowerOneOfAnyOf's buildUnion fallback strips every branch as a
// null marker and interns a Union with none left, the empty value space
// irverify's ir/union-no-variants rule rejects (GitHub #416). The branch set
// admits exactly what a bare `{type: null}` schema at the same position admits
// — null alone — so this position lowers the same way: the shared `any`
// primitive with Nullable set, and the combinator(s) that produced it are kept
// verbatim under Unmodeled since none carries a shape the Scalar's fields could
// hold.
func assertNullOnlyUnion(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) {
cases := []struct {
name string
key string
raw string
}{
{"AnyOfNull", "openapi:anyOf", `[{"type":"null"}]`},
{"OneOfNull", "openapi:oneOf", `[{"type":"null"}]`},
{"AnyOfTwoNull", "openapi:anyOf", `[{"type":"null"},{"type":"null"}]`},
}
for _, tc := range cases {
s := assertNullOnlyScalar(t, doc, tc.name)
assertKeptRaw(t, s.Unmodeled, tc.key, tc.raw)
assert.Equal(t, []ir.Severity{ir.SeverityInfo},
diagsAt(diags, "openapi/degraded-construct", "/components/schemas/"+tc.name),
"%s announces the degraded lowering", tc.name)
}

// BothNull declares oneOf and anyOf at once, each with its own null-only
// branch. unionBranches elects oneOf, so lowerNullOnlyUnion is reached the
// same way as the single-combinator cases above, but buildUnion is never
// called at all here — which is what sidesteps preserveUnusedCombinator,
// the union path's usual way of keeping a passed-over combinator. Both
// keywords must therefore come from preserveBranchSets's own loop over
// both, not from that mechanism.
both := assertNullOnlyScalar(t, doc, "BothNull")
assertKeptRaw(t, both.Unmodeled, "openapi:oneOf", `[{"type":"null"}]`)
assertKeptRaw(t, both.Unmodeled, "openapi:anyOf", `[{"type":"null"}]`)
assert.Equal(t, []ir.Severity{ir.SeverityInfo},
diagsAt(diags, "openapi/degraded-construct", "/components/schemas/BothNull"),
"BothNull announces the degraded lowering once, for both kept keywords")
}

// assertNullOnlyScalar requires doc.Types[name] to be the scalar over `any`
// with Nullable set that a null-only union — and a bare `{type: null}` schema
// at the same position — both lower to.
func assertNullOnlyScalar(t *testing.T, doc *ir.Document, name string) *ir.Scalar {
t.Helper()
s, ok := doc.Types[namedID(name)].(*ir.Scalar)
require.True(t, ok, "%s lowers to a scalar over any, the same node a bare "+
"`{type: null}` schema at this position would", name)
require.NotNil(t, s.Base)
assert.Equal(t, ir.TypeID("t/prim/any"), s.Base.Target)
assert.True(t, s.Base.Nullable,
"%s: the null-only branch set lifts onto the reference the way a bare "+
"{type: null} schema's does", name)
return s
}

// assertCoDeclaredSchemaContent covers the election at the other pair of
// positions: a parameter and a header may state their type as `schema` or as
// `content`, and OpenAPI forbids both. `content` is elected at both — it names a
Expand Down
1 change: 1 addition & 0 deletions compilers/openapi/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ func schemaSeeds() []string {
`{"allOf":[{"type":"object"},{"type":"object"}]}`,
`{"oneOf":[{"type":"string"},{"type":"integer"}]}`,
`{"anyOf":[{"type":"boolean"},{"type":"null"}]}`,
`{"anyOf":[{"type":"null"}]}`,
`{"enum":["a","b",1,null]}`,
`{"const":"v1"}`,
`{"type":["string","null"]}`,
Expand Down
55 changes: 52 additions & 3 deletions compilers/openapi/internal/schema/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -537,15 +537,20 @@ func unvisitedRefTargets(s *oas3.Schema, visited map[*oas3.Schema]bool) []*oas3.
}

// lowerOneOfAnyOf lowers a oneOf/anyOf schema. A two-variant {X, null} set
// collapses to nullable X (ir-design §3.3); everything else becomes a Union
// with one Variant per branch (oneOf exclusive, anyOf not), never collapsing a
// union into optional fields.
// collapses to nullable X (ir-design §3.3); a set with no non-null branch at
// all (every branch a bare `type: null`) has no X for that collapse to name,
// so it lowers to nullable `any` instead (lowerNullOnlyUnion, GitHub #416);
// everything else becomes a Union with one Variant per branch (oneOf
// exclusive, anyOf not), never collapsing a union into optional fields.
func lowerOneOfAnyOf(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, pointer, hint string) (ir.TypeRef, []ir.Diagnostic) {
if inner, ip, ih, ok := nullUnionCollapse(s, pointer); ok {
ref, diags := Ref(c, ts, anchors, depth, inner, ip, ih)
ref.Nullable = true
return ref, diags
}
if allNullUnion(s) {
return lowerNullOnlyUnion(c, ts, s, pointer, hint)
}
var diags []ir.Diagnostic
tid := internNode(c, ts, pointer, hint, func(common ir.TypeCommon) ir.TypeDef {
def, unionDiags := buildUnion(c, ts, s, common, pointer,
Expand All @@ -560,6 +565,50 @@ func lowerOneOfAnyOf(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, de
return ir.TypeRef{Target: tid, Nullable: schemaAdmitsNull(s)}, diags
}

// lowerNullOnlyUnion lowers a oneOf/anyOf every one of whose branches is a bare
// `type: null` schema (allNullUnion). One null branch or several admit exactly
// the value a bare `{type: null}` schema at this position admits — null alone
// — so this position lowers the way lowerUntyped's own null-only body does:
// the shared `any` primitive with Nullable set. The combinator that produced
// it, and the other one too when both are declared, carries no shape a Union
// or a Scalar's fields could hold, so it is kept verbatim under Unmodeled
// instead of dropped (GitHub #416; ir-design §4.8).
func lowerNullOnlyUnion(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, pointer, hint string) (ir.TypeRef, []ir.Diagnostic) {
// inner is the shared `any` primitive: no schema pointer ever interns at
// its ID, so this position never already owns it. Hoist an alias
// unconditionally, rather than testing for a case that cannot occur, so
// the preserved union attaches to a node this pointer owns and not to the
// primitive every other unrelated position also resolves to.
inner := ts.PrimID(ir.PrimAny)
var kept ir.Unmodeled
cons, diags := schemaConstraints(c, &kept, s, pointer)
owner := internAlias(c, ts, pointer, hint, ir.TypeRef{Target: inner, Nullable: true}, cons, kept)
return ir.TypeRef{Target: owner, Nullable: true}, append(diags, preserveNullOnlyUnion(c, ts, owner, s, pointer)...)
}

// preserveNullOnlyUnion keeps s's oneOf/anyOf verbatim under the owning node's
// Unmodeled: lowerNullOnlyUnion's node is the shared `any` primitive or an
// alias over it, neither of which carries a branch set of its own to hold
// them in.
func preserveNullOnlyUnion(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *oas3.Schema, pointer string) []ir.Diagnostic {
td, ok, diags := registeredNode(c, ts, id, pointer)
if !ok {
return diags
}
common := td.Common()
kept, keepDiags := preserveBranchSets(c, &common.Unmodeled, s, ir.ReasonDegradedLowering, pointer)
diags = append(diags, keepDiags...)
if len(kept) == 0 {
return diags
}
// Both keywords land here when both are declared: buildUnion, and its
// preserveUnusedCombinator, are never reached from this arm, so this is the
// only place either combinator gets a home.
return append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer,
"every branch admits only the null value, so this position lowered as nullable any; "+
"%s kept verbatim under Unmodeled", strings.Join(kept, " and ")))
}

// unionLowering names how a oneOf/anyOf co-declared with structural keywords is
// lowered.
type unionLowering int
Expand Down
42 changes: 42 additions & 0 deletions compilers/openapi/internal/schema/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,48 @@ func TestOneOf_NullVariantCollapses(t *testing.T) {
}
}

// TestOneOfAnyOf_NullOnlyLowersToNullableAny covers a oneOf/anyOf whose every
// branch is a bare `type: null` schema (GitHub #416). nullUnionCollapse has no
// non-null branch to collapse a set like this onto, so without a guard for the
// shape, buildUnion's null-branch strip leaves nothing behind it and interns a
// Union with no variants — the empty value space irverify's
// ir/union-no-variants rule rejects. This position instead lowers the way a
// bare `{type: null}` schema at it would: the shared `any` primitive with
// Nullable set, with the combinator that produced it kept verbatim under
// Unmodeled since it has no shape a Scalar's fields could hold.
func TestOneOfAnyOf_NullOnlyLowersToNullableAny(t *testing.T) {
t.Parallel()
spec := openapitest.ComponentSpec(` AnyOfNull:
anyOf: [{type: "null"}]
OneOfNull:
oneOf: [{type: "null"}]
AnyOfTwoNull:
anyOf: [{type: "null"}, {type: "null"}]
`)
doc, diags := lowerSpec(t, spec)
openapitest.RequireNoErrorDiags(t, diags)

for _, tc := range []struct{ name, key string }{
{"AnyOfNull", "openapi:anyOf"},
{"OneOfNull", "openapi:oneOf"},
{"AnyOfTwoNull", "openapi:anyOf"},
} {
s, ok := doc.Types[componentID(tc.name)].(*ir.Scalar)
require.True(t, ok, "%s lowers to a scalar over any, the same node a bare "+
"`{type: null}` schema at this position would", tc.name)
require.NotNil(t, s.Base)
assert.Equal(t, ir.TypeID("t/prim/any"), s.Base.Target)
assert.True(t, s.Base.Nullable, "%s: the null-only branch set lifts onto the reference", tc.name)
entry, ok := s.Unmodeled[tc.key]
require.True(t, ok, "%s: the union is kept verbatim beside the approximation", tc.name)
assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason)
}
for id, def := range doc.Types {
_, isUnion := def.(*ir.Union)
assert.False(t, isUnion, "a null-only branch set must not produce a union node: %s", id)
}
}

func TestOneOf_ThreeVariantsWithNullStripsNullLiftsNullable(t *testing.T) {
t.Parallel()
// A oneOf with two non-null branches plus a null branch stays a Union of the
Expand Down
54 changes: 47 additions & 7 deletions compilers/openapi/internal/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,29 @@ func preserveUnionSiblings(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *o
// does not, because there is none. A shared template assuming one wording
// fits both reads as self-contradictory at a $ref site (GitHub #406).
func preserveUnionSiblingsAt(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer string, reason ir.UnmodeledReason, why string) []ir.Diagnostic {
kept, diags := preserveBranchSets(c, p, s, reason, pointer)
if reason == ir.ReasonValidationOnly || len(kept) == 0 {
return diags
}
return append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer,
"%s; union branches kept verbatim under Unmodeled", why))
}

// preserveBranchSets stores s's declared oneOf/anyOf verbatim under p, in
// keyword order, reporting one it cannot convert. It returns the keywords
// actually kept (empty when neither is written or neither converts), so a
// caller with a message of its own knows what to name in it. reason ==
// ir.ReasonValidationOnly routes each through §4.7's keyword-family reporting
// (preserveKeyword) instead; every other reason goes through the plain
// preserve every other §4.8 degradation uses.
//
// preserveUnionSiblingsAt and preserveNullOnlyUnion share this loop because they
// keep the same two keywords for the same underlying reason — the node they
// attach to carries no branch set of its own to hold them in — and differ only
// in the sentence that explains why.
func preserveBranchSets(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, reason ir.UnmodeledReason, pointer string) ([]string, []ir.Diagnostic) {
var diags []ir.Diagnostic
kept := false
var kept []string
for _, kw := range []string{"oneOf", "anyOf"} {
raw, err := annotation.RawFromNode(annotation.RawPropertyNode(s, kw))
if err != nil {
Expand All @@ -481,13 +502,11 @@ func preserveUnionSiblingsAt(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, po
continue
}
preserve(c, p, "openapi:"+kw, raw, reason, pointer+ids.Ptr(kw))
kept = kept || len(raw) > 0
}
if reason == ir.ReasonValidationOnly || !kept {
return diags
if len(raw) > 0 {
kept = append(kept, kw)
}
}
return append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer,
"%s; union branches kept verbatim under Unmodeled", why))
return kept, diags
}

// declaresUnion reports whether s writes oneOf or anyOf at all. It is the one
Expand Down Expand Up @@ -2381,6 +2400,27 @@ func isNullSchema(js *oas3.JSONSchema[oas3.Referenceable]) bool {
return len(types) == 1 && types[0] == oas3.SchemaTypeNull
}

// allNullUnion reports whether every branch of the oneOf/anyOf combinator
// unionBranches elects is a bare `type: null` schema. nullUnionCollapse has no
// non-null branch to collapse a set like this onto, so without this check
// lowerOneOfAnyOf's buildUnion fallback strips every branch as a null marker
// and interns a Union with none left — the empty value space irverify's
// ir/union-no-variants rejects (GitHub #416). The branch set still admits
// exactly one value, the same one a bare `{type: null}` schema at this
// position admits, so lowerOneOfAnyOf lowers it the same way instead.
func allNullUnion(s *oas3.Schema) bool {
variants, _, _ := unionBranches(s)
if len(variants) == 0 {
return false
}
for _, v := range variants {
if !isNullSchema(v) {
return false
}
}
return true
}

// listConstraints reads a list schema's collection constraints. Only the safe
// integer/bool bounds are read here; numeric-value bounds go through raw nodes
// elsewhere to avoid the float64 trap.
Expand Down
36 changes: 36 additions & 0 deletions compilers/openapi/internal/schema/schema_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ func TestIsNullSchema_EmptyEitherFalse(t *testing.T) {
assert.False(t, isNullSchema(openapitest.EmptyEitherSchema()), "empty either is not a null schema")
}

// TestAllNullUnion_NoBranchesFalse covers the guard's own contract rather than
// its one caller: lowerOneOfAnyOf never reaches allNullUnion without a
// non-empty oneOf or anyOf, so a schema declaring neither is a case only a
// direct call can drive.
func TestAllNullUnion_NoBranchesFalse(t *testing.T) {
t.Parallel()
assert.False(t, allNullUnion(&oas3.Schema{}), "no oneOf/anyOf branches admits nothing to be null-only")
}

func TestPreserveUnionSiblings_MissingNode(t *testing.T) {
t.Parallel()
l := newRawLowerer(&soa.OpenAPI{})
Expand All @@ -57,6 +66,33 @@ func TestPreserveUnionSiblings_MissingNode(t *testing.T) {
assertInternalInvariant(t, diags)
}

// TestPreserveNullOnlyUnion_MissingNode is preserveNullOnlyUnion's half of
// TestPreserveUnionSiblings_MissingNode: no node registered under id means the
// null-only oneOf/anyOf has nowhere to attach, so the guard reports the broken
// invariant instead of dropping it quietly.
func TestPreserveNullOnlyUnion_MissingNode(t *testing.T) {
t.Parallel()
l := newRawLowerer(&soa.OpenAPI{})
diags := preserveNullOnlyUnion(l.ctx, l.types, "t/anon/missing", &oas3.Schema{}, "/p")
assertInternalInvariant(t, diags)
}

// TestPreserveNullOnlyUnion_NothingToKeep covers a registered node whose
// schema declares neither oneOf nor anyOf: preserveBranchSets keeps nothing,
// so nothing announces a keeping that never happened. lowerNullOnlyUnion never
// reaches this — its caller always has one of the two — so only a direct call
// drives it, the same way TestAllNullUnion_NoBranchesFalse drives its sibling
// guard.
func TestPreserveNullOnlyUnion_NothingToKeep(t *testing.T) {
t.Parallel()
l := newRawLowerer(&soa.OpenAPI{})
id := internNode(l.ctx, l.types, "/components/schemas/S", "", func(cm ir.TypeCommon) ir.TypeDef {
return &ir.Scalar{TypeCommon: cm}
})
diags := preserveNullOnlyUnion(l.ctx, l.types, id, &oas3.Schema{}, "/components/schemas/S")
assert.Empty(t, diags, "nothing was kept, so nothing is announced")
}

// TestAttachDeclaredAnnotations_MissingNode drives the invariant no source can
// break: a pointer owning an ID the registry never registered. Lowering records
// the two together, so this state is a compiler bug — and the annotations it
Expand Down
Loading
Loading