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:
parent
7074a9dbd8
commit
7da9ef3a94
|
@ -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};
|
||||
}
|
|
@ -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"]));
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue