diff --git a/packages/quicktype-core/src/UnionBuilder.ts b/packages/quicktype-core/src/UnionBuilder.ts index 611a14785..7bbd24289 100644 --- a/packages/quicktype-core/src/UnionBuilder.ts +++ b/packages/quicktype-core/src/UnionBuilder.ts @@ -405,6 +405,26 @@ function attributesForTypes( traverse(t, rootPath, typesArray.length === 1); } + // A nested homogeneous union next to null becomes one non-null type during + // unification, so its attributes belong on that type rather than on the + // enclosing nullable union. + const allDescendants = defined(typesForUnion.get(rootPath[0])); + const nullableUnionsCollapsedIntoType = new Set( + Array.from(unions).filter((u) => { + if (unionsEquivalentToRoot.has(u)) return false; + const descendants = defined(typesForUnion.get(u)); + const otherDescendants = Array.from(allDescendants).filter( + (t) => !descendants.has(t), + ); + return ( + descendants.size > 1 && + new Set(Array.from(descendants, (t) => t.kind)).size === 1 && + otherDescendants.length > 0 && + otherDescendants.every((t) => t.kind === "null") + ); + }), + ); + const resultAttributes = mapMap(unionsForType, (unionForType, t) => { const singleAncestors = Array.from(unionForType).filter( (u) => defined(typesForUnion.get(u)).size === 1, @@ -416,14 +436,31 @@ function attributesForTypes( const inheritedAttributes = singleAncestors.map((u) => u.getAttributes(), ); - return combineTypeAttributes( + let attributes = combineTypeAttributes( "union", [t.getAttributes()].concat(inheritedAttributes), ); + const collapsedAncestors = Array.from(unionForType).filter( + (u): u is UnionType => + u instanceof UnionType && + nullableUnionsCollapsedIntoType.has(u), + ); + for (const u of collapsedAncestors.reverse()) { + attributes = combineTypeAttributes( + "union", + u.getAttributes(), + increaseTypeAttributesDistance(attributes), + ); + } + + return attributes; }); const unionAttributes = Array.from(unions).map((u) => { const t = typesForUnion.get(u); - if (t !== undefined && t.size === 1) { + if ( + (t !== undefined && t.size === 1) || + nullableUnionsCollapsedIntoType.has(u) + ) { return emptyTypeAttributes; } diff --git a/test/inputs/schema/enum-of-enums-null.1.fail.enum.json b/test/inputs/schema/enum-of-enums-null.1.fail.enum.json new file mode 100644 index 000000000..5ab1864c3 --- /dev/null +++ b/test/inputs/schema/enum-of-enums-null.1.fail.enum.json @@ -0,0 +1,3 @@ +{ + "format": "xml" +} diff --git a/test/inputs/schema/enum-of-enums-null.1.json b/test/inputs/schema/enum-of-enums-null.1.json new file mode 100644 index 000000000..ef801982c --- /dev/null +++ b/test/inputs/schema/enum-of-enums-null.1.json @@ -0,0 +1,3 @@ +{ + "format": "rdf_xml" +} diff --git a/test/inputs/schema/enum-of-enums-null.2.json b/test/inputs/schema/enum-of-enums-null.2.json new file mode 100644 index 000000000..b0686c604 --- /dev/null +++ b/test/inputs/schema/enum-of-enums-null.2.json @@ -0,0 +1,3 @@ +{ + "format": null +} diff --git a/test/inputs/schema/enum-of-enums-null.schema b/test/inputs/schema/enum-of-enums-null.schema new file mode 100644 index 000000000..790d459a8 --- /dev/null +++ b/test/inputs/schema/enum-of-enums-null.schema @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConstructResponse", + "type": "object", + "required": ["format"], + "properties": { + "format": { + "description": "The format of the data.", + "anyOf": [ + { "$ref": "#/definitions/DataFormat" }, + { "type": "null" } + ] + } + }, + "additionalProperties": false, + "definitions": { + "DataFormat": { + "title": "DataFormat", + "description": "The format of the data.", + "oneOf": [ + { + "title": "Turtle", + "description": "Turtle format", + "type": "string", + "enum": ["turtle"] + }, + { + "title": "RDF XML", + "description": "RDF XML format", + "type": "string", + "enum": ["rdf_xml"] + }, + { + "title": "N-Triples", + "description": "N-Triples format", + "type": "string", + "enum": ["n_triples"] + }, + { + "title": "N-Quads", + "description": "N-Quads format", + "type": "string", + "enum": ["n_quads"] + } + ] + } + } +} diff --git a/test/languages.ts b/test/languages.ts index 120f3170b..d8a655c7b 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -25,6 +25,7 @@ const skipsEnumValueValidation = [ "enum-large.schema", "optional-enum.schema", "const-non-string.schema", + "enum-of-enums-null.schema", "haskell-enum-forbidden.schema", "nullable-optional-one-of.schema", "all-of-additional-properties-false.schema", diff --git a/test/unit/enum-of-enums-null-name.test.ts b/test/unit/enum-of-enums-null-name.test.ts new file mode 100644 index 000000000..048ccf5ef --- /dev/null +++ b/test/unit/enum-of-enums-null-name.test.ts @@ -0,0 +1,55 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { expect, test } from "vitest"; + +const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + required: ["format"], + properties: { + format: { + anyOf: [{ $ref: "#/definitions/DataFormat" }, { type: "null" }], + }, + }, + definitions: { + DataFormat: { + title: "DataFormat", + oneOf: [ + { title: "Turtle", type: "string", enum: ["turtle"] }, + { title: "RDF XML", type: "string", enum: ["rdf_xml"] }, + { + title: "N-Triples", + type: "string", + enum: ["n_triples"], + }, + { title: "N-Quads", type: "string", enum: ["n_quads"] }, + ], + }, + }, +}); + +// The fixture harness verifies round-tripping, but generated identifiers are +// self-consistent even when the wrong title is chosen. Assert the emitted name +// directly so the outer oneOf title cannot regress to the first variant title. +test("uses the outer oneOf title for a nullable enum-of-enums", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "Out", schema }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "prefer-types": true }, + }); + const output = result.lines.join("\n"); + + expect(output).toContain("format: DataFormat | null;"); + expect(output).toContain( + 'export type DataFormat = "turtle" | "rdf_xml" | "n_triples" | "n_quads";', + ); + expect(output).not.toMatch(/export type Turtle\b/); +});