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 @@ -152,6 +152,7 @@ func conformanceCases() []conformanceCase {
{"discriminator-inheritance", assertDiscriminatorInheritance},
{"discriminator-default-mapping", assertDiscriminatorDefaultMapping},
{"unhomed-keywords", assertUnhomedKeywords},
{"codeclared-keywords", assertCoDeclaredKeywords},
{"anyof-untagged", assertAnyOfUntagged},
{"negation-not", assertNegationNot},
{"dependent-required", assertDependentRequired},
Expand Down
55 changes: 55 additions & 0 deletions compilers/openapi/conformance_unmodeled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,58 @@ func assertResponseLinks(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
_, ok = opByName(doc, "getOrder")
assert.True(t, ok, "the operation a link names is an ordinary operation")
}

// assertCoDeclaredKeywords pins the keywords a schema writes that compete for
// one position (GitHub #35).
//
// The dispatch elects one of them and the rest were dropped outright — a whole
// relationship to a Base, or half a union, gone with no Unmodeled entry and no
// diagnostic at all. Every case here asserts the same three things: the elected
// keyword still lowers, the ones passed over are kept verbatim under
// ReasonDegradedLowering, and the position says so.
func assertCoDeclaredKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) {
base := `[{"$ref":"#/components/schemas/Base"}]`
e, ok := doc.Types[namedID("NarrowedEnum")].(*ir.Enum)
require.True(t, ok, "the enum is the value")
assertKeptRaw(t, e.Unmodeled, "openapi:allOf", base)

lit, ok := doc.Types[namedID("NarrowedConst")].(*ir.Literal)
require.True(t, ok, "const outranks the composition beside it")
assertKeptRaw(t, lit.Unmodeled, "openapi:allOf", base)

within, ok := doc.Types[namedID("ConstWithinEnum")].(*ir.Literal)
require.True(t, ok, "const is the narrower of the two")
assertKeptRaw(t, within.Unmodeled, "openapi:enum", `["a","b"]`)

anyOf := `[{"type":"number"},{"type":"boolean"}]`
both, ok := doc.Types[namedID("BothCombinators")].(*ir.Union)
require.True(t, ok, "oneOf becomes the union")
assert.Len(t, both.Variants, 2, "with a variant per oneOf branch")
assertKeptRaw(t, both.Unmodeled, "openapi:anyOf", anyOf)

// Suppressing the {X, null} collapse is what gives this one a node of its
// own: collapsing would resolve the position to the shared string primitive,
// which must never carry one declaration's keywords.
nullable, ok := doc.Types[namedID("NullableWithCombinators")].(*ir.Union)
require.True(t, ok, "a co-declared anyOf stops the {X, null} collapse")
assert.Len(t, nullable.Variants, 1, "the null branch still lifts off the variant list")
assertKeptRaw(t, nullable.Unmodeled, "openapi:anyOf", anyOf)

for _, name := range []string{
"NarrowedEnum", "NarrowedConst", "ConstWithinEnum",
"BothCombinators", "NullableWithCombinators",
} {
assert.Equal(t, []ir.Severity{ir.SeverityInfo},
diagsAt(diags, "openapi/degraded-construct", "/components/schemas/"+name),
"%s announces the keyword it passed over", name)
}
}

