schema: allow annotating primitive field types (#926)

Summary:
Prior to this change, primitive fields were un\[i\]typed. This commit
allows adding type annotations. Such annotations do not change the
semantics at all, but we will be able to use them to generate Flow types
corresponding to a schema.

This commit also strengthens the validation on schemata to catch some
errors that would previously have gone unnoticed at schema construction
time: e.g., a node reference to a type that does not exist.

Test Plan:
Unit tests updated, retaining full coverage. The demo script continues
to work when loading `example-github` or `sourcecred`.

wchargin-branch: schema-annotated-primitives
This commit is contained in:
William Chargin 2018-10-19 09:00:52 -07:00 committed by GitHub
parent 4558d775a5
commit e787db53c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 330 additions and 7 deletions

View File

@ -808,6 +808,15 @@ export class Mirror {
} }
const b = Queries.build; const b = Queries.build;
switch (type.type) { switch (type.type) {
case "SCALAR":
throw new Error(
"Cannot create selections for scalar type: " +
JSON.stringify(typename)
);
case "ENUM":
throw new Error(
"Cannot create selections for enum type: " + JSON.stringify(typename)
);
case "OBJECT": case "OBJECT":
return [b.field("__typename"), b.field("id")]; return [b.field("__typename"), b.field("id")];
case "UNION": case "UNION":
@ -1831,6 +1840,12 @@ export function _buildSchemaInfo(schema: Schema.Schema): SchemaInfo {
for (const typename of Object.keys(schema)) { for (const typename of Object.keys(schema)) {
const type = schema[typename]; const type = schema[typename];
switch (type.type) { switch (type.type) {
case "SCALAR":
// Nothing to do.
break;
case "ENUM":
// Nothing to do.
break;
case "OBJECT": { case "OBJECT": {
const entry: {| const entry: {|
+fields: {|+[Schema.Fieldname]: Schema.FieldType|}, +fields: {|+[Schema.Fieldname]: Schema.FieldType|},

View File

@ -41,6 +41,16 @@ describe("graphql/mirror", () => {
function buildGithubSchema(): Schema.Schema { function buildGithubSchema(): Schema.Schema {
const s = Schema; const s = Schema;
const types: {[Schema.Typename]: Schema.NodeType} = { const types: {[Schema.Typename]: Schema.NodeType} = {
URI: s.scalar("string"),
Date: s.scalar("string"),
ReactionContent: s.enum([
"THUMBS_UP",
"THUMBS_DOWN",
"LAUGH",
"HOORAY",
"CONFUSED",
"HEART",
]),
Repository: s.object({ Repository: s.object({
id: s.id(), id: s.id(),
url: s.primitive(), url: s.primitive(),
@ -48,7 +58,7 @@ describe("graphql/mirror", () => {
}), }),
Issue: s.object({ Issue: s.object({
id: s.id(), id: s.id(),
url: s.primitive(), url: s.primitive(s.nonNull("URI")),
author: s.node("Actor"), author: s.node("Actor"),
repository: s.node("Repository"), repository: s.node("Repository"),
title: s.primitive(), title: s.primitive(),
@ -64,7 +74,7 @@ describe("graphql/mirror", () => {
id: s.id(), id: s.id(),
oid: s.primitive(), oid: s.primitive(),
author: /* GitActor */ s.nested({ author: /* GitActor */ s.nested({
date: s.primitive(), date: s.primitive(s.nonNull("Date")),
user: s.node("User"), user: s.node("User"),
}), }),
}), }),
@ -1142,6 +1152,20 @@ describe("graphql/mirror", () => {
mirror._queryShallow("Wat"); mirror._queryShallow("Wat");
}).toThrow('No such type: "Wat"'); }).toThrow('No such type: "Wat"');
}); });
it("fails when given a scalar type", () => {
const db = new Database(":memory:");
const mirror = new Mirror(db, buildGithubSchema());
expect(() => {
mirror._queryShallow("Date");
}).toThrow('Cannot create selections for scalar type: "Date"');
});
it("fails when given an enum type", () => {
const db = new Database(":memory:");
const mirror = new Mirror(db, buildGithubSchema());
expect(() => {
mirror._queryShallow("ReactionContent");
}).toThrow('Cannot create selections for enum type: "ReactionContent"');
});
it("handles an object type", () => { it("handles an object type", () => {
const db = new Database(":memory:"); const db = new Database(":memory:");
const mirror = new Mirror(db, buildGithubSchema()); const mirror = new Mirror(db, buildGithubSchema());
@ -3143,7 +3167,7 @@ describe("graphql/mirror", () => {
expect(result.objectTypes["Commit"].nestedFieldNames).toEqual(["author"]); expect(result.objectTypes["Commit"].nestedFieldNames).toEqual(["author"]);
expect(result.objectTypes["Commit"].nestedFields).toEqual({ expect(result.objectTypes["Commit"].nestedFields).toEqual({
author: { author: {
primitives: {date: Schema.primitive()}, primitives: {date: Schema.primitive(Schema.nonNull("Date"))},
nodes: {user: Schema.node("User")}, nodes: {user: Schema.node("User")},
}, },
}); });

View File

@ -32,6 +32,9 @@ export type ObjectId = string;
// - Connections are supported as object fields, but arbitrary lists // - Connections are supported as object fields, but arbitrary lists
// are not. // are not.
// //
// Primitive and enum fields on an object type may optionally be
// annotated with their representations.
//
// To accommodate schemata where some object types do not have IDs, // To accommodate schemata where some object types do not have IDs,
// objects may have "nested" fields of primitive or node-reference type. // objects may have "nested" fields of primitive or node-reference type.
// These may be nested to depth exactly 1. Suppose that `Foo` is an // These may be nested to depth exactly 1. Suppose that `Foo` is an
@ -46,6 +49,8 @@ export type ObjectId = string;
// contains the eggs.) // contains the eggs.)
export type Schema = {+[Typename]: NodeType}; export type Schema = {+[Typename]: NodeType};
export type NodeType = export type NodeType =
| {|+type: "SCALAR", +representation: "string" | "number" | "boolean"|}
| {|+type: "ENUM", +values: {|+[string]: true|}|}
| {|+type: "OBJECT", +fields: {|+[Fieldname]: FieldType|}|} | {|+type: "OBJECT", +fields: {|+[Fieldname]: FieldType|}|}
| {|+type: "UNION", +clauses: {|+[Typename]: true|}|}; | {|+type: "UNION", +clauses: {|+[Typename]: true|}|};
@ -56,7 +61,14 @@ export type FieldType =
| ConnectionFieldType | ConnectionFieldType
| NestedFieldType; | NestedFieldType;
export type IdFieldType = {|+type: "ID"|}; export type IdFieldType = {|+type: "ID"|};
export type PrimitiveFieldType = {|+type: "PRIMITIVE"|}; export type PrimitiveFieldType = {|
+type: "PRIMITIVE",
+annotation: null | PrimitiveTypeAnnotation,
|};
export type PrimitiveTypeAnnotation = {|
+nonNull: boolean,
+elementType: Typename,
|};
export type NodeFieldType = {|+type: "NODE", +elementType: Typename|}; export type NodeFieldType = {|+type: "NODE", +elementType: Typename|};
export type ConnectionFieldType = {| export type ConnectionFieldType = {|
+type: "CONNECTION", +type: "CONNECTION",
@ -76,7 +88,89 @@ export function schema(types: {[Typename]: NodeType}): Schema {
for (const typename of Object.keys(types)) { for (const typename of Object.keys(types)) {
const type = types[typename]; const type = types[typename];
switch (type.type) { switch (type.type) {
case "SCALAR":
result[typename] = {
type: "SCALAR",
representation: type.representation,
};
break;
case "ENUM":
result[typename] = {type: "ENUM", values: {...type.values}};
break;
case "OBJECT": case "OBJECT":
for (const fieldname of Object.keys(type.fields)) {
const field = type.fields[fieldname];
function assertKind(path, elementTypename, validKinds) {
const self = `field ${path
.map((x) => JSON.stringify(x))
.join("/")}`;
const elementType = types[elementTypename];
if (elementType == null) {
throw new Error(`${self} has unknown type: "${elementTypename}"`);
}
if (!validKinds.includes(elementType.type)) {
throw new Error(
`${self} has invalid type "${elementTypename}" ` +
`of kind "${elementType.type}"`
);
}
}
switch (field.type) {
case "ID":
// Nothing to check.
break;
case "PRIMITIVE":
if (field.annotation != null) {
assertKind(
[typename, fieldname],
field.annotation.elementType,
["SCALAR", "ENUM"]
);
}
break;
case "NODE":
assertKind([typename, fieldname], field.elementType, [
"OBJECT",
"UNION",
]);
break;
case "CONNECTION":
assertKind([typename, fieldname], field.elementType, [
"OBJECT",
"UNION",
]);
break;
case "NESTED":
for (const eggName of Object.keys(field.eggs)) {
const egg = field.eggs[eggName];
switch (egg.type) {
case "PRIMITIVE":
if (egg.annotation != null) {
assertKind(
[typename, fieldname, eggName],
egg.annotation.elementType,
["SCALAR", "ENUM"]
);
}
break;
case "NODE":
assertKind(
[typename, fieldname, eggName],
egg.elementType,
["OBJECT", "UNION"]
);
break;
// istanbul ignore next: unreachable per Flow
default:
throw new Error((egg.type: empty));
}
}
break;
// istanbul ignore next: unreachable per Flow
default:
throw new Error((field.type: empty));
}
}
result[typename] = {type: "OBJECT", fields: {...type.fields}}; result[typename] = {type: "OBJECT", fields: {...type.fields}};
break; break;
case "UNION": case "UNION":
@ -107,6 +201,21 @@ export function schema(types: {[Typename]: NodeType}): Schema {
return result; return result;
} }
export function scalar(
representation: "string" | "number" | "boolean"
): NodeType {
return {type: "SCALAR", representation};
}
function enum_(values: $ReadOnlyArray<string>): NodeType {
const valuesObject: {|[string]: true|} = ({}: any);
for (const v of values) {
valuesObject[v] = true;
}
return {type: "ENUM", values: valuesObject};
}
export {enum_ as enum};
export function object(fields: {[Fieldname]: FieldType}): NodeType { export function object(fields: {[Fieldname]: FieldType}): NodeType {
for (const fieldname of Object.keys(fields)) { for (const fieldname of Object.keys(fields)) {
const field = fields[fieldname]; const field = fields[fieldname];
@ -141,8 +250,10 @@ export function id(): IdFieldType {
return {type: "ID"}; return {type: "ID"};
} }
export function primitive(): PrimitiveFieldType { export function primitive(
return {type: "PRIMITIVE"}; annotation?: PrimitiveTypeAnnotation
): PrimitiveFieldType {
return {type: "PRIMITIVE", annotation: annotation || null};
} }
export function node(elementType: Typename): NodeFieldType { export function node(elementType: Typename): NodeFieldType {
@ -153,6 +264,14 @@ export function connection(elementType: Typename): ConnectionFieldType {
return {type: "CONNECTION", elementType}; return {type: "CONNECTION", elementType};
} }
export function nonNull(elementType: Typename): PrimitiveTypeAnnotation {
return {nonNull: true, elementType};
}
export function nullable(elementType: Typename): PrimitiveTypeAnnotation {
return {nonNull: false, elementType};
}
export function nested(eggs: { export function nested(eggs: {
+[Fieldname]: PrimitiveFieldType | NodeFieldType, +[Fieldname]: PrimitiveFieldType | NodeFieldType,
}): NestedFieldType { }): NestedFieldType {

View File

@ -6,6 +6,8 @@ describe("graphql/schema", () => {
function buildGithubTypes(): {[Schema.Typename]: Schema.NodeType} { function buildGithubTypes(): {[Schema.Typename]: Schema.NodeType} {
const s = Schema; const s = Schema;
return { return {
DateTime: s.scalar("string"),
Color: s.enum(["RED", "GREEN", "BLUE"]),
Repository: s.object({ Repository: s.object({
id: s.id(), id: s.id(),
url: s.primitive(), url: s.primitive(),
@ -23,7 +25,7 @@ describe("graphql/schema", () => {
id: s.id(), id: s.id(),
oid: s.primitive(), oid: s.primitive(),
author: /* GitActor */ s.nested({ author: /* GitActor */ s.nested({
date: s.primitive(), date: s.primitive(s.nonNull("DateTime")),
user: s.node("User"), user: s.node("User"),
}), }),
}), }),
@ -48,6 +50,15 @@ describe("graphql/schema", () => {
url: s.primitive(), url: s.primitive(),
login: s.primitive(), login: s.primitive(),
}), }),
ColorPalette: s.object({
id: s.id(),
currentColor: s.primitive(s.nonNull("Color")),
favoriteColor: s.primitive(s.nullable("Color")),
nested: s.nested({
exists: s.primitive(),
forall: s.primitive(s.nonNull("Color")),
}),
}),
}; };
} }
function buildGithubSchema(): Schema.Schema { function buildGithubSchema(): Schema.Schema {
@ -84,6 +95,160 @@ describe("graphql/schema", () => {
const schema = buildGithubSchema(); const schema = buildGithubSchema();
expect(JSON.parse(JSON.stringify(schema))).toEqual(schema); expect(JSON.parse(JSON.stringify(schema))).toEqual(schema);
}); });
it("disallows objects with primitives of unknown type", () => {
const s = Schema;
const types = {
O: s.object({id: s.id(), foo: s.primitive(s.nonNull("Wat"))}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has unknown type: "Wat"'
);
});
it("disallows objects with primitives of object type", () => {
const s = Schema;
const types = {
O: s.object({id: s.id(), foo: s.primitive(s.nonNull("O"))}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has invalid type "O" of kind "OBJECT"'
);
});
it("disallows objects with primitives of union type", () => {
const s = Schema;
const types = {
U: s.union(["O"]),
O: s.object({id: s.id(), foo: s.primitive(s.nonNull("U"))}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has invalid type "U" of kind "UNION"'
);
});
it("disallows objects with node fields of unknown type", () => {
const s = Schema;
const types = {
O: s.object({id: s.id(), foo: s.node("Wat")}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has unknown type: "Wat"'
);
});
it("disallows objects with node fields of scalar type", () => {
const s = Schema;
const types = {
DateTime: s.scalar("string"),
O: s.object({id: s.id(), foo: s.node("DateTime")}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has invalid type "DateTime" of kind "SCALAR"'
);
});
it("disallows objects with node fields of enum type", () => {
const s = Schema;
const types = {
Color: s.enum(["RED", "GREEN", "BLUE"]),
O: s.object({id: s.id(), foo: s.node("Color")}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has invalid type "Color" of kind "ENUM"'
);
});
it("disallows objects with connection fields of unknown type", () => {
const s = Schema;
const types = {
O: s.object({id: s.id(), foo: s.connection("Wat")}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has unknown type: "Wat"'
);
});
it("disallows objects with connection fields of scalar type", () => {
const s = Schema;
const types = {
DateTime: s.scalar("string"),
O: s.object({id: s.id(), foo: s.connection("DateTime")}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has invalid type "DateTime" of kind "SCALAR"'
);
});
it("disallows objects with connection fields of enum type", () => {
const s = Schema;
const types = {
Color: s.enum(["RED", "GREEN", "BLUE"]),
O: s.object({id: s.id(), foo: s.connection("Color")}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo" has invalid type "Color" of kind "ENUM"'
);
});
it("disallows objects with egg primitives of unknown type", () => {
const s = Schema;
const types = {
O: s.object({
id: s.id(),
foo: s.nested({bar: s.primitive(s.nonNull("Wat"))}),
}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo"/"bar" has unknown type: "Wat"'
);
});
it("disallows objects with egg primitives of object type", () => {
const s = Schema;
const types = {
O: s.object({
id: s.id(),
foo: s.nested({bar: s.primitive(s.nonNull("O"))}),
}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo"/"bar" has invalid type "O" of kind "OBJECT"'
);
});
it("disallows objects with egg primitives of union type", () => {
const s = Schema;
const types = {
U: s.union(["O"]),
O: s.object({
id: s.id(),
foo: s.nested({bar: s.primitive(s.nonNull("U"))}),
}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo"/"bar" has invalid type "U" of kind "UNION"'
);
});
it("disallows objects with egg node fields of unknown type", () => {
const s = Schema;
const types = {
O: s.object({id: s.id(), foo: s.nested({bar: s.node("Wat")})}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo"/"bar" has unknown type: "Wat"'
);
});
it("disallows objects with egg node fields of scalar type", () => {
const s = Schema;
const types = {
DateTime: s.scalar("string"),
O: s.object({id: s.id(), foo: s.nested({bar: s.node("DateTime")})}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo"/"bar" has invalid type "DateTime" of kind "SCALAR"'
);
});
it("disallows objects with egg node fields of enum type", () => {
const s = Schema;
const types = {
Color: s.enum(["RED", "GREEN", "BLUE"]),
O: s.object({id: s.id(), foo: s.nested({bar: s.node("Color")})}),
};
expect(() => Schema.schema(types)).toThrowError(
'field "O"/"foo"/"bar" has invalid type "Color" of kind "ENUM"'
);
});
it("disallows unions with unknown clauses", () => { it("disallows unions with unknown clauses", () => {
const s = Schema; const s = Schema;
const types = { const types = {