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
23 changes: 23 additions & 0 deletions packages/quicktype-core/src/attributes/InferenceFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { TypeAttributeKind } from "./TypeAttributes.js";

export type SchemaArrayProvenance = "explicit" | "inferred";

class SchemaArrayTypeAttributeKind extends TypeAttributeKind<SchemaArrayProvenance> {
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";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is stupid. Remove this, and fix the fallout properly!

23 changes: 21 additions & 2 deletions packages/quicktype-core/src/input/JSONSchemaInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1039,7 +1043,9 @@ async function addTypesInSchema(
async function makeArrayType(
arrayAttributes: TypeAttributes,
): Promise<TypeRef> {
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
Expand Down Expand Up @@ -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<TypeRef> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions test/inputs/schema/top-level-array.1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[
{ "label": "positive", "score": 0.95 },
{ "label": "negative", "score": 0.05 }
]
14 changes: 14 additions & 0 deletions test/inputs/schema/top-level-array.schema
Original file line number Diff line number Diff line change
@@ -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"]
}
}
1 change: 1 addition & 0 deletions test/inputs/schema/top-level-primitive-array.1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["one", "two", "three"]
6 changes: 6 additions & 0 deletions test/inputs/schema/top-level-primitive-array.schema
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-06/schema#",
"title": "SomeInput",
"type": "array",
"items": { "type": "string" }
}
12 changes: 12 additions & 0 deletions test/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long>` whose
// element type is erased at runtime, so a mistyped element
// round-trips instead of failing.
Expand Down Expand Up @@ -1706,6 +1710,8 @@ export const KotlinXLanguage: Language = {
// Top-level array: `typealias TopLevel = JsonArray<T>` 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,
Expand Down Expand Up @@ -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: {},
Expand Down Expand Up @@ -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",
Expand Down
103 changes: 103 additions & 0 deletions test/unit/typescript-top-level-array.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<string> {
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);
});
});
Loading