diff --git a/packages/quicktype-core/src/attributes/InferenceFlags.ts b/packages/quicktype-core/src/attributes/InferenceFlags.ts new file mode 100644 index 0000000000..cfbb22b5d7 --- /dev/null +++ b/packages/quicktype-core/src/attributes/InferenceFlags.ts @@ -0,0 +1,23 @@ +import { TypeAttributeKind } from "./TypeAttributes.js"; + +export type SchemaArrayProvenance = "explicit" | "inferred"; + +class SchemaArrayTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("schemaArrayProvenance"); + } + + public combine(attrs: SchemaArrayProvenance[]): SchemaArrayProvenance { + return attrs.includes("explicit") ? "explicit" : "inferred"; + } + + public makeInferred(attr: SchemaArrayProvenance): SchemaArrayProvenance { + return attr; + } +} + +export const schemaArrayTypeAttributeKind = new SchemaArrayTypeAttributeKind(); + +// Preserve sample-inference semantics when a top-level array is round-tripped +// through quicktype's JSON Schema output. +export const inferredTopLevelSchemaComment = "qt-inferred-top-level"; diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 66b1c4104b..d0e9eb137d 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -32,6 +32,10 @@ import { import { defaultValueAttributeProducer } from "../attributes/DefaultValue.js"; import { descriptionAttributeProducer } from "../attributes/Description.js"; import { enumValuesAttributeProducer } from "../attributes/EnumValues.js"; +import { + inferredTopLevelSchemaComment, + schemaArrayTypeAttributeKind, +} from "../attributes/InferenceFlags.js"; import { StringTypes } from "../attributes/StringTypes.js"; import { type TypeAttributes, @@ -1039,7 +1043,9 @@ async function addTypesInSchema( async function makeArrayType( arrayAttributes: TypeAttributes, ): Promise { - const singularAttributes = singularizeTypeNames(typeAttributes); + const singularAttributes = makeTypeAttributesInferred( + singularizeTypeNames(typeAttributes), + ); const items = schema.items; // JSON Schema 2020-12 renamed the array (tuple) form of `items` to // `prefixItems`; treat it the same as a draft-07 array-valued @@ -1107,7 +1113,20 @@ async function addTypesInSchema( } typeBuilder.addAttributes(itemType, singularAttributes); - return typeBuilder.getArrayType(arrayAttributes, itemType); + const provenanceAttributes = + schemaArrayTypeAttributeKind.makeAttributes( + schema.$comment === inferredTopLevelSchemaComment + ? "inferred" + : "explicit", + ); + return typeBuilder.getArrayType( + combineTypeAttributes( + "union", + arrayAttributes, + provenanceAttributes, + ), + itemType, + ); } async function makeObjectType(): Promise { diff --git a/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts b/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts index 227a46da75..250d0ea4b0 100644 --- a/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts +++ b/packages/quicktype-core/src/language/JSONSchema/JSONSchemaRenderer.ts @@ -1,6 +1,10 @@ import { iterableFirst, mapFirst } from "collection-utils"; import { addDescriptionToSchema } from "../../attributes/Description.js"; +import { + inferredTopLevelSchemaComment, + schemaArrayTypeAttributeKind, +} from "../../attributes/InferenceFlags.js"; import { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import type { Name, Namer } from "../../Naming.js"; import { defined, panic } from "../../support/Support.js"; @@ -178,10 +182,26 @@ export class JSONSchemaRenderer extends ConvenienceRenderer { protected emitSourceStructure(): void { // FIXME: Find a good way to do multiple top-levels. Maybe multiple files? - const topLevelType = + const topLevel = this.topLevels.size === 1 - ? this.schemaForType(defined(mapFirst(this.topLevels))) - : {}; + ? defined(mapFirst(this.topLevels)) + : undefined; + const topLevelType = + topLevel === undefined ? {} : this.schemaForType(topLevel); + if (topLevel?.kind === "array") { + const provenance = schemaArrayTypeAttributeKind.tryGetInAttributes( + topLevel.getAttributes(), + ); + if ( + provenance === "inferred" || + (provenance === undefined && + topLevel.hasNames && + !topLevel.getNames().areInferred) + ) { + topLevelType.$comment = inferredTopLevelSchemaComment; + } + } + const schema: Schema = { $schema: "http://json-schema.org/draft-06/schema#", ...topLevelType, diff --git a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts index 927ece2c36..f7c91a8f2b 100644 --- a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts +++ b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts @@ -1,5 +1,6 @@ import { arrayIntercalate } from "collection-utils"; +import { schemaArrayTypeAttributeKind } from "../../attributes/InferenceFlags.js"; import { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import { type Name, type Namer, funPrefixNamer } from "../../Naming.js"; import type { RenderContext } from "../../Renderer.js"; @@ -86,6 +87,18 @@ export class JavaScriptRenderer extends ConvenienceRenderer { } protected namedTypeToNameForTopLevel(type: Type): Type | undefined { + // JSON sample arrays collapse to their item type, including when + // round-tripped through quicktype's schema output. Only arrays + // explicitly declared by an input schema retain their wrapper. + if ( + type.kind === "array" && + schemaArrayTypeAttributeKind.tryGetInAttributes( + type.getAttributes(), + ) === "explicit" + ) { + return undefined; + } + return directlyReachableSingleNamedType(type); } diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts index 6ce623d1b5..33f37e9ea9 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts @@ -1,3 +1,4 @@ +import { schemaArrayTypeAttributeKind } from "../../attributes/InferenceFlags.js"; import { type Name, type Namer, funPrefixNamer } from "../../Naming.js"; import type { RenderContext } from "../../Renderer.js"; import type { OptionValues } from "../../RendererOptions/index.js"; @@ -237,15 +238,25 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { } protected emitTypes(): void { - // emit primitive top levels this.forEachTopLevel("none", (t, name) => { - if (!t.isPrimitive()) { + const isSchemaArray = + t instanceof ArrayType && + schemaArrayTypeAttributeKind.tryGetInAttributes( + t.getAttributes(), + ) === "explicit"; + if (!t.isPrimitive() && !isSchemaArray) { return; } this.ensureBlankLine(); this.emitDescription(this.descriptionForType(t)); - this.emitLine("type ", name, " = ", this.sourceFor(t).source, ";"); + this.emitLine( + isSchemaArray ? "export type " : "type ", + name, + " = ", + this.sourceFor(t).source, + ";", + ); }); this.forEachNamedType( diff --git a/test/inputs/schema/top-level-array.1.json b/test/inputs/schema/top-level-array.1.json new file mode 100644 index 0000000000..fd98543f94 --- /dev/null +++ b/test/inputs/schema/top-level-array.1.json @@ -0,0 +1,4 @@ +[ + { "label": "positive", "score": 0.95 }, + { "label": "negative", "score": 0.05 } +] diff --git a/test/inputs/schema/top-level-array.schema b/test/inputs/schema/top-level-array.schema new file mode 100644 index 0000000000..8f8d7ae89f --- /dev/null +++ b/test/inputs/schema/top-level-array.schema @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "TextClassificationOutput", + "type": "array", + "items": { + "type": "object", + "title": "TextClassificationOutputElement", + "properties": { + "label": { "type": "string" }, + "score": { "type": "number" } + }, + "required": ["label", "score"] + } +} diff --git a/test/inputs/schema/top-level-primitive-array.1.json b/test/inputs/schema/top-level-primitive-array.1.json new file mode 100644 index 0000000000..24b776a667 --- /dev/null +++ b/test/inputs/schema/top-level-primitive-array.1.json @@ -0,0 +1 @@ +["one", "two", "three"] diff --git a/test/inputs/schema/top-level-primitive-array.schema b/test/inputs/schema/top-level-primitive-array.schema new file mode 100644 index 0000000000..e142503499 --- /dev/null +++ b/test/inputs/schema/top-level-primitive-array.schema @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "SomeInput", + "type": "array", + "items": { "type": "string" } +} diff --git a/test/languages.ts b/test/languages.ts index 120f3170b0..ed41f59048 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1558,6 +1558,10 @@ export const KotlinJacksonLanguage: Language = { "top-level-enum.schema", "top-level-primitive.schema", "recursive-union-flattening.schema", + // Jackson cannot deserialize the generated ArrayList subclass because + // it has no default constructor. + "top-level-array.schema", + "top-level-primitive-array.schema", // A top-level array is deserialized into an `ArrayList` whose // element type is erased at runtime, so a mistyped element // round-trips instead of failing. @@ -1706,6 +1710,8 @@ export const KotlinXLanguage: Language = { // Top-level array: `typealias TopLevel = JsonArray` doesn't // compile (documented TODO in KotlinXRenderer.ts). "union.schema", + "top-level-array.schema", + "top-level-primitive-array.schema", "issue2680-top-level-array.schema", ], skipMiscJSON: false, @@ -1976,6 +1982,8 @@ export const PHPLanguage: Language = { "top-level-enum.schema", // The driver does not support top-level arrays. "union.schema", + "top-level-array.schema", + "top-level-primitive-array.schema", "issue2680-top-level-array.schema", ], rendererOptions: {}, @@ -2275,6 +2283,10 @@ export const ElixirLanguage: Language = { "required.schema", // The default-value fail sample also relies on required-property enforcement. "default-value.schema", + // The renderer references a nonexistent TopLevelElement module for + // top-level arrays. + "top-level-array.schema", + "top-level-primitive-array.schema", "boolean-subschema.schema", "intersection.schema", "optional-any.schema", diff --git a/test/unit/typescript-top-level-array.test.ts b/test/unit/typescript-top-level-array.test.ts new file mode 100644 index 0000000000..4c5d7ced41 --- /dev/null +++ b/test/unit/typescript-top-level-array.test.ts @@ -0,0 +1,103 @@ +import fs from "node:fs"; + +import { describe, expect, test } from "vitest"; + +import { + InputData, + JSONSchemaInput, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; + +async function typesForSchema(filename: string, name: string): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name, + schema: fs.readFileSync(`test/inputs/schema/${filename}`, "utf8"), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": true }, + }); + return result.lines.join("\n"); +} + +async function typesForJSON(name: string, sample: string): Promise { + const jsonInput = jsonInputForTargetLanguage("typescript"); + await jsonInput.addSource({ name, samples: [sample] }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": true }, + }); + return result.lines.join("\n"); +} + +async function typesForJSONViaSchema( + name: string, + sample: string, +): Promise { + const jsonInput = jsonInputForTargetLanguage("schema"); + await jsonInput.addSource({ name, samples: [sample] }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const schema = await quicktype({ inputData, lang: "schema" }); + + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name, schema: schema.lines.join("\n") }); + const schemaInputData = new InputData(); + schemaInputData.addInput(schemaInput); + const result = await quicktype({ + inputData: schemaInputData, + lang: "typescript", + rendererOptions: { "just-types": true }, + }); + return result.lines.join("\n"); +} + +describe("TypeScript top-level arrays", () => { + test("schema array emits an alias and preserves the item title", async () => { + const output = await typesForSchema( + "top-level-array.schema", + "TextClassificationOutput", + ); + + expect(output).toContain( + "export type TextClassificationOutput = TextClassificationOutputElement[];", + ); + expect(output).toContain( + "export interface TextClassificationOutputElement", + ); + }); + + test("schema array of primitives emits an alias", async () => { + const output = await typesForSchema( + "top-level-primitive-array.schema", + "SomeInput", + ); + + expect(output).toContain("export type SomeInput = string[];"); + }); + + test("JSON sample arrays still collapse to the element type", async () => { + const sample = '[{"label":"a","score":1},{"label":"b","score":2}]'; + const output = await typesForJSON("Sample", sample); + + expect(output).toContain("export interface Sample"); + expect(output).not.toContain("export type Sample ="); + expect(await typesForJSONViaSchema("Sample", sample)).toBe(output); + }); + + test("JSON primitive arrays still round-trip without an alias", async () => { + const sample = '["one","two"]'; + const output = await typesForJSON("Sample", sample); + + expect(output).toBe(""); + expect(await typesForJSONViaSchema("Sample", sample)).toBe(output); + }); +});