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
This commit is contained in:
William Chargin 2018-09-13 18:02:14 -07:00 committed by GitHub
parent 7074a9dbd8
commit 7da9ef3a94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 207 additions and 0 deletions

96
src/graphql/schema.js Normal file
View File

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

111
src/graphql/schema.test.js Normal file
View File

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