From 7da9ef3a94ac53e80cc733d2e41aacaa93a3e72d Mon Sep 17 00:00:00 2001 From: William Chargin Date: Thu, 13 Sep 2018 18:02:14 -0700 Subject: [PATCH] graphql: add a schema module (#834) Summary: This commit introduces a module for declaratively specifying the schema of a GraphQL database. See `buildGithubSchema` in `schema.test.js` for an example of the API. This makes progress toward #622, though the design has evolved some since its original specification there. Test Plan: Unit tests added, with full coverage; `yarn unit` suffices. wchargin-branch: graphql-schema --- src/graphql/schema.js | 96 ++++++++++++++++++++++++++++++++ src/graphql/schema.test.js | 111 +++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/graphql/schema.js create mode 100644 src/graphql/schema.test.js diff --git a/src/graphql/schema.js b/src/graphql/schema.js new file mode 100644 index 0000000..34160f9 --- /dev/null +++ b/src/graphql/schema.js @@ -0,0 +1,96 @@ +// @flow + +/** + * Data types to describe a particular subset of GraphQL schemata. + * Schemata represented by this module must satisfy these constraints: + * + * - Every object must have an `id` field of primitive type. + * - Every field of an object must be either a primitive, a reference + * to a single (possibly nullable) object, or a _connection_ as + * described in the Relay cursor connections specification. In + * particular, no field may directly contain a list. + * - Interface types must be represented as unions of all their + * implementations. + */ + +// The name of a GraphQL type, like `Repository` or `Int`. +export type Typename = string; + +// The name of a GraphQL object field, like `name` or `pullRequests`. +export type Fieldname = string; + +// The database-wide unique ID of a GraphQL object. +export type ObjectId = string; + +// Description of a GraphQL schema. Types are represented as follows: +// - An object type is represented directly as an `OBJECT`. +// - A union type is represented directly as a `UNION`. +// - An interface type is represented as a `UNION` of all its +// implementations. +// - Scalars and enums may only occur as object fields, and are +// represented as `PRIMITIVE`s (except for `ID`s). +// - Connections are supported as object fields, but arbitrary lists +// are not. +export type Schema = {+[Typename]: NodeType}; +export type NodeType = + | {|+type: "OBJECT", +fields: {+[Fieldname]: FieldType}|} + | {|+type: "UNION", +clauses: {+[Typename]: true}|}; +export type FieldType = + | {|+type: "ID"|} + | {|+type: "PRIMITIVE"|} + | {|+type: "NODE", +elementType: Typename|} + | {|+type: "CONNECTION", +elementType: Typename|}; + +// Every object must have exactly one `id` field, and it must have this +// name. +const ID_FIELD_NAME = "id"; + +export function schema(types: {[Typename]: NodeType}): Schema { + return {...types}; +} + +export function object(fields: {[Fieldname]: FieldType}): NodeType { + for (const fieldname of Object.keys(fields)) { + const field = fields[fieldname]; + if (fieldname === "__typename") { + throw new Error("reserved field name: " + fieldname); + } + if (field.type === "ID" && fieldname !== ID_FIELD_NAME) { + throw new Error(`invalid ID field with name "${fieldname}"`); + } + } + if (fields[ID_FIELD_NAME] == null) { + throw new Error(`expected ID field with name "${ID_FIELD_NAME}"`); + } + if (fields[ID_FIELD_NAME].type !== "ID") { + throw new Error(`field "${ID_FIELD_NAME}" must be an ID field`); + } + return {type: "OBJECT", fields: {...fields}}; +} + +export function union(clauses: $ReadOnlyArray): NodeType { + const clausesMap = {}; + for (const clause of clauses) { + if (clausesMap[clause] != null) { + throw new Error(`duplicate union clause: "${clause}"`); + } + clausesMap[clause] = true; + } + return {type: "UNION", clauses: clausesMap}; +} + +export function id(): FieldType { + return {type: "ID"}; +} + +export function primitive(): FieldType { + return {type: "PRIMITIVE"}; +} + +export function node(elementType: Typename): FieldType { + return {type: "NODE", elementType}; +} + +export function connection(elementType: Typename): FieldType { + return {type: "CONNECTION", elementType}; +} diff --git a/src/graphql/schema.test.js b/src/graphql/schema.test.js new file mode 100644 index 0000000..1fc951b --- /dev/null +++ b/src/graphql/schema.test.js @@ -0,0 +1,111 @@ +// @flow + +import * as Schema from "./schema"; + +describe("graphql/schema", () => { + function buildGithubSchema(): Schema.Schema { + const s = Schema; + return s.schema({ + Repository: s.object({ + id: s.id(), + url: s.primitive(), + issues: s.connection("Issue"), + }), + Issue: s.object({ + id: s.id(), + url: s.primitive(), + author: s.node("Actor"), + parent: s.node("Repository"), + title: s.primitive(), + comments: s.connection("IssueComment"), + }), + IssueComment: s.object({ + id: s.id(), + body: s.primitive(), + author: s.node("Actor"), + }), + Actor: s.union(["User", "Bot", "Organization"]), // actually an interface + User: s.object({ + id: s.id(), + url: s.primitive(), + login: s.primitive(), + }), + Bot: s.object({ + id: s.id(), + url: s.primitive(), + login: s.primitive(), + }), + Organization: s.object({ + id: s.id(), + url: s.primitive(), + login: s.primitive(), + }), + }); + } + + it("builds a representative schema", () => { + const githubSchema = buildGithubSchema(); + expect(typeof githubSchema).toBe("object"); + }); + it("passes through serialization unscathed", () => { + const schema = buildGithubSchema(); + expect(JSON.parse(JSON.stringify(schema))).toEqual(schema); + }); + + describe("object", () => { + const s = Schema; + it('requires an "id" field', () => { + expect(() => s.object({})).toThrow('expected ID field with name "id"'); + }); + it('requires field "id" to be an ID', () => { + expect(() => s.object({id: s.primitive()})).toThrow( + 'field "id" must be an ID field' + ); + }); + it("prohibits unexpected ID fields", () => { + expect(() => s.object({id: s.id(), di: s.id()})).toThrow( + 'invalid ID field with name "di"' + ); + }); + it("prohibits a field called __typename", () => { + expect(() => s.object({id: s.id(), __typename: s.primitive()})).toThrow( + "reserved field name: __typename" + ); + }); + it("builds reasonable objects", () => { + const o1 = s.object({id: s.id()}); + const o2 = s.object({ + id: s.id(), + name: s.primitive(), + widget: s.node("Widget"), + mcguffins: s.connection("McGuffin"), + }); + expect(o1).not.toEqual(o2); + }); + it("is invariant with respect to field order", () => { + const o1 = s.object({id: s.id(), x: s.primitive(), y: s.node("Y")}); + const o2 = s.object({y: s.node("Y"), x: s.primitive(), id: s.id()}); + expect(o1).toEqual(o2); + }); + }); + + describe("union", () => { + const s = Schema; + it("permits the empty union", () => { + s.union([]); + }); + it("forbids duplicate clauses", () => { + expect(() => s.union(["One", "One"])).toThrow( + 'duplicate union clause: "One"' + ); + }); + it("builds reasonable unions", () => { + const u1 = s.union(["A", "B"]); + const u2 = s.union(["B", "C"]); + expect(u1).not.toEqual(u2); + }); + it("is invariant with respect to clause order", () => { + expect(s.union(["One", "Two"])).toEqual(s.union(["Two", "One"])); + }); + }); +});