// assertKeptRaw requires p to hold key under ReasonDegradedLowering with the
// given JSON payload.
func assertKeptRaw(t *testing.T, p ir.Unmodeled, key, want string) {
t.Helper()
entry := unmodeledEntry(t, p, key)
assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason)
assert.JSONEq(t, want, string(entry.Value))
}
49 changes: 45 additions & 4 deletions compilers/openapi/internal/schema/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,12 @@ func composesAsModel(s *oas3.Schema) bool {

// unionBranches returns the branches of whichever combinator the schema
// declares, the keyword's name (for pointers), and whether it is exclusive.
// oneOf wins when both are present; only the verbatim lowering ever sees that
// shape, and it keeps both keywords.
//
// oneOf wins when both are written, so every caller owns the set it passed over:
// buildUnion keeps it on the Union (preserveUnusedCombinator), nullUnionCollapse
// declines to collapse past it, and the verbatim lowering keeps both keywords
// (preserveUnionSiblings). Reading the preference as one only that last lowering
// could reach is what dropped the anyOf in silence (GitHub #35).
func unionBranches(s *oas3.Schema) ([]*oas3.JSONSchema[oas3.Referenceable], string, bool) {
if branches := s.GetOneOf(); len(branches) > 0 {
return branches, "oneOf", true
Expand Down Expand Up @@ -671,6 +675,7 @@ type variantTypeFunc func(b *oas3.JSONSchema[oas3.Referenceable], vptr, vhint st
// (internNode), so buildUnion needs no hint of its own to build one.
func buildUnion(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, common ir.TypeCommon, pointer string, variantType variantTypeFunc) (ir.TypeDef, []ir.Diagnostic) {
branches, key, exclusive := unionBranches(s)
diags := preserveUnusedCombinator(c, &common.Unmodeled, s, key, pointer)
variants := make([]ir.Variant, 0, len(branches))
for i, b := range branches {
if isNullSchema(b) {
Expand All @@ -689,9 +694,45 @@ func buildUnion(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, common ir.Typ
Exclusive: exclusive,
WireTagged: false,
}
disc, diags := lowerDiscriminator(c, ts, s, nil, pointer)
disc, discDiags := lowerDiscriminator(c, ts, s, nil, pointer)
u.Discriminator = disc
return u, diags
return u, append(diags, discDiags...)
}

// otherCombinator names each union keyword's counterpart, so the branch set
// unionBranches passed over is read off the choice it returned rather than
// restated beside it.
var otherCombinator = map[string]string{"oneOf": "anyOf", "anyOf": "oneOf"}

// preserveUnusedCombinator keeps the branch set unionBranches passed over
// verbatim on the Union the position lowers to, and reports it. A schema
// declaring only one combinator passes over nothing and is left alone.
//
// A schema writing both conjoins them — an instance must satisfy the oneOf *and*
// the anyOf — and one ir.Union carries one branch set, so the loser had no place
// in the node and was dropped in silence. It is the union half of the same §4.8
// rule recordSkippedFamilies applies to the keyword families: the position keeps
// lowering to the branch set unionBranches elects, because that Union is a shape
// the IR can express and discarding it too would model nothing at all, and the
// set it did not elect stays recoverable beside it.
//
// Where a *structural* sibling is written as well, classifyUnionSiblings reaches
// unionBothCombinators first and neither set is elected — there the sibling body
// is the most the IR can express, and distributing either union across it would
// drop the other (lowerBesideUnmodeledUnion).
func preserveUnusedCombinator(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, won, pointer string) []ir.Diagnostic {
if len(s.GetOneOf()) == 0 || len(s.GetAnyOf()) == 0 {
return nil
}
unused := otherCombinator[won]
kept, diags := PreserveSchemaKeyword(c, p, s, unused, ir.ReasonDegradedLowering, pointer+ids.Ptr(unused))
if !kept {
return diags
}
return append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer,
"oneOf and anyOf are both declared here and conjoin, and one Union carries one "+
"branch set; this position lowered as its %s, with %s kept verbatim under Unmodeled",
won, unused))
}

// lowerDistributedUnion emits the Union that is the schema's value, distributing
Expand Down
106 changes: 106 additions & 0 deletions compilers/openapi/internal/schema/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1779,3 +1779,109 @@ func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) {
assert.Equal(t, 5, countDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo),
"each declined shape is reported once; got %+v", diags)
}

// TestUnionCombinators_PassedOverBranchSetIsKept covers the preference nothing
// used to record (GitHub #35). unionBranches takes oneOf whenever it is written
// and falls back to anyOf only when it is not, so a schema declaring both lost
// the anyOf outright — no variant, no Unmodeled entry, no diagnostic. The two
// conjoin, and one ir.Union carries one branch set, so the elected set stays the
// union and the other is kept beside it.
func TestUnionCombinators_PassedOverBranchSetIsKept(t *testing.T) {
t.Parallel()
doc, diags := lowerSpec(t, componentSpec(` S:
oneOf: [{type: string}, {type: integer}]
anyOf: [{type: number}, {type: boolean}]
`))
requireNoErrorDiags(t, diags)

u, ok := typeByName(doc, "S").(*ir.Union)
require.True(t, ok, "the elected branch set still becomes the union")
assert.Len(t, u.Variants, 2, "one variant per oneOf branch")
entry, ok := u.Unmodeled["openapi:anyOf"]
require.True(t, ok, "the set unionBranches passed over is kept; got %v", u.Unmodeled)
assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason)
assert.JSONEq(t, `[{"type":"number"},{"type":"boolean"}]`, string(entry.Value))
assert.Contains(t,
diagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S"),
"lowered as its oneOf, with anyOf kept verbatim under Unmodeled")
}

// TestUnionCombinators_NullBranchDoesNotCollapsePastTheAnyOf covers the second
// site of the same preference. A {X, null} oneOf normally collapses to nullable
// X, which resolves the position straight to X's own node — the shared string
// primitive here — leaving the co-declared anyOf nowhere of its own to sit; it
// was dropped there too. A schema writing both combinators is not nullable X in
// the first place, so it stays a Union and keeps the loser on it.
func TestUnionCombinators_NullBranchDoesNotCollapsePastTheAnyOf(t *testing.T) {
t.Parallel()
doc, diags := lowerSpec(t, componentSpec(` S:
oneOf: [{type: string}, {type: "null"}]
anyOf: [{type: number}, {type: boolean}]
`))
requireNoErrorDiags(t, diags)

u, ok := typeByName(doc, "S").(*ir.Union)
require.True(t, ok, "the collapse is declined; got %v", typeByName(doc, "S"))
require.Len(t, u.Variants, 1, "the null branch still lifts off the variant list")
assert.Equal(t, ir.TypeID("t/prim/string"), u.Variants[0].Type.Target)
assert.Contains(t, u.Unmodeled, "openapi:anyOf",
"and the node the position now owns carries the set passed over")
}

// TestUnionCombinators_UnpreservableIsNotAnnounced pins the pairing GitHub #144
// exists for, at this site: a position may only announce what it actually kept.
// The anyOf here fails to convert, so the union lowers, the failure is reported
// at the keyword, and nothing claims the branch set survived.
func TestUnionCombinators_UnpreservableIsNotAnnounced(t *testing.T) {
t.Parallel()
doc, diags := lowerSpec(t, componentSpec(` S:
oneOf: [{type: string}, {type: integer}]
anyOf: [{type: number, x-t: `+unpreservableValue+`}]
`))

u, ok := typeByName(doc, "S").(*ir.Union)
require.True(t, ok)
assert.NotContains(t, u.Unmodeled, "openapi:anyOf", "the conversion failed, so nothing was kept")
assert.True(t, hasDiag(diags, diag.UnpreservableConstruct), "the failure itself is reported")
assert.Empty(t, preservationClaims(diags),
"nothing was written under Unmodeled, so nothing may announce that it was")
}

// TestUnionCombinators_KeepingIsOrderIndependent states the property over the
// whole document: declining the collapse gives this position a Union of its own
// and turns its branches into ordinary union branches, so an outside $ref naming
// one of them must reach the same IR whichever of the two is declared first.
//
// The branch writes a description on purpose, and the guard below is what keeps
// that load-bearing. A bare `{type: string}` branch resolves through the union to
// the shared primitive, so the outside $ref is the only lowering that ever hoists
// a node at the branch pointer: the node is still *there*, which is why asserting
// its presence proves nothing — one lowering put it there and no hint ever had to
// agree with another. Pinning the union's own variant to that node is what puts
// both lowerings on the pointer, which is the state where only the first to
// arrive interns it (branchHint, subSchemaHint, #181).
func TestUnionCombinators_KeepingIsOrderIndependent(t *testing.T) {
t.Parallel()
host := ` S:
oneOf:
- {type: string, description: the branch}
- {type: "null"}
anyOf: [{type: number}, {type: boolean}]
`
outsider := " Outsider: {$ref: '#/components/schemas/S/oneOf/0'}\n"
first, diags := parseFull(t, componentSpec(outsider+host))
requireNoErrorDiags(t, diags)
last, diags := parseFull(t, componentSpec(host+outsider))
requireNoErrorDiags(t, diags)

for _, doc := range []*ir.Document{first, last} {
u, ok := typeByName(doc, "S").(*ir.Union)
require.True(t, ok, "the position is the Union that keeps the passed-over set")
require.Len(t, u.Variants, 1, "the null branch still lifts off the variant list")
require.Equal(t, ir.TypeID("t/anon/components/schemas/S/oneOf/0"), u.Variants[0].Type.Target,
"the union reaches the branch's own node, or the $ref is the only lowering that does")
}
assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...),
"declaring the reference before or after the union must not change the IR")
assert.Empty(t, cmp.Diff(first.Types, last.Types), "nor any name hint in the registry")
}
Loading
Loading