Skip to content

Commit 3a646fc

Browse files
authored
fix(compilers/openapi): keep bounds beside a preserved union (#363)
PR: #363
1 parent 3712d50 commit 3a646fc

5 files changed

Lines changed: 168 additions & 8 deletions

File tree

compilers/openapi/conformance_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,9 @@ func assertAllOfRequiredOnly(t *testing.T, doc *ir.Document, _ []ir.Diagnostic)
759759
// assertAllOfOneOfCooccurrence pins both halves of the co-declared composition
760760
// rule: §4.3 distributes a union whose branches all name referents, and §4.8
761761
// keeps one with an inline branch verbatim rather than distributing it halfway.
762+
// The verbatim half is covered over a model body, which owns a node already, and
763+
// over a scalar one, which does not — there the alias hoisted for the union
764+
// carries what the position wrote beside it too.
762765
// The outside reference to a branch pointer pins the third thing: a composed
763766
// variant is Morphic's own node, so it cannot be taken by, or take from, a $ref.
764767
func assertAllOfOneOfCooccurrence(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
@@ -780,6 +783,15 @@ func assertAllOfOneOfCooccurrence(t *testing.T, doc *ir.Document, _ []ir.Diagnos
780783
require.True(t, ok, "and the union it could not absorb survives beside it")
781784
assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason)
782785

786+
bounded, ok := doc.Types[namedID("BoundedKinds")].(*ir.Scalar)
787+
require.True(t, ok, "a body that is not a model reduces to a shared primitive and hoists an alias")
788+
entry, ok = bounded.Unmodeled["openapi:oneOf"]
789+
require.True(t, ok, "which is the node the kept union sits on")
790+
assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason)
791+
require.NotNil(t, bounded.Constraints, "and the bounds written beside the union sit on it too")
792+
require.NotNil(t, bounded.Constraints.MinLength)
793+
assert.Equal(t, int64(3), *bounded.Constraints.MinLength)
794+
783795
outsider, ok := doc.Types[namedID("Outsider")].(*ir.Model)
784796
require.True(t, ok)
785797
require.Len(t, outsider.Properties, 1)

compilers/openapi/internal/schema/compose_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2330,6 +2330,90 @@ func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) {
23302330
"each declined shape is reported once; got %+v", diags)
23312331
}
23322332

2333+
// TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt pins that keeping
2334+
// a union verbatim does not cost the position the value constraints written
2335+
// beside it. The alias exists so the union attaches to a node this pointer owns
2336+
// rather than to the shared primitive the body reduced to, and owning a node is
2337+
// what stops hoistDeclarationHome hoisting the alias that would otherwise carry
2338+
// the bounds — so this alias has to carry them itself, as every other hoist here
2339+
// does (GitHub #343).
2340+
//
2341+
// Both reasons that keep a union hoist the same alias, and anyOf rides the same
2342+
// path as oneOf, so each is covered. The last case pins what reading the bounds
2343+
// also produces: the co-declared-bound reconciliation reports at a position
2344+
// nothing used to read, and the keyword it cannot home is kept on the node the
2345+
// bounds landed on. The key set is asserted whole — carrying the constraints
2346+
// without that keyword leaves the diagnostic naming an entry the node lacks.
2347+
func TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt(t *testing.T) {
2348+
t.Parallel()
2349+
three := int64(3)
2350+
ten, five := ir.BigVal("10"), ir.BigVal("5")
2351+
cases := []struct {
2352+
name, schemas, unionKey string
2353+
reason ir.UnmodeledReason
2354+
want ir.Constraints
2355+
wantKept []string
2356+
wantDiag string
2357+
}{
2358+
{
2359+
name: "a validation-only union",
2360+
schemas: " A: {type: string, minLength: 3, oneOf: [{minLength: 1}, {minLength: 2}]}\n",
2361+
unionKey: "openapi:oneOf",
2362+
reason: ir.ReasonValidationOnly,
2363+
want: ir.Constraints{MinLength: &three},
2364+
wantKept: []string{"openapi:oneOf"},
2365+
},
2366+
{
2367+
name: "a union kept as a degraded lowering",
2368+
schemas: " A: {type: number, minimum: 10, multipleOf: 5, oneOf: [{type: string}, {type: integer}]}\n",
2369+
unionKey: "openapi:oneOf",
2370+
reason: ir.ReasonDegradedLowering,
2371+
want: ir.Constraints{Min: &ten, MultipleOf: &five},
2372+
wantKept: []string{"openapi:oneOf"},
2373+
},
2374+
{
2375+
name: "an anyOf kept in place of a oneOf",
2376+
schemas: " A: {type: string, minLength: 3, anyOf: [{minLength: 1}, {minLength: 2}]}\n",
2377+
unionKey: "openapi:anyOf",
2378+
reason: ir.ReasonValidationOnly,
2379+
want: ir.Constraints{MinLength: &three},
2380+
wantKept: []string{"openapi:anyOf"},
2381+
},
2382+
{
2383+
name: "co-declared bounds beside a union",
2384+
schemas: " A: {type: number, minimum: 10, exclusiveMinimum: 0, oneOf: [{minLength: 1}, {minLength: 2}]}\n",
2385+
unionKey: "openapi:oneOf",
2386+
reason: ir.ReasonValidationOnly,
2387+
want: ir.Constraints{Min: &ten},
2388+
wantKept: []string{"openapi:exclusiveMinimum", "openapi:oneOf"},
2389+
wantDiag: "kept minimum as the tighter of the two",
2390+
},
2391+
}
2392+
for _, tc := range cases {
2393+
t.Run(tc.name, func(t *testing.T) {
2394+
t.Parallel()
2395+
doc, diags := lowerSpec(t, openapitest.ComponentSpec(tc.schemas))
2396+
openapitest.RequireNoErrorDiags(t, diags)
2397+
2398+
sc, ok := typeByName(doc, "A").(*ir.Scalar)
2399+
require.True(t, ok, "the preserved union hoists an alias over the shared primitive")
2400+
entry, ok := sc.Unmodeled[tc.unionKey]
2401+
require.True(t, ok, "and keeps the union on it")
2402+
assert.Equal(t, tc.reason, entry.Reason)
2403+
require.NotNil(t, sc.Constraints, "while keeping the bounds written beside it")
2404+
assert.Empty(t, cmp.Diff(tc.want, *sc.Constraints))
2405+
assert.Equal(t, tc.wantKept, unmodeledKeys(sc.Unmodeled),
2406+
"the bound keyword that reaches no Constraints field is kept on the same node")
2407+
if tc.wantDiag == "" {
2408+
return
2409+
}
2410+
assert.Contains(t,
2411+
openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/A"),
2412+
tc.wantDiag, "reading the bounds is what reports on them")
2413+
})
2414+
}
2415+
}
2416+
23332417
// TestUnionCombinators_PassedOverBranchSetIsKept covers the preference nothing
23342418
// used to record (GitHub #35). unionBranches takes oneOf whenever it is written
23352419
// and falls back to anyOf only when it is not, so a schema declaring both lost

compilers/openapi/internal/schema/schema.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -428,13 +428,15 @@ func lowerBesideUnmodeledUnion(c lowering.Ctx, ts *compile.Types, anchors *Ancho
428428
if got, _ := ts.Lookup(pointer); got != inner {
429429
// The structural body reduced to a shared/aliased target; hoist an alias
430430
// so the preserved union attaches to a node this pointer owns, never to a
431-
// shared primitive.
432-
//
433-
// Alone among the alias hoists this one reads no constraints, so the
434-
// position's bounds — and with them the co-declared keyword kept beside
435-
// them — reach no field here. That is GitHub #343, deliberately left as
436-
// it was rather than settled as a side effect of the keyword's own fix.
437-
owner = internAlias(c, ts, pointer, hint, ir.TypeRef{Target: inner}, nil, nil)
431+
// shared primitive. The alias carries the position's value constraints for
432+
// the reason hoistByteScalar records: owning the node is what stops
433+
// hoistDeclarationHome hoisting the alias that would otherwise carry them.
434+
// kept travels with them, so the co-declared bound keyword that reaches no
435+
// Constraints field lands on the same node as the bounds it lost to.
436+
var kept ir.Unmodeled
437+
cons, consDiags := schemaConstraints(c, &kept, s, pointer)
438+
diags = append(diags, consDiags...)
439+
owner = internAlias(c, ts, pointer, hint, ir.TypeRef{Target: inner}, cons, kept)
438440
}
439441
return owner, append(diags, preserveUnionSiblings(c, ts, owner, s, pointer, reason, why)...)
440442
}

testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,48 @@
251251
"positional": false,
252252
"inputOnly": false
253253
},
254+
"t/openapi/components/schemas/BoundedKinds": {
255+
"kind": "scalar",
256+
"id": "t/openapi/components/schemas/BoundedKinds",
257+
"name": {
258+
"source": "BoundedKinds",
259+
"canonical": "bounded_kinds"
260+
},
261+
"anonymous": false,
262+
"docs": {},
263+
"sensitive": false,
264+
"unmodeled": {
265+
"openapi:oneOf": {
266+
"reason": "degraded_lowering",
267+
"value": [
268+
{
269+
"$ref": "#/components/schemas/A"
270+
},
271+
{
272+
"type": "string"
273+
}
274+
],
275+
"provenance": {
276+
"source": 0,
277+
"pointer": "/components/schemas/BoundedKinds/oneOf"
278+
}
279+
}
280+
},
281+
"provenance": {
282+
"source": 0,
283+
"pointer": "/components/schemas/BoundedKinds"
284+
},
285+
"base": {
286+
"target": "t/prim/string",
287+
"nullable": false
288+
},
289+
"constraints": {
290+
"exclusiveMin": false,
291+
"exclusiveMax": false,
292+
"minLength": 3,
293+
"uniqueItems": false
294+
}
295+
},
254296
"t/openapi/components/schemas/Combo": {
255297
"kind": "union",
256298
"id": "t/openapi/components/schemas/Combo",
@@ -542,13 +584,22 @@
542584
"source": 0,
543585
"pointer": "/components/schemas/MixedKinds"
544586
}
587+
},
588+
{
589+
"severity": "info",
590+
"code": "openapi/degraded-construct",
591+
"message": "oneOf/anyOf co-declared with structural keywords intersects with them, and the body is not a model, so it carries no composition to distribute into; union branches kept verbatim under Unmodeled",
592+
"provenance": {
593+
"source": 0,
594+
"pointer": "/components/schemas/BoundedKinds"
595+
}
545596
}
546597
],
547598
"sources": [
548599
{
549600
"format": "openapi@3.1",
550601
"path": "allof-oneof-cooccurrence.yaml",
551-
"hash": "580c2ffd45b8b24e175b4c5b7f1af6c237a920de7d1942d84b10a00ad2dc1e33"
602+
"hash": "9deaf845e7a310c701113bd61e60a7e0d170cbf96860921816623667813e458f"
552603
}
553604
]
554605
}

testdata/conformance/openapi/allof-oneof-cooccurrence.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ components:
3232
oneOf:
3333
- {$ref: '#/components/schemas/A'}
3434
- {type: string}
35+
# The same conjunction over a body that is not a model. It reduces to the
36+
# shared string primitive, so the position hoists an alias for the kept union
37+
# to sit on — and that alias has to carry the bounds written here too, since
38+
# owning a node is what stops the declaration-home fallback carrying them
39+
# (GitHub #343).
40+
BoundedKinds:
41+
type: string
42+
minLength: 3
43+
oneOf:
44+
- {$ref: '#/components/schemas/A'}
45+
- {type: string}
3546
# A branch pointer denotes the branch schema, and a reference to it must get
3647
# that schema — not the variant Morphic composes for the same branch, which
3748
# is a node of its own with no pointer a $ref can name.

0 commit comments

Comments
 (0)