diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 66b1c4104..a1aaddf94 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -544,7 +544,7 @@ class Canonizer { this.addIDs( schema, - new Location(Ref.root(address), Ref.root(undefined)), + new Location(Ref.parse(address), Ref.root(undefined)), ); this._schemaAddressesAdded.add(address); return true; diff --git a/test/fixtures.ts b/test/fixtures.ts index 84616537e..007603afe 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -809,7 +809,38 @@ class JSONSchemaFixture extends LanguageFixture { } getSamples(sources: string[]): { priority: Sample[]; others: Sample[] } { - const prioritySamples = testsInDir("test/inputs/schema/", "schema"); + const schemaDirectory = "test/inputs/schema/"; + const multiFileSamples = fs + .readdirSync(schemaDirectory) + .map((entry) => path.join(schemaDirectory, entry)) + .filter( + (entry) => + fs.statSync(entry).isDirectory() && + fs + .readdirSync(entry) + .some((filename) => filename.endsWith(".schema")), + ); + const prioritySamples = testsInDir(schemaDirectory, "schema").concat( + multiFileSamples, + ); + + if ( + sources.length === 1 && + fs.lstatSync(sources[0]).isDirectory() && + path.resolve(sources[0]) !== path.resolve(schemaDirectory) + ) { + return { + priority: [ + { + path: sources[0], + additionalRendererOptions: {}, + saveOutput: true, + }, + ], + others: [], + }; + } + const samples = samplesFromSources( sources, prioritySamples, diff --git a/test/inputs/schema/issue-1833.1.fail.no-defaults.json b/test/inputs/schema/issue-1833.1.fail.no-defaults.json new file mode 100644 index 000000000..1c7faa2ba --- /dev/null +++ b/test/inputs/schema/issue-1833.1.fail.no-defaults.json @@ -0,0 +1,8 @@ +{ + "a": { + "name": "root-a", + "in": [ + { "name": "first" } + ] + } +} diff --git a/test/inputs/schema/issue-1833.1.json b/test/inputs/schema/issue-1833.1.json new file mode 100644 index 000000000..cfef1715e --- /dev/null +++ b/test/inputs/schema/issue-1833.1.json @@ -0,0 +1,15 @@ +{ + "a": { + "name": "root-a", + "in": [ + { "name": "first" }, + { "name": "second" } + ] + }, + "b": { + "name": "root-b", + "out": [ + { "name": "third" } + ] + } +} diff --git a/test/inputs/schema/issue-1833/a.schema b/test/inputs/schema/issue-1833/a.schema new file mode 100644 index 000000000..1ac0836bb --- /dev/null +++ b/test/inputs/schema/issue-1833/a.schema @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/a", + "type": "object", + "title": "A", + "properties": { + "name": { "type": "string" }, + "in": { + "type": "array", + "items": { "$ref": "/schemas/c" } + } + }, + "required": ["name", "in"] +} diff --git a/test/inputs/schema/issue-1833/b.schema b/test/inputs/schema/issue-1833/b.schema new file mode 100644 index 000000000..ae9736303 --- /dev/null +++ b/test/inputs/schema/issue-1833/b.schema @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/b", + "type": "object", + "title": "B", + "properties": { + "name": { "type": "string" }, + "out": { + "type": "array", + "items": { "$ref": "/schemas/c" } + } + }, + "required": ["name", "out"] +} diff --git a/test/inputs/schema/issue-1833/c.schema b/test/inputs/schema/issue-1833/c.schema new file mode 100644 index 000000000..6d9351247 --- /dev/null +++ b/test/inputs/schema/issue-1833/c.schema @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/c", + "type": "object", + "title": "C", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] +} diff --git a/test/inputs/schema/issue-1833/top-level.schema b/test/inputs/schema/issue-1833/top-level.schema new file mode 100644 index 000000000..f2932c40d --- /dev/null +++ b/test/inputs/schema/issue-1833/top-level.schema @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/top-level", + "type": "object", + "title": "TopLevel", + "properties": { + "a": { "$ref": "/schemas/a" }, + "b": { "$ref": "/schemas/b" } + }, + "required": ["a", "b"] +} diff --git a/test/languages.ts b/test/languages.ts index 120f3170b..bc2963cfc 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -263,6 +263,10 @@ export const JavaLanguage: Language = { skipSchema: [ "integer-before-number.schema", // Python-specific union-order regression. "keyword-unions.schema", // generates classes with names that are case-insensitively equal + // Multi-top-level directory input; the Java driver deserializes via the + // generic `Converter.fromJsonString`, which is only emitted for a single + // top-level (multi-top-level emits per-type `FromJsonString`). + "issue-1833", // The generated converter deserializes a top-level array with a raw // `List`, so a mistyped element round-trips instead of failing. ...skipsArrayElementValidation, @@ -678,6 +682,9 @@ export const CJSONLanguage: Language = { /* Required properties absent are not checked (for the current implementation, can be added later, should abord parsing and return NULL) */ "intersection.schema", "required.schema", + /* Multi-top-level directory input whose only negative case is a missing + * required property, which this renderer does not enforce (see above). */ + "issue-1833", // The default-value fail sample also relies on required-property // enforcement, which cJSON does not do. "default-value.schema", @@ -938,6 +945,9 @@ export const ElmLanguage: Language = { // The generated decoder accepts invalid union members because all // class properties decode via `Jpipe.optional`. "nested-intersection-union.schema", + // Multi-top-level directory input; the Elm driver decodes a single + // top-level named `QuickType`, which this fixture does not produce. + "issue-1833", ], rendererOptions: {}, // `list` is the default now; keep the `Array` code path covered. @@ -1012,6 +1022,9 @@ export const SwiftLanguage: Language = { "class-with-additional.schema", "vega-lite.schema", "top-level-primitive.schema", + // Multi-top-level directory input whose only negative case is a missing + // required property, which the Swift driver does not treat as a failure. + "issue-1833", ], rendererOptions: { "support-linux": "true" }, quickTestRendererOptions: [ @@ -1082,7 +1095,13 @@ export const ObjectiveCLanguage: Language = { "combinations4.json", ], skipMiscJSON: false, - skipSchema: ["integer-before-number.schema"], // Python-specific union-order regression. + skipSchema: [ + "integer-before-number.schema", // Python-specific union-order regression. + // Multi-top-level directory input; the Objective-C driver decodes a + // single top-level named `QTTopLevel`, which this fixture does not + // produce. + "issue-1833", + ], rendererOptions: { functions: "true" }, quickTestRendererOptions: [], sourceFiles: ["src/language/Objective-C/index.ts"], @@ -1921,6 +1940,9 @@ export const HaskellLanguage: Language = { // The default-value fail sample also relies on required-property enforcement. "default-value.schema", "required-non-properties.schema", + // Multi-top-level directory input; the Haskell driver decodes a single + // top-level named `QuickType`, which this fixture does not produce. + "issue-1833", ], rendererOptions: {}, // The default is array-type=list; this keeps the Vector code path @@ -2090,6 +2112,9 @@ export const TypeScriptZodLanguage: Language = { // The default-value fail sample also relies on required-property enforcement. "default-value.schema", "required-non-properties.schema", + // Multi-top-level directory input whose only negative case is a missing + // required property, which this fixture does not treat as a failure. + "issue-1833", ], rendererOptions: {}, quickTestRendererOptions: [], @@ -2209,6 +2234,9 @@ export const TypeScriptEffectSchemaLanguage: Language = { // The default-value fail sample also relies on required-property enforcement. "default-value.schema", "required-non-properties.schema", + // Multi-top-level directory input whose only negative case is a missing + // required property, which this fixture does not treat as a failure. + "issue-1833", "issue2680-top-level-array.schema", ], rendererOptions: {}, @@ -2285,6 +2313,9 @@ export const ElixirLanguage: Language = { // The generated top-level type is not emitted as a TopLevel module the fixture can call. "recursive-union-flattening.schema", + // Multi-top-level directory input whose only negative case is a missing + // required property, which Elixir cannot enforce at runtime (see above). + "issue-1833", // A top-level array is deserialized without enforcing its element // type, so a mistyped element round-trips instead of failing. diff --git a/test/unit/json-schema-multiple-sources.test.ts b/test/unit/json-schema-multiple-sources.test.ts new file mode 100644 index 000000000..1538b6941 --- /dev/null +++ b/test/unit/json-schema-multiple-sources.test.ts @@ -0,0 +1,42 @@ +// The fixture runner exercises this multi-file input end to end, but duplicate +// structural interfaces still compile. Assert here that the dead interface is +// not generated. +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +const fixtureDirectory = "test/inputs/schema/issue-1833"; + +async function generateTypeScript(): Promise { + const schemaInput = new JSONSchemaInput(undefined); + for (const filename of ["a.schema", "b.schema", "c.schema"]) { + const fixturePath = path.resolve(fixtureDirectory, filename); + await schemaInput.addSource({ + name: path.basename(filename, ".schema"), + schema: fs.readFileSync(fixturePath, "utf8"), + uris: [fixturePath], + }); + } + + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": true }, + }); + return result.lines.join("\n"); +} + +describe("JSON Schema multiple sources (issue #1833)", () => { + test("a top-level schema and references to its $id share one type", async () => { + const output = await generateTypeScript(); + + expect(output).toContain("in: C[];"); + expect(output).toContain("out: C[];"); + expect(output.match(/export interface C/g)).toHaveLength(1); + expect(output).not.toContain("export interface InElement"); + }); +});