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