schema: add fidelity types (#1662)

Summary:
Fields in a GraphQL schema may now declare themselves as “unfaithful”,
indicating that the server is known to return responses with incorrect
types for that field. Future changes will teach the Mirror module to
query these fields more carefully, handling such incorrect responses
robustly. See the [tracking issue] and [project initiative] for details.

[project initiative]: https://discourse.sourcecred.io/t/graphql-mirror-fidelity-awareness/275
[tracking issue]: https://github.com/sourcecred/sourcecred/issues/998

This change is source-compatible and data-incompatible: the APIs are
backward-compatible, but the schema representation (JSON serialized
form) has changed, and so the `Mirror` constructor will reject old
caches.

Test Plan:
Unit tests included.

wchargin-branch: schema-fidelity
This commit is contained in:
William Chargin 2020-02-29 16:53:12 -08:00 committed by GitHub
parent f86ee92f9f
commit b36ecb4d9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 306 additions and 10 deletions

View File

@ -25,6 +25,12 @@ export type EmptyEnum = empty;
export const EmptyEnum$Values: {||} = deepFreeze({});
export type EmptyUnfaithfulSet = {|
+__typename: \\"EmptyUnfaithfulSet\\",
+foo: null | empty,
+id: string,
|};
export type EmptyUnion = empty;
export type Human = {|
@ -35,6 +41,8 @@ export type Human = {|
export type PaintJob = {|
+__typename: \\"PaintJob\\",
+client: null | Human | TalentedChimpanzee,
+coClients: $ReadOnlyArray<null | Human | TalentedChimpanzee>,
+color: Color,
+completed: null | DateTime,
+cost: Dollars,
@ -42,6 +50,7 @@ export type PaintJob = {|
+details: null | {|
+description: null | String,
+moreMetadata: mixed,
+oldClient: null | Human | TalentedChimpanzee,
+oldColor: Color,
+oldPainter: null | Actor,
|},

View File

@ -4,12 +4,14 @@ import prettier, {type Options as PrettierOptions} from "prettier";
import type {
ConnectionFieldType,
Fidelity,
FieldType,
IdFieldType,
NestedFieldType,
NodeFieldType,
PrimitiveFieldType,
Schema,
Typename,
} from "./schema";
export default function generateFlowTypes(
@ -38,6 +40,19 @@ export default function generateFlowTypes(
function formatIdField(_unused_field: IdFieldType): string {
return "string";
}
function formatFidelity(nominal: Typename, fidelity: Fidelity): string {
switch (fidelity.type) {
case "FAITHFUL":
return nominal;
case "UNFAITHFUL": {
const values = Object.keys(fidelity.actualTypenames).sort();
return values.length === 0 ? "empty" : values.join(" | ");
}
// istanbul ignore next: unreachable per Flow
default:
throw new Error((fidelity.type: empty));
}
}
function formatPrimitiveField(field: PrimitiveFieldType): string {
if (field.annotation == null) {
return "mixed";
@ -48,10 +63,12 @@ export default function generateFlowTypes(
}
}
function formatNodeField(field: NodeFieldType): string {
return "null | " + field.elementType;
const elementType = formatFidelity(field.elementType, field.fidelity);
return `null | ${elementType}`;
}
function formatConnectionField(field: ConnectionFieldType): string {
return `$ReadOnlyArray<null | ${field.elementType}>`;
const elementType = formatFidelity(field.elementType, field.fidelity);
return `$ReadOnlyArray<null | ${elementType}>`;
}
function formatNestedField(field: NestedFieldType): string {
const eggs = [];

View File

@ -26,6 +26,14 @@ describe("graphql/generateFlowTypes", () => {
color: s.primitive(s.nonNull("Color")),
designer: s.node("Actor"),
painters: s.connection("Actor"),
client: s.node(
"Human",
s.unfaithful(["Human", "TalentedChimpanzee"])
),
coClients: s.connection(
"Human",
s.unfaithful(["Human", "TalentedChimpanzee"])
),
referrer: s.node("PaintJob"),
relatedWork: s.connection("PaintJob"),
details: s.nested({
@ -33,6 +41,10 @@ describe("graphql/generateFlowTypes", () => {
description: s.primitive(s.nullable("String")),
oldColor: s.primitive(s.nonNull("Color")),
oldPainter: s.node("Actor"),
oldClient: s.node(
"Human",
s.unfaithful(["Human", "TalentedChimpanzee"])
),
}),
}),
Actor: s.union(["Human", "TalentedChimpanzee"]),
@ -46,6 +58,10 @@ describe("graphql/generateFlowTypes", () => {
}),
EmptyEnum: s.enum([]),
EmptyUnion: s.union([]),
EmptyUnfaithfulSet: s.object({
id: s.id(),
foo: s.node("Human", s.unfaithful([])),
}),
});
expect(run(schema)).toMatchSnapshot();
});
@ -128,6 +144,27 @@ describe("graphql/generateFlowTypes", () => {
expect(run(s1)).toEqual(run(s2));
});
it("is invariant with respect to unfaithful typename order", () => {
const s = Schema;
const s1 = s.schema({
O: s.object({
id: s.id(),
x: s.node("A", s.unfaithful(["A", "B"])),
}),
A: s.object({id: s.id()}),
B: s.object({id: s.id()}),
});
const s2 = s.schema({
O: s.object({
id: s.id(),
x: s.node("A", s.unfaithful(["B", "A"])),
}),
A: s.object({id: s.id()}),
B: s.object({id: s.id()}),
});
expect(run(s1)).toEqual(run(s2));
});
it("is invariant with respect to union clause order", () => {
const s = Schema;
const s1 = s.schema({

View File

@ -82,6 +82,7 @@ export class Mirror {
this._db = db;
this._schema = schema;
this._schemaInfo = _buildSchemaInfo(this._schema);
_checkAllFaithful(this._schemaInfo);
this._blacklistedIds = (() => {
const result: {|[Schema.ObjectId]: true|} = ({}: any);
for (const id of fullOptions.blacklistedIds) {
@ -2228,3 +2229,54 @@ export function _makeSingleUpdateFunction<Args: BindingDictionary>(
}
};
}
/**
* Ensure that the provided schema only has node fields of faithful
* fidelity.
*/
function _checkAllFaithful(schemaInfo) {
function check(path, field) {
if (field.fidelity.type === "FAITHFUL") {
return;
}
const pathMsg = path.join(".");
throw new Error(`Unfaithful fields not yet supported: ${pathMsg}`);
}
for (const typename of Object.keys(schemaInfo.objectTypes)) {
const type = schemaInfo.objectTypes[typename];
const fields = type.fields;
for (const fieldname of Object.keys(fields)) {
const field = fields[fieldname];
switch (field.type) {
case "ID":
continue;
case "PRIMITIVE":
continue;
case "NODE":
check([typename, fieldname], field);
break;
case "CONNECTION":
check([typename, fieldname], field);
break;
case "NESTED":
for (const eggName of Object.keys(field.eggs)) {
const egg = field.eggs[eggName];
switch (egg.type) {
case "PRIMITIVE":
break;
case "NODE":
check([typename, fieldname, eggName], egg);
break;
// istanbul ignore next
default:
throw new Error((egg.type: empty));
}
}
break;
// istanbul ignore next
default:
throw new Error((field.type: empty));
}
}
}
}

View File

@ -208,6 +208,55 @@ describe("graphql/mirror", () => {
new Mirror(db, schema, {blacklistedIds: ["ominous"]});
}).toThrow("incompatible schema, options, or version");
});
describe("rejects a schema with unfaithful fields", () => {
it("of node type", () => {
const s = Schema;
const schema = s.schema({
Foo: s.object({
id: s.id(),
bar: s.node("Foo", s.unfaithful(["Foo", "Bar"])),
}),
Bar: s.object({id: s.id()}),
});
const db = new Database(":memory:");
expect(() => {
new Mirror(db, schema);
}).toThrow("Unfaithful fields not yet supported: Foo.bar");
});
it("of connection type", () => {
const s = Schema;
const schema = s.schema({
Foo: s.object({
id: s.id(),
bar: s.connection("Foo", s.unfaithful(["Foo", "Bar"])),
}),
Bar: s.object({id: s.id()}),
});
const db = new Database(":memory:");
expect(() => {
new Mirror(db, schema);
}).toThrow("Unfaithful fields not yet supported: Foo.bar");
});
it("of egg-node type", () => {
const s = Schema;
const schema = s.schema({
Foo: s.object({
id: s.id(),
bar: s.nested({
baz: s.node("Foo", s.unfaithful(["Foo", "Bar"])),
}),
}),
Bar: s.object({id: s.id()}),
});
const db = new Database(":memory:");
expect(() => {
new Mirror(db, schema);
}).toThrow("Unfaithful fields not yet supported: Foo.bar.baz");
});
});
});
describe("_createUpdate", () => {

View File

@ -69,23 +69,68 @@ export type PrimitiveTypeAnnotation = {|
+nonNull: boolean,
+elementType: Typename,
|};
export type NodeFieldType = {|+type: "NODE", +elementType: Typename|};
export type NodeFieldType = {|
+type: "NODE",
+elementType: Typename,
+fidelity: Fidelity,
|};
export type ConnectionFieldType = {|
+type: "CONNECTION",
+elementType: Typename,
+fidelity: Fidelity,
|};
export type NestedFieldType = {|
+type: "NESTED",
+eggs: {+[Fieldname]: PrimitiveFieldType | NodeFieldType},
|};
// A field is _faithful_ if selecting its `__typename` and `id` will
// always yield the correct `__typename` for the node of the given ID.
// In theory, this should always be the case, but some remote schemas
// are broken. For details, see:
//
// - https://github.com/sourcecred/sourcecred/issues/996
// - https://github.com/sourcecred/sourcecred/issues/998
//
// For an unfaithful field, the `actualTypenames` property lists all the
// types of objects that can _actually_ be returned when the field is
// queried. (This set only affects generated Flow types, not runtime
// semantics.) These must all be object types.
//
// It is always sound to represent an actually-faithful field as
// unfaithful, but doing so may incur additional queries. Marking a type
// as faithful should be seen as an optimization that may be performed
// only when the server is abiding by its contract for that field.
export type Fidelity =
| {|+type: "FAITHFUL"|}
| {|+type: "UNFAITHFUL", actualTypenames: {|+[Typename]: true|}|};
export function faithful(): Fidelity {
return {type: "FAITHFUL"};
}
export function unfaithful(actualTypenames: $ReadOnlyArray<Typename>) {
const actualTypenamesObject: {|[Typename]: true|} = ({}: any);
for (const t of actualTypenames) {
actualTypenamesObject[t] = true;
}
return {type: "UNFAITHFUL", actualTypenames: actualTypenamesObject};
}
// 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 {
function assertKind(path, elementTypename, validKinds) {
const self = `field ${path.map((x) => JSON.stringify(x)).join("/")}`;
function assertKind(
path,
elementTypename,
validKinds,
{isFidelity = false} = {}
) {
const self =
(isFidelity ? "unfaithful typenames list of " : "") +
`field ${path.map((x) => JSON.stringify(x)).join("/")}`;
const elementType = types[elementTypename];
if (elementType == null) {
throw new Error(`${self} has unknown type: "${elementTypename}"`);
@ -97,6 +142,20 @@ export function schema(types: {[Typename]: NodeType}): Schema {
);
}
}
function validateFidelity(path, fidelity) {
switch (fidelity.type) {
case "FAITHFUL":
break;
case "UNFAITHFUL":
for (const typename of Object.keys(fidelity.actualTypenames)) {
assertKind(path, typename, ["OBJECT"], {isFidelity: true});
}
break;
// istanbul ignore next: unreachable per Flow
default:
throw new Error((fidelity.type: empty));
}
}
const result = {};
for (const typename of Object.keys(types)) {
const type = types[typename];
@ -131,12 +190,14 @@ export function schema(types: {[Typename]: NodeType}): Schema {
"OBJECT",
"UNION",
]);
validateFidelity([typename, fieldname], field.fidelity);
break;
case "CONNECTION":
assertKind([typename, fieldname], field.elementType, [
"OBJECT",
"UNION",
]);
validateFidelity([typename, fieldname], field.fidelity);
break;
case "NESTED":
for (const eggName of Object.keys(field.eggs)) {
@ -157,6 +218,10 @@ export function schema(types: {[Typename]: NodeType}): Schema {
egg.elementType,
["OBJECT", "UNION"]
);
validateFidelity(
[typename, fieldname, eggName],
egg.fidelity
);
break;
// istanbul ignore next: unreachable per Flow
default:
@ -256,12 +321,18 @@ export function primitive(
return {type: "PRIMITIVE", annotation: annotation || null};
}
export function node(elementType: Typename): NodeFieldType {
return {type: "NODE", elementType};
export function node(
elementType: Typename,
fidelity: Fidelity = faithful()
): NodeFieldType {
return {type: "NODE", elementType, fidelity};
}
export function connection(elementType: Typename): ConnectionFieldType {
return {type: "CONNECTION", elementType};
export function connection(
elementType: Typename,
fidelity: Fidelity = faithful()
): ConnectionFieldType {
return {type: "CONNECTION", elementType, fidelity};
}
export function nonNull(elementType: Typename): PrimitiveTypeAnnotation {

View File

@ -26,7 +26,7 @@ describe("graphql/schema", () => {
oid: s.primitive(),
author: /* GitActor */ s.nested({
date: s.primitive(s.nonNull("DateTime")),
user: s.node("User"),
user: s.node("User", s.unfaithful(["User", "Bot"])),
}),
}),
IssueComment: s.object({
@ -249,6 +249,48 @@ describe("graphql/schema", () => {
);
});
it("disallows node fidelities with non-object types", () => {
const s = Schema;
const types = {
Color: s.enum(["RED", "GREEN", "BLUE"]),
O: s.object({id: s.id(), foo: s.node("O", s.unfaithful(["Color"]))}),
};
expect(() => Schema.schema(types)).toThrowError(
'unfaithful typenames list of field "O"/"foo" has ' +
'invalid type "Color" of kind "ENUM"'
);
});
it("disallows connection fidelities with non-object types", () => {
const s = Schema;
const types = {
Color: s.enum(["RED", "GREEN", "BLUE"]),
O: s.object({
id: s.id(),
foo: s.connection("O", s.unfaithful(["Color"])),
}),
};
expect(() => Schema.schema(types)).toThrowError(
'unfaithful typenames list of field "O"/"foo" has ' +
'invalid type "Color" of kind "ENUM"'
);
});
it("disallows nest-node fidelities with non-object types", () => {
const s = Schema;
const types = {
Color: s.enum(["RED", "GREEN", "BLUE"]),
O: s.object({
id: s.id(),
foo: s.nested({
bar: s.node("O", s.unfaithful(["Color"])),
}),
}),
};
expect(() => Schema.schema(types)).toThrowError(
'unfaithful typenames list of field "O"/"foo"/"bar" has ' +
'invalid type "Color" of kind "ENUM"'
);
});
it("disallows unions with unknown clauses", () => {
const s = Schema;
const types = {
@ -327,4 +369,23 @@ describe("graphql/schema", () => {
expect(s.union(["One", "Two"])).toEqual(s.union(["Two", "One"]));
});
});
describe("faithful", () => {
it("creates a datum", () => {
expect(Schema.faithful()).toEqual({type: "FAITHFUL"});
});
});
describe("unfaithful", () => {
it("creates a datum", () => {
expect(Schema.unfaithful(["User", "Bot"])).toEqual({
type: "UNFAITHFUL",
actualTypenames: {User: true, Bot: true},
});
});
it("is invariant with respect to field order", () => {
const a = Schema.unfaithful(["User", "Bot"]);
const b = Schema.unfaithful(["Bot", "User"]);
expect(a).toEqual(b);
});
});
});