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
This commit is contained in:
William Chargin 2018-10-19 09:02:49 -07:00 committed by GitHub
parent e787db53c4
commit 04f7e9ea8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 317 additions and 0 deletions

View File

@ -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<null | Actor>,
+referrer: null | PaintJob,
+relatedWork: $ReadOnlyArray<null | PaintJob>,
|};
export type String = string;
export type TalentedChimpanzee = {|
+__typename: \\"TalentedChimpanzee\\",
+id: string,
+name: null | String,
|};
"
`;

View File

@ -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<null | ${field.elementType}>`;
}
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);
}

View File

@ -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));
});
});
});