fix(compilers/openapi): lower a nullable enum as an Enum - #267
Conversation
The canonical 3.1 spelling of a nullable enum lists `null` among the
members of a null-admitting type array. enumMembers refused the null
member outright, so the whole enum fell to the union-of-literals
fallback: `{type: [string, "null"], enum: [red, green, null]}` lowered
to a Union of three Literals with a degraded-construct diagnostic, and
the declaration's enum-ness was gone. The 3.0 spelling
(`nullable: true` beside the same member list) degraded the same way.
Strip the null member and build the Enum from the rest, gated on
schemaAdmitsNull — the one predicate the declaration position, unions
and refNullable all compute the Nullable bit from, so the null dropped
here is exactly the null every use site puts back. Member kinds are now
reconciled against the members actually kept rather than the one at
index 0, so a leading `null` no longer fixes the kind to one no scalar
member can match, and a set that keeps no member still degrades rather
than producing a memberless Enum.
The fallback is unchanged for every set the rule must not touch: a
schema whose type keyword excludes null (where the member is dead by
conjunction and normalizing would widen the type), a bare enum that
declares no nullability any use site would re-derive (#265), members
that are heterogeneous for another reason, and an all-null set.
Closes #44
lowerEnum claimed schemaAdmitsNull is what "every use site" re-derives
the Nullable bit from, so the null it strips from an enum's members is
put back everywhere. A conjunct position is not such a site: Model.Base
and Mixins carry no Nullable bit at all, which conjoinBranch states as a
rule of its own, so `{allOf: [{$ref: T}]}` over a nullable T reaches no
null. That holds for every spelling of T's nullability rather than for
the enum one, and a model or enum target leaves it unrecoverable — only
a scalar target keeps it, on the alias's own Base. Filed as #279; name
the exception here so the claim reads as what it is.
assertNullableEnum31 left the reader to discover that its golden cannot
pin the strip: the type array already carries the bit, so deleting the
`null` member from the spec moves the source hash and nothing else. Say
which assertion does the pinning instead.
The lead sentence enumerated two conditions under which enumMembers reports ok=false -- a non-scalar member, heterogeneous kinds -- but there are now three. A set that keeps no member also fails, which is what makes an all-null enum degrade rather than become a memberless Enum. The third was documented, but inside a later paragraph about the returned PrimKind, so the sentence a reader takes as the contract was the one that did not mention it. Stated in the lead now, with the paragraph below keeping the reasoning rather than repeating the condition.
Code reviewFound 1 issue, fixed in 066a683.
morphic/compilers/openapi/internal/schema/compose.go Lines 1014 to 1027 in fa4b9f0 Everything else in the change verified out. The load-bearing claim — that Two claims I checked by compiling rather than reading:
The mutation table reproduces exactly:
Two notes on reproducing that last row: spelled as an unconditional removal of the Filed #288 while checking this, not fixed here: a nullable type array whose enum excludes Gate green at 066a683: 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Summary
OpenAPI 3.1 spells a nullable enum by listing
nullamong the members of a null-admitting typearray.
enumMembersclassified that member as inadmissible, so the whole enum fell to theunion-of-literals fallback and lost its enum-ness. Compiling the issue's repro on
main:produced a
Unionatt/openapi/components/schemas/Colorover three hoistedLiterals(
.../Color/enum/0= "red",/1= "green",/2= null), plusinfo openapi/degraded-construct: heterogeneous or non-scalar enum lowered as a union of literals.The 3.0 spelling (
type: string, nullable: truebeside the same member list, which is how 3.0writes a nullable enum at all) degraded identically.
ir-design.md§3.3 says a null variant isnormalized onto
TypeRef.Nullable, and the remaining members here are homogeneous strings, so thelowering contradicted the documented normalization.
The fix strips the
nullmember and builds theEnumfrom the rest, gated onschemaAdmitsNull.That predicate is deliberate rather than incidental: it is the single place the declaration
position, a union, and
refNullableat a$refuse site all compute theNullablebit, so thenull dropped from the member list is exactly the null a reference to the enum puts back.
Colornow lowers to
Enum{red, green}withnullable: trueon the reference — nothing else changed,since the use-site half of the mechanism was already correct after #101.
Two smaller corrections come with it. Member kinds are reconciled against the members actually kept
rather than the one at index 0, so
enum: [null, red, green]is the same string enum asenum: [red, green, null]— without this, a leadingnullfixed the kind to one no scalar membercould match and the enum degraded anyway. And a set that keeps no member reports failure, so
{type: ["null"], enum: [null]}still degrades instead of producing a memberlessEnum.What deliberately still degrades
The union-of-literals fallback is unchanged for every member set the rule must not touch, and the
last two are scope decisions rather than oversights (both are stated in
lowerEnum's doc comment):{type: string, enum: [red, green, null]}— keywords conjoin, sotype: stringexcludes null andthe member is dead. Normalizing here would claim the document admits null when it says it does
not, which is the one direction of this change that could silently alter meaning.
{enum: [red, green, null]}with no type keyword. In the 2020-12 dialect this does admit null,but
schemaAdmitsNullreads onlynullable: trueand thetypearray, so stripping the memberwould set the bit nowhere — the null would vanish, which is the failure openapi: 3.1
type: [T, "null"]nullability is lost at every$refsite #28/fix(compilers/openapi): honor every null spelling at $ref sites #101 fixed for theother spellings. Making it work means teaching
schemaAdmitsNullabout enum members, whichchanges every site that computes nullability and has its own cases to settle. Filed as openapi: a bare enum with a null member does not normalize to a nullable Enum #265.
enum: [1, "a", null]), and an all-null set.One position the normalized null does not reach
A conjunct is not a reference.
Model.BaseandMixinsname one side of a conjunction and carry noNullablebit at all, whichconjoinBranchstates as a rule of its own, so{allOf: [{$ref: T}]}over a nullable
Treaches no null and the property referencing the composition is not nullableeither. That predates this change and holds for every spelling of
T's nullability —type: [string, "null"]on a scalar or a model degrades the same way today, with no diagnostic. What isnew is only that the nullable-enum spelling now joins them, since the enum no longer carries a null
literal variant of its own to be found by following the composition. It is filed as #279 and named
in
lowerEnum's doc comment rather than fixed here: deciding it means deciding whether acomposition derives nullability from its conjuncts, which is a change to
allOflowering for everytarget kind, not to the enum rule.
Test plan
Full gate green locally:
gofmt -l,go vet ./...,golangci-lint run(0 issues),go build ./...,./scripts/check-coverage.sh(100.0%, 4620/4620 statements).New coverage:
TestEnum_NullMemberNormalizesToNullable— four rows over the 3.1 type-array and 3.0nullablespellings, each asserting the
Enum(closed, value type, exact members), the propertyreference's
Nullablebit, and the absence of a degraded-construct diagnostic. Two rows put thenullfirst, which is the order that was broken by the index-0 reconciliation.TestEnum_NullMemberKeepsUnionFallback— the four sets above, asserting theUnion, the infodiagnostic, and that every declared member (including the null) survives as a
Literal.testdata/conformance/openapi/nullable-enum-31.{yaml,golden.json}withassertNullableEnum31:the declaration is an
Enumof two members and the bit reaches a property$ref, an arrayelement, and a query parameter, with a negative control that the array itself is not nullable.
Being in the corpus also puts it under the harness sweep and the two-order permutation oracle
(
go run ./cmd/morphic-harness testdata/conformance/openapi/nullable-enum-31.yaml→ ok).TestSchema_RefNullableAcrossSpellingsso the nullable-enum shape is registeredin that table. Its comment records that the bit there comes from the type array, so the row
covers the spelling rather than the enum lowering.
Each assertion was proved able to fail by planting the defect and watching it go red:
compose.goenumMembers(s.GetEnum(), false)(revert the fix)NormalizesToNullablerows + the conformance caselen(members) == 0→i == 0(the old reconciliation)len(members) == 0failureKeepsUnionFallback/every member is nullenumMembers(s.GetEnum(), true)(permissive guard)type keyword excludes nullandno type keywordrowsKeepsUnionFallback/heterogeneous members beside a null memberDeleting the
nullmember from the corpus spec also reddensTestConformance/nullable-enum-31, sothe golden is genuinely compared. That golden cannot pin the strip on its own, though: the type
array already carries the bit, so the IR body is byte-identical with or without the member and only
the source hash moves.
assertNullableEnum31's member count is what pins it — which mutation 1above confirms — and the assertion's doc comment now says so rather than leaving it to be
rediscovered.
Beyond the tests, the use-site claim was checked by compiling rather than by reading: a spec
referencing the nullable enum as a property, an array element, a map value, a tuple element, a
query parameter, a request body, a response body, a response header, a union variant, a
$refcarrying sibling keywords, and a sub-schema pointer puts
nullable: trueon theTypeRefat everyone. The conjunct position above is the only one that does not, and it behaves identically for a
non-enum nullable target.
One further note on the predicate, since this change leans on it in one direction.
schemaAdmitsNullis exact where the strip needs it: anullmember is droppedprecisely where a reference puts the bit back. It overstates in the other
direction —
{type: [string, "null"], enum: [red, green]}, a nullable type arraywhose enum excludes
null, still reads as nullable at every reference althoughthe conjunction forbids that value. That is pre-existing and untouched here, this
change altering behaviour only when the member list does contain
null, and it isinconsistent with the
oneOfspelling, where an enum sibling already suppressesthe null via
hasUnionSiblings. Filed as #288.Closes #44