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:
parent
4558d775a5
commit
e787db53c4
|
@ -808,6 +808,15 @@ export class Mirror {
|
|||
}
|
||||
const b = Queries.build;
|
||||
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":
|
||||
return [b.field("__typename"), b.field("id")];
|
||||
case "UNION":
|
||||
|
@ -1831,6 +1840,12 @@ export function _buildSchemaInfo(schema: Schema.Schema): SchemaInfo {
|
|||
for (const typename of Object.keys(schema)) {
|
||||
const type = schema[typename];
|
||||
switch (type.type) {
|
||||
case "SCALAR":
|
||||
// Nothing to do.
|
||||
break;
|
||||
case "ENUM":
|
||||
// Nothing to do.
|
||||
break;
|
||||
case "OBJECT": {
|
||||
const entry: {|
|
||||
+fields: {|+[Schema.Fieldname]: Schema.FieldType|},
|
||||
|
|
|
@ -41,6 +41,16 @@ describe("graphql/mirror", () => {
|
|||
function buildGithubSchema(): Schema.Schema {
|
||||
const s = Schema;
|
||||
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({
|
||||
id: s.id(),
|
||||
url: s.primitive(),
|
||||
|
@ -48,7 +58,7 @@ describe("graphql/mirror", () => {
|
|||
}),
|
||||
Issue: s.object({
|
||||
id: s.id(),
|
||||
url: s.primitive(),
|
||||
url: s.primitive(s.nonNull("URI")),
|
||||
author: s.node("Actor"),
|
||||
repository: s.node("Repository"),
|
||||
title: s.primitive(),
|
||||
|
@ -64,7 +74,7 @@ describe("graphql/mirror", () => {
|
|||
id: s.id(),
|
||||
oid: s.primitive(),
|
||||
author: /* GitActor */ s.nested({
|
||||
date: s.primitive(),
|
||||
date: s.primitive(s.nonNull("Date")),
|
||||
user: s.node("User"),
|
||||
}),
|
||||
}),
|
||||
|
@ -1142,6 +1152,20 @@ describe("graphql/mirror", () => {
|
|||
mirror._queryShallow("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", () => {
|
||||
const db = new Database(":memory:");
|
||||
const mirror = new Mirror(db, buildGithubSchema());
|
||||
|
@ -3143,7 +3167,7 @@ describe("graphql/mirror", () => {
|
|||
expect(result.objectTypes["Commit"].nestedFieldNames).toEqual(["author"]);
|
||||
expect(result.objectTypes["Commit"].nestedFields).toEqual({
|
||||
author: {
|
||||
primitives: {date: Schema.primitive()},
|
||||
primitives: {date: Schema.primitive(Schema.nonNull("Date"))},
|
||||
nodes: {user: Schema.node("User")},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -32,6 +32,9 @@ export type ObjectId = string;
|
|||
// - Connections are supported as object fields, but arbitrary lists
|
||||
// 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,
|
||||
// objects may have "nested" fields of primitive or node-reference type.
|
||||
// These may be nested to depth exactly 1. Suppose that `Foo` is an
|
||||
|
@ -46,6 +49,8 @@ export type ObjectId = string;
|
|||
// contains the eggs.)
|
||||
export type Schema = {+[Typename]: NodeType};
|
||||
export type NodeType =
|
||||
| {|+type: "SCALAR", +representation: "string" | "number" | "boolean"|}
|
||||
| {|+type: "ENUM", +values: {|+[string]: true|}|}
|
||||
| {|+type: "OBJECT", +fields: {|+[Fieldname]: FieldType|}|}
|
||||
| {|+type: "UNION", +clauses: {|+[Typename]: true|}|};
|
||||
|
||||
|
@ -56,7 +61,14 @@ export type FieldType =
|
|||
| ConnectionFieldType
|
||||
| NestedFieldType;
|
||||
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 ConnectionFieldType = {|
|
||||
+type: "CONNECTION",
|
||||
|
@ -76,7 +88,89 @@ export function schema(types: {[Typename]: NodeType}): Schema {
|
|||
for (const typename of Object.keys(types)) {
|
||||
const type = types[typename];
|
||||
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":
|
||||
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}};
|
||||
break;
|
||||
case "UNION":
|
||||
|
@ -107,6 +201,21 @@ export function schema(types: {[Typename]: NodeType}): Schema {
|
|||
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 {
|
||||
for (const fieldname of Object.keys(fields)) {
|
||||
const field = fields[fieldname];
|
||||
|
@ -141,8 +250,10 @@ export function id(): IdFieldType {
|
|||
return {type: "ID"};
|
||||
}
|
||||
|
||||
export function primitive(): PrimitiveFieldType {
|
||||
return {type: "PRIMITIVE"};
|
||||
export function primitive(
|
||||
annotation?: PrimitiveTypeAnnotation
|
||||
): PrimitiveFieldType {
|
||||
return {type: "PRIMITIVE", annotation: annotation || null};
|
||||
}
|
||||
|
||||
export function node(elementType: Typename): NodeFieldType {
|
||||
|
@ -153,6 +264,14 @@ export function connection(elementType: Typename): ConnectionFieldType {
|
|||
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: {
|
||||
+[Fieldname]: PrimitiveFieldType | NodeFieldType,
|
||||
}): NestedFieldType {
|
||||
|
|
|
@ -6,6 +6,8 @@ describe("graphql/schema", () => {
|
|||
function buildGithubTypes(): {[Schema.Typename]: Schema.NodeType} {
|
||||
const s = Schema;
|
||||
return {
|
||||
DateTime: s.scalar("string"),
|
||||
Color: s.enum(["RED", "GREEN", "BLUE"]),
|
||||
Repository: s.object({
|
||||
id: s.id(),
|
||||
url: s.primitive(),
|
||||
|
@ -23,7 +25,7 @@ describe("graphql/schema", () => {
|
|||
id: s.id(),
|
||||
oid: s.primitive(),
|
||||
author: /* GitActor */ s.nested({
|
||||
date: s.primitive(),
|
||||
date: s.primitive(s.nonNull("DateTime")),
|
||||
user: s.node("User"),
|
||||
}),
|
||||
}),
|
||||
|
@ -48,6 +50,15 @@ describe("graphql/schema", () => {
|
|||
url: 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 {
|
||||
|
@ -84,6 +95,160 @@ describe("graphql/schema", () => {
|
|||
const schema = buildGithubSchema();
|
||||
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", () => {
|
||||
const s = Schema;
|
||||
const types = {
|
||||
|
|
Loading…
Reference in New Issue