From 83408ec8694f7f7c65e36820fcc496586160d2d4 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sat, 7 Nov 2026 10:15:00 +0800 Subject: [PATCH] fix(graphql): preserve names for multiple operations in one document --- packages/quicktype-graphql-input/src/index.ts | 4 +- .../multiple-operations/queries.graphql | 16 +++ .../multiple-operations/responses.json | 12 ++ test/unit/graphql-operations.test.ts | 123 ++++++++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 test/inputs/graphql/multiple-operations/queries.graphql create mode 100644 test/inputs/graphql/multiple-operations/responses.json create mode 100644 test/unit/graphql-operations.test.ts diff --git a/packages/quicktype-graphql-input/src/index.ts b/packages/quicktype-graphql-input/src/index.ts index 2ac6fcbad8..1df5cff508 100644 --- a/packages/quicktype-graphql-input/src/index.ts +++ b/packages/quicktype-graphql-input/src/index.ts @@ -700,7 +700,9 @@ export class GraphQLInput implements Input { ); for (const [actualName, t] of newTopLevels) { typeBuilder.addTopLevel( - this._topLevels.size === 1 ? name : actualName, + this._topLevels.size === 1 && newTopLevels.size === 1 + ? name + : actualName, t, ); } diff --git a/test/inputs/graphql/multiple-operations/queries.graphql b/test/inputs/graphql/multiple-operations/queries.graphql new file mode 100644 index 0000000000..fa7cfd4ea8 --- /dev/null +++ b/test/inputs/graphql/multiple-operations/queries.graphql @@ -0,0 +1,16 @@ +query ViewerQuery { + viewer { + ...UserFields + } +} + +query UserQuery { + user { + ...UserFields + } +} + +fragment UserFields on User { + id + name +} diff --git a/test/inputs/graphql/multiple-operations/responses.json b/test/inputs/graphql/multiple-operations/responses.json new file mode 100644 index 0000000000..9fe17173f5 --- /dev/null +++ b/test/inputs/graphql/multiple-operations/responses.json @@ -0,0 +1,12 @@ +{ + "viewer": { + "data": { + "viewer": { "id": "1", "name": "Alice" } + } + }, + "user": { + "data": { + "user": { "id": "2", "name": "Bob" } + } + } +} diff --git a/test/unit/graphql-operations.test.ts b/test/unit/graphql-operations.test.ts new file mode 100644 index 0000000000..65a1b9b083 --- /dev/null +++ b/test/unit/graphql-operations.test.ts @@ -0,0 +1,123 @@ +import { readFileSync } from "node:fs"; + +import { buildSchema, introspectionFromSchema } from "graphql"; +import { InputData, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +import { GraphQLInput } from "../../packages/quicktype-graphql-input/src"; + +const schema = { + data: introspectionFromSchema( + buildSchema(` + type Query { + viewer: User! + user: User! + } + type Mutation { + renameUser: User! + } + type User { + id: ID! + name: String! + } + `), + ), +}; +const fixtureDirectory = "test/inputs/graphql/multiple-operations"; +const queries = readFileSync(`${fixtureDirectory}/queries.graphql`, "utf8"); +const responses = JSON.parse( + readFileSync(`${fixtureDirectory}/responses.json`, "utf8"), +); + +async function render( + sources: { name: string; query: string }[], + lang = "javascript", +): Promise { + const input = new GraphQLInput(); + for (const source of sources) { + await input.addSource({ ...source, schema }); + } + const inputData = new InputData(); + inputData.addInput(input); + const result = await quicktype({ inputData, lang }); + return result.lines.join("\n"); +} + +interface GeneratedConverters { + toViewerQuery: (json: string) => unknown; + viewerQueryToJson: (value: unknown) => string; + toUserQuery: (json: string) => unknown; + userQueryToJson: (value: unknown) => string; +} + +describe("GraphQL operation names", () => { + test("round-trips each query in a document with a shared fragment", async () => { + const output = await render([{ name: "Response", query: queries }]); + const generatedModule = { exports: {} }; + new Function("module", output)(generatedModule); + const convert = generatedModule.exports as GeneratedConverters; + + const viewer = convert.toViewerQuery(JSON.stringify(responses.viewer)); + expect(JSON.parse(convert.viewerQueryToJson(viewer))).toEqual( + responses.viewer, + ); + const user = convert.toUserQuery(JSON.stringify(responses.user)); + expect(JSON.parse(convert.userQueryToJson(user))).toEqual( + responses.user, + ); + expect(() => + convert.toViewerQuery(JSON.stringify(responses.user)), + ).toThrow(); + expect(() => + convert.toUserQuery(JSON.stringify(responses.viewer)), + ).toThrow(); + }); + + test.each([ + "swift", + "kotlin", + ])("generates both operations for %s", async (lang) => { + const output = await render( + [{ name: "Response", query: queries }], + lang, + ); + expect(output).toMatch(/(?:struct|class) ViewerQuery\b/); + expect(output).toMatch(/(?:struct|class) UserQuery\b/); + }); + + test.each([ + "query ViewerQuery", + "", + ])("preserves the source name for a single operation (%s)", async (operation) => { + const output = await render([ + { name: "CustomResponse", query: `${operation} { viewer { id } }` }, + ]); + expect(output).toContain("function toCustomResponse("); + expect(output).not.toContain("function toViewerQuery("); + }); + + test("uses operation names when there are multiple sources", async () => { + const output = await render([ + { name: "First", query: "query ViewerQuery { viewer { id } }" }, + { name: "Second", query: "query UserQuery { user { id } }" }, + ]); + expect(output).toContain("function toViewerQuery("); + expect(output).toContain("function toUserQuery("); + expect(output).not.toContain("function toFirst("); + expect(output).not.toContain("function toSecond("); + }); + + test("supports a query and mutation in the same document", async () => { + const output = await render([ + { + name: "Response", + query: ` + query ViewerQuery { viewer { id } } + mutation RenameUser { renameUser { id name } } + `, + }, + ]); + expect(output).toContain("function toViewerQuery("); + expect(output).toContain("function toRenameUser("); + }); +});