From 04f7e9ea8c4b60ea3af37bcb4520c93b1ace4e97 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Fri, 19 Oct 2018 09:02:49 -0700 Subject: [PATCH] graphql: generate Flow types from a schema (#927) Summary: Generating Flow types from a structured schema is both straightforward and terribly useful. This commit implements it. Test Plan: Inspect the snapshot for correctness manually. Then, copy it into a new file, remove backslash-escapes, and verify that it passes Flow. A subsequent commit will generate types for the actual GitHub schema. wchargin-branch: graphql-generate-flow-types --- .../generateFlowTypes.test.js.snap | 53 +++++++ src/graphql/generateFlowTypes.js | 118 ++++++++++++++ src/graphql/generateFlowTypes.test.js | 146 ++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 src/graphql/__snapshots__/generateFlowTypes.test.js.snap create mode 100644 src/graphql/generateFlowTypes.js create mode 100644 src/graphql/generateFlowTypes.test.js diff --git a/src/graphql/__snapshots__/generateFlowTypes.test.js.snap b/src/graphql/__snapshots__/generateFlowTypes.test.js.snap new file mode 100644 index 0000000..35f05dc --- /dev/null +++ b/src/graphql/__snapshots__/generateFlowTypes.test.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`graphql/generateFlowTypes generateFlowTypes works on a representative schema 1`] = ` +"// @flow + +// Autogenerated file. Do not edit. + +export type Actor = Human | TalentedChimpanzee; + +export type Color = \\"BLUE\\" | \\"GREEN\\" | \\"RED\\"; + +export type DateTime = string; + +export type Dollars = number; + +export type EmptyEnum = empty; + +export type EmptyUnion = empty; + +export type Human = {| + +__typename: \\"Human\\", + +id: string, + +name: null | String, +|}; + +export type PaintJob = {| + +__typename: \\"PaintJob\\", + +color: Color, + +completed: null | DateTime, + +cost: Dollars, + +designer: null | Actor, + +details: null | {| + +description: null | String, + +moreMetadata: mixed, + +oldColor: Color, + +oldPainter: null | Actor, + |}, + +id: string, + +metadata: mixed, + +painters: $ReadOnlyArray, + +referrer: null | PaintJob, + +relatedWork: $ReadOnlyArray, +|}; + +export type String = string; + +export type TalentedChimpanzee = {| + +__typename: \\"TalentedChimpanzee\\", + +id: string, + +name: null | String, +|}; +" +`; diff --git a/src/graphql/generateFlowTypes.js b/src/graphql/generateFlowTypes.js new file mode 100644 index 0000000..2b1d342 --- /dev/null +++ b/src/graphql/generateFlowTypes.js @@ -0,0 +1,118 @@ +// @flow + +import prettier, {type Options as PrettierOptions} from "prettier"; + +import type { + ConnectionFieldType, + FieldType, + IdFieldType, + NestedFieldType, + NodeFieldType, + PrimitiveFieldType, + Schema, +} from "./schema"; + +export default function generateFlowTypes( + schema: Schema, + prettierOptions: PrettierOptions +): string { + const typedefs = []; + + function formatField(field: FieldType): string { + switch (field.type) { + case "ID": + return formatIdField(field); + case "PRIMITIVE": + return formatPrimitiveField(field); + case "NODE": + return formatNodeField(field); + case "CONNECTION": + return formatConnectionField(field); + case "NESTED": + return formatNestedField(field); + // istanbul ignore next: unreachable per Flow + default: + throw new Error((field.type: empty)); + } + } + function formatIdField(_unused_field: IdFieldType): string { + return "string"; + } + function formatPrimitiveField(field: PrimitiveFieldType): string { + if (field.annotation == null) { + return "mixed"; + } else if (field.annotation.nonNull) { + return field.annotation.elementType; + } else { + return "null | " + field.annotation.elementType; + } + } + function formatNodeField(field: NodeFieldType): string { + return "null | " + field.elementType; + } + function formatConnectionField(field: ConnectionFieldType): string { + return `$ReadOnlyArray`; + } + function formatNestedField(field: NestedFieldType): string { + const eggs = []; + for (const eggName of Object.keys(field.eggs).sort()) { + eggs.push({eggName, rhs: formatField(field.eggs[eggName])}); + } + const eggContents = eggs.map((x) => `+${x.eggName}: ${x.rhs}`).join(", "); + return `null | {|\n${eggContents}\n|}`; + } + + for (const typename of Object.keys(schema).sort()) { + const type = schema[typename]; + switch (type.type) { + case "SCALAR": + typedefs.push(`export type ${typename} = ${type.representation};`); + break; + case "ENUM": { + const rhs = + Object.keys(type.values).length === 0 + ? "empty" + : Object.keys(type.values) + .sort() + .map((x) => JSON.stringify(x)) + .join(" | "); + typedefs.push(`export type ${typename} = ${rhs};`); + break; + } + case "OBJECT": { + const fields = [ + {fieldname: "__typename", rhs: JSON.stringify(typename)}, + ]; + for (const fieldname of Object.keys(type.fields).sort()) { + fields.push({fieldname, rhs: formatField(type.fields[fieldname])}); + } + const fieldContents = fields + .map((x) => `+${x.fieldname}: ${x.rhs}`) + .join(", "); + const rhs = `{|\n${fieldContents}\n|}`; + typedefs.push(`export type ${typename} = ${rhs};`); + break; + } + case "UNION": { + const rhs = + Object.keys(type.clauses).length === 0 + ? "empty" + : Object.keys(type.clauses) + .sort() + .join(" | "); + typedefs.push(`export type ${typename} = ${rhs};`); + break; + } + // istanbul ignore next: unreachable per Flow + default: + throw new Error((type.type: empty)); + } + } + + const rawSource = [ + "// @flow", + "// Autogenerated file. Do not edit.", + ...typedefs, + ].join("\n\n"); + return prettier.format(rawSource, prettierOptions); +} diff --git a/src/graphql/generateFlowTypes.test.js b/src/graphql/generateFlowTypes.test.js new file mode 100644 index 0000000..079e562 --- /dev/null +++ b/src/graphql/generateFlowTypes.test.js @@ -0,0 +1,146 @@ +// @flow + +import generateFlowTypes from "./generateFlowTypes"; +import * as Schema from "./schema"; + +describe("graphql/generateFlowTypes", () => { + function run(schema: Schema.Schema): string { + return generateFlowTypes(schema, {parser: "babylon", trailingComma: "es5"}); + } + + describe("generateFlowTypes", () => { + it("works on a representative schema", () => { + // This schema should be constructed as to singlehandedly achieve + // full test coverage. + const s = Schema; + const schema = s.schema({ + String: s.scalar("string"), + DateTime: s.scalar("string"), + Dollars: s.scalar("number"), + Color: s.enum(["RED", "GREEN", "BLUE"]), + PaintJob: s.object({ + id: s.id(), + metadata: s.primitive(/* unannotated */), + cost: s.primitive(s.nonNull("Dollars")), + completed: s.primitive(s.nullable("DateTime")), + color: s.primitive(s.nonNull("Color")), + designer: s.node("Actor"), + painters: s.connection("Actor"), + referrer: s.node("PaintJob"), + relatedWork: s.connection("PaintJob"), + details: s.nested({ + moreMetadata: s.primitive(), + description: s.primitive(s.nullable("String")), + oldColor: s.primitive(s.nonNull("Color")), + oldPainter: s.node("Actor"), + }), + }), + Actor: s.union(["Human", "TalentedChimpanzee"]), + Human: s.object({ + id: s.id(), + name: s.primitive(s.nullable("String")), + }), + TalentedChimpanzee: s.object({ + id: s.id(), + name: s.primitive(s.nullable("String")), + }), + EmptyEnum: s.enum([]), + EmptyUnion: s.union([]), + }); + expect(run(schema)).toMatchSnapshot(); + }); + + it("respects the Prettier options", () => { + const s = Schema; + const schema = s.schema({ + E: s.enum(["ONE", "TWO"]), + }); + const options1 = {parser: "babylon", singleQuote: false}; + const options2 = {parser: "babylon", singleQuote: true}; + const output1 = generateFlowTypes(schema, options1); + const output2 = generateFlowTypes(schema, options2); + expect(output1).not.toEqual(output2); + }); + + it("is invariant with respect to type definition order", () => { + const s = Schema; + const s1 = s.schema({ + A: s.object({id: s.id()}), + B: s.object({id: s.id()}), + }); + const s2 = s.schema({ + B: s.object({id: s.id()}), + A: s.object({id: s.id()}), + }); + expect(run(s1)).toEqual(run(s2)); + }); + + it("is invariant with respect to enum clause order", () => { + const s = Schema; + const s1 = s.schema({ + E: s.enum(["ONE", "TWO"]), + }); + const s2 = s.schema({ + E: s.enum(["TWO", "ONE"]), + }); + expect(run(s1)).toEqual(run(s2)); + }); + + it("is invariant with respect to object field order", () => { + const s = Schema; + const s1 = s.schema({ + O: s.object({ + id: s.id(), + x: s.primitive(), + y: s.primitive(), + }), + }); + const s2 = s.schema({ + O: s.object({ + id: s.id(), + y: s.primitive(), + x: s.primitive(), + }), + }); + expect(run(s1)).toEqual(run(s2)); + }); + + it("is invariant with respect to nested egg order", () => { + const s = Schema; + const s1 = s.schema({ + O: s.object({ + id: s.id(), + nest: s.nested({ + x: s.primitive(), + y: s.primitive(), + }), + }), + }); + const s2 = s.schema({ + O: s.object({ + id: s.id(), + nest: s.nested({ + y: s.primitive(), + x: s.primitive(), + }), + }), + }); + expect(run(s1)).toEqual(run(s2)); + }); + + it("is invariant with respect to union clause order", () => { + const s = Schema; + const s1 = s.schema({ + A: s.object({id: s.id()}), + B: s.object({id: s.id()}), + U: s.union(["A", "B"]), + }); + const s2 = s.schema({ + A: s.object({id: s.id()}), + B: s.object({id: s.id()}), + U: s.union(["B", "A"]), + }); + expect(run(s1)).toEqual(run(s2)); + }); + }); +});