graphile-build
Version:
Build a GraphQL schema from plugins
622 lines (567 loc) • 20 kB
Flow
// @flow
import debugFactory from "debug";
import makeNewBuild from "./makeNewBuild";
import { bindAll } from "./utils";
import * as graphql from "graphql";
import type {
GraphQLType,
GraphQLNamedType,
GraphQLInterfaceType,
GraphQLObjectTypeConfig,
} from "graphql";
import EventEmitter from "events";
// TODO: when we move to TypeScript, change this to:
// import { EventEmitter } from "events";
import type {
simplifyParsedResolveInfoFragmentWithType,
parseResolveInfo,
} from "graphql-parse-resolve-info";
import type { GraphQLResolveInfo } from "graphql/type/definition";
import type resolveNode from "./resolveNode";
import type { FieldWithHooksFunction } from "./makeNewBuild";
const { GraphQLSchema } = graphql;
const debug = debugFactory("graphile-builder");
const INDENT = " ";
export type Options = {
[string]: mixed,
};
export type Plugin = (
builder: SchemaBuilder,
options: Options
) => Promise<void> | void;
type TriggerChangeType = () => void;
export type DataForType = {
[string]: Array<mixed>,
};
export type Build = {|
graphileBuildVersion: string,
graphql: *,
parseResolveInfo: parseResolveInfo,
simplifyParsedResolveInfoFragmentWithType: simplifyParsedResolveInfoFragmentWithType,
// DEPRECATED: getAliasFromResolveInfo: (resolveInfo: GraphQLResolveInfo) => string,
getSafeAliasFromResolveInfo: (resolveInfo: GraphQLResolveInfo) => string,
getSafeAliasFromAlias: (alias: string) => string,
resolveAlias(
data: {},
_args: mixed,
_context: mixed,
resolveInfo: GraphQLResolveInfo
): string,
addType(type: GraphQLNamedType, origin?: ?string): void,
getTypeByName(typeName: string): ?GraphQLType,
extend<Obj1: *, Obj2: *>(base: Obj1, extra: Obj2, hint?: string): Obj1 & Obj2,
newWithHooks<T: GraphQLNamedType | GraphQLSchema, ConfigType: *>(
Class<T>,
spec: ConfigType,
scope: Scope,
performNonEmptyFieldsCheck?: boolean
): ?T,
fieldDataGeneratorsByType: Map<*, *>, // @deprecated - use fieldDataGeneratorsByFieldNameByType instead
fieldDataGeneratorsByFieldNameByType: Map<*, *>,
fieldArgDataGeneratorsByFieldNameByType: Map<*, *>,
inflection: {
// eslint-disable-next-line flowtype/no-weak-types
[string]: (...args: Array<any>) => string,
},
wrapDescription: (
description: string,
position: "root" | "type" | "field" | "arg"
) => string,
swallowError: (e: Error) => void,
// resolveNode: EXPERIMENTAL, API might change!
resolveNode: resolveNode,
status: {
currentHookName: ?string,
currentHookEvent: ?string,
},
scopeByType: Map<GraphQLType, Scope>,
|};
export type BuildExtensionQuery = {|
$$isQuery: Symbol,
|};
export type Scope = {
__origin: ?string,
[string]: mixed,
};
export type Context = {|
scope: Scope,
type: string,
[string]: mixed,
|};
type DataGeneratorFunction = () => {};
export type ContextGraphQLObjectTypeFields = {|
addDataGeneratorForField: (
fieldName: string,
fn: DataGeneratorFunction
) => void,
recurseDataGeneratorsForField: (fieldName: string) => void, // @deprecated - DO NOT USE!
Self: GraphQLNamedType,
GraphQLObjectType: GraphQLObjectTypeConfig<*, *>,
fieldWithHooks: FieldWithHooksFunction,
|};
type SupportedHookTypes = {} | Build | Array<GraphQLInterfaceType>;
export type Hook<
Type: SupportedHookTypes,
BuildExtensions: *,
ContextExtensions: *
> = {
(
input: Type,
build: { ...Build, ...BuildExtensions },
context: { ...Context, ...ContextExtensions }
): Type,
displayName?: string,
provides?: Array<string>,
before?: Array<string>,
after?: Array<string>,
};
export type WatchUnwatch = (triggerChange: TriggerChangeType) => void;
export type SchemaListener = (newSchema: GraphQLSchema) => void;
class SchemaBuilder extends EventEmitter {
options: Options;
watchers: Array<WatchUnwatch>;
unwatchers: Array<WatchUnwatch>;
triggerChange: ?TriggerChangeType;
depth: number;
hooks: {
[string]: Array<Hook<*, *, *>>,
};
_currentPluginName: ?string;
_generatedSchema: ?GraphQLSchema;
_explicitSchemaListener: ?SchemaListener;
_busy: boolean;
_watching: boolean;
constructor(options: Options) {
super();
this.options = options;
if (!options) {
throw new Error("Please pass options to SchemaBuilder");
}
this._busy = false;
this._watching = false;
this.watchers = [];
this.unwatchers = [];
// Because hooks can nest, this keeps track of how deep we are.
this.depth = -1;
this.hooks = {
// The build object represents the current schema build and is passed to
// all hooks, hook the 'build' event to extend this object:
build: [],
// Inflection is used for naming resulting types/fields/args/etc - it's
// hookable so that other plugins may extend it or override it
inflection: [],
// 'build' phase should not generate any GraphQL objects (because the
// build object isn't finalised yet so it risks weirdness occurring); so
// if you need to set up any global types you can do so here.
init: [],
// 'finalize' phase is called once the schema is built; typically you
// shouldn't use this, but it's useful for interfacing with external
// libraries that mutate an already constructed schema.
finalize: [],
// Add 'query', 'mutation' or 'subscription' types in this hook:
GraphQLSchema: [],
// When creating a GraphQLObjectType via `newWithHooks`, we'll
// execute, the following hooks:
// - 'GraphQLObjectType' to add any root-level attributes, e.g. add a description
// - 'GraphQLObjectType:interfaces' to add additional interfaces to this object type
// - 'GraphQLObjectType:fields' to add additional fields to this object type (is
// ran asynchronously and gets a reference to the final GraphQL Object as
// `Self` in the context)
// - 'GraphQLObjectType:fields:field' to customise an individual field from above
// - 'GraphQLObjectType:fields:field:args' to customize the arguments to a field
GraphQLObjectType: [],
"GraphQLObjectType:interfaces": [],
"GraphQLObjectType:fields": [],
"GraphQLObjectType:fields:field": [],
"GraphQLObjectType:fields:field:args": [],
// When creating a GraphQLInputObjectType via `newWithHooks`, we'll
// execute, the following hooks:
// - 'GraphQLInputObjectType' to add any root-level attributes, e.g. add a description
// - 'GraphQLInputObjectType:fields' to add additional fields to this object type (is
// ran asynchronously and gets a reference to the final GraphQL Object as
// `Self` in the context)
// - 'GraphQLInputObjectType:fields:field' to customise an individual field from above
GraphQLInputObjectType: [],
"GraphQLInputObjectType:fields": [],
"GraphQLInputObjectType:fields:field": [],
// When creating a GraphQLEnumType via `newWithHooks`, we'll
// execute, the following hooks:
// - 'GraphQLEnumType' to add any root-level attributes, e.g. add a description
// - 'GraphQLEnumType:values' to add additional values
// - 'GraphQLEnumType:values:value' to change an individual value
GraphQLEnumType: [],
"GraphQLEnumType:values": [],
"GraphQLEnumType:values:value": [],
// When creating a GraphQLUnionType via `newWithHooks`, we'll
// execute, the following hooks:
// - 'GraphQLUnionType' to add any root-level attributes, e.g. add a description
// - 'GraphQLUnionType:types' to add additional types to this union
GraphQLUnionType: [],
"GraphQLUnionType:types": [],
// When creating a GraphQLInterfaceType via `newWithHooks`, we'll
// execute, the following hooks:
// - 'GraphQLInterfaceType' to add any root-level attributes, e.g. add a description
// - 'GraphQLInterfaceType:fields' to add additional fields to this interface type (is
// ran asynchronously and gets a reference to the final GraphQL Interface as
// `Self` in the context)
// - 'GraphQLInterfaceType:fields:field' to customise an individual field from above
// - 'GraphQLInterfaceType:fields:field:args' to customize the arguments to a field
GraphQLInterfaceType: [],
"GraphQLInterfaceType:fields": [],
"GraphQLInterfaceType:fields:field": [],
"GraphQLInterfaceType:fields:field:args": [],
};
}
_setPluginName(name: ?string) {
this._currentPluginName = name;
}
/*
* Every hook `fn` takes three arguments:
* - obj - the object currently being inspected
* - build - the current build object (which contains a number of utilities and the context of the build)
* - context - information specific to the current invocation of the hook
*
* The function must either return a replacement object for `obj` or `obj` itself
*/
hook<T: *>(
hookName: string,
fn: Hook<T, *, *>,
provides?: Array<string>,
before?: Array<string>,
after?: Array<string>
) {
if (!this.hooks[hookName]) {
throw new Error(`Sorry, '${hookName}' is not a supported hook`);
}
if (this._currentPluginName) {
fn.displayName = `${this._currentPluginName}/${hookName}/${
(provides && provides.length && provides.join("+")) ||
fn.displayName ||
fn.name ||
"unnamed"
}`;
}
if (provides) {
if (!fn.displayName && provides.length) {
fn.displayName = `unknown/${hookName}/${provides[0]}`;
}
fn.provides = provides;
}
if (before) {
fn.before = before;
}
if (after) {
fn.after = after;
}
if (!fn.provides && !fn.before && !fn.after) {
// No explicit dependencies - add to the end
this.hooks[hookName].push(fn);
} else {
// We need to figure out where it can go, respecting all the dependencies.
// TODO: I think there are situations in which this algorithm may result in unnecessary conflict errors; we should take a more iterative approach or find a better algorithm
const relevantHooks = this.hooks[hookName];
let minIndex = 0;
let minReason = null;
let maxIndex = relevantHooks.length;
let maxReason = null;
const { provides: newProvides, before: newBefore, after: newAfter } = fn;
const describe = (hook, index) => {
if (!hook) {
return "-";
}
return `${hook.displayName || hook.name || "anonymous"} (${
index ? `index: ${index}, ` : ""
}provides: ${hook.provides ? hook.provides.join(",") : "-"}, before: ${
hook.before ? hook.before.join(",") : "-"
}, after: ${hook.after ? hook.after.join(",") : "-"})`;
};
const check = () => {
if (minIndex > maxIndex) {
throw new Error(
`Cannot resolve plugin order - ${describe(
fn
)} cannot be before ${describe(
maxReason,
maxIndex
)} and after ${describe(
minReason,
minIndex
)} - please report this issue`
);
}
};
const setMin = (newMin, reason) => {
if (newMin > minIndex) {
minIndex = newMin;
minReason = reason;
check();
}
};
const setMax = (newMax, reason) => {
if (newMax < maxIndex) {
maxIndex = newMax;
maxReason = reason;
check();
}
};
relevantHooks.forEach((oldHook, idx) => {
const {
provides: oldProvides,
before: oldBefore,
after: oldAfter,
} = oldHook;
if (newProvides) {
if (oldBefore && oldBefore.some(dep => newProvides.includes(dep))) {
// Old says it has to come before new
setMin(idx + 1, oldHook);
}
if (oldAfter && oldAfter.some(dep => newProvides.includes(dep))) {
// Old says it has to be after new
setMax(idx, oldHook);
}
}
if (oldProvides) {
if (newBefore && newBefore.some(dep => oldProvides.includes(dep))) {
// New says it has to come before old
setMax(idx, oldHook);
}
if (newAfter && newAfter.some(dep => oldProvides.includes(dep))) {
// New says it has to be after old
setMin(idx + 1, oldHook);
}
}
});
// We've already validated everything, so we can now insert the record.
this.hooks[hookName].splice(maxIndex, 0, fn);
}
}
applyHooks<T: *, Context>(
build: { ...Build },
hookName: string,
input: T,
context: Context,
debugStr: string = ""
): T {
if (!input) {
throw new Error("applyHooks was called with falsy input");
}
this.depth++;
try {
debug(`${INDENT.repeat(this.depth)}[${hookName}${debugStr}]: Running...`);
const hooks: Array<Hook<T, *, *>> = this.hooks[hookName];
if (!hooks) {
throw new Error(`Sorry, '${hookName}' is not a registered hook`);
}
let newObj = input;
for (const hook: Hook<T, *, *> of hooks) {
this.depth++;
try {
const hookDisplayName = hook.displayName || hook.name || "anonymous";
debug(
`${INDENT.repeat(
this.depth
)}[${hookName}${debugStr}]: Executing '${hookDisplayName}'`
);
const previousHookName = build.status.currentHookName;
const previousHookEvent = build.status.currentHookEvent;
build.status.currentHookName = hookDisplayName;
build.status.currentHookEvent = hookName;
const oldObj = newObj;
newObj = hook(newObj, build, context);
if (hookName === "build") {
/*
* Unlike all the other hooks, the `build` hook must always use the
* same `build` object - never returning a new object for fear of
* causing issues to other build hooks that reference the old
* object and don't get the new additions.
*/
if (newObj !== oldObj) {
// TODO:v5: forbid this
// eslint-disable-next-line no-console
console.warn(
`Build hook '${hookDisplayName}' returned a new object; please use 'return build.extend(build, {...})' instead.`
);
// Copy everything from newObj back to oldObj
Object.assign(oldObj, newObj);
// Go back to the old objectect
newObj = oldObj;
}
}
build.status.currentHookName = previousHookName;
build.status.currentHookEvent = previousHookEvent;
if (!newObj) {
throw new Error(
`Hook '${
hook.displayName || hook.name || "anonymous"
}' for '${hookName}' returned falsy value '${newObj}'`
);
}
debug(
`${INDENT.repeat(
this.depth
)}[${hookName}${debugStr}]: '${hookDisplayName}' complete`
);
} finally {
this.depth--;
}
}
debug(`${INDENT.repeat(this.depth)}[${hookName}${debugStr}]: Complete`);
return newObj;
} finally {
this.depth--;
}
}
registerWatcher(listen: WatchUnwatch, unlisten: WatchUnwatch) {
if (!listen || !unlisten) {
throw new Error("You must provide both a listener and an unlistener");
}
this.watchers.push(listen);
this.unwatchers.push(unlisten);
}
createBuild(): { ...Build } {
const initialBuild = makeNewBuild(this);
// Inflection needs to come first, in case 'build' hooks depend on it
initialBuild.inflection = this.applyHooks(
initialBuild,
"inflection",
initialBuild.inflection,
{
scope: {},
}
);
const build = this.applyHooks(initialBuild, "build", initialBuild, {
scope: {},
});
// Bind all functions so they can be dereferenced
bindAll(
build,
Object.keys(build).filter(key => typeof build[key] === "function")
);
Object.freeze(build);
this.applyHooks(build, "init", {}, { scope: {} });
return build;
}
buildSchema(): GraphQLSchema {
if (!this._generatedSchema) {
const build = this.createBuild();
const schema = build.newWithHooks(
GraphQLSchema,
{
directives: [...build.graphql.specifiedDirectives],
},
{
__origin: `GraphQL built-in`,
isSchema: true,
}
);
const hookedSchema = this.applyHooks(
build,
"finalize",
schema,
{},
"Finalising GraphQL schema"
);
const errors = build.graphql.validateSchema(hookedSchema);
if (errors && errors.length) {
throw new Error(
"GraphQL schema is invalid:\n" +
errors.map(e => `- ` + e.message.replace(/\n/g, "\n ")).join("\n")
);
}
this._generatedSchema = hookedSchema;
}
if (!this._generatedSchema) {
throw new Error("Schema generation failed");
}
return this._generatedSchema;
}
async watchSchema(listener?: SchemaListener) {
if (this._busy) {
throw new Error("An operation is in progress");
}
if (this._watching) {
throw new Error(
"We're already watching this schema! Use `builder.on('schema', callback)` instead."
);
}
try {
this._busy = true;
this._explicitSchemaListener = listener;
// We want to ignore `triggerChange` calls that occur whilst we're setting
// up the listeners to prevent an unnecessary double schema build.
let ignoreChangeTriggers = true;
this.triggerChange = () => {
if (ignoreChangeTriggers) {
return;
}
this._generatedSchema = null;
// XXX: optionally debounce
try {
const schema = this.buildSchema();
this.emit("schema", schema);
} catch (e) {
// Build errors introduced while watching are ignored because it's
// primarily used in development.
// eslint-disable-next-line no-console
console.error(
"⚠️⚠️⚠️ An error occured when building the schema on watch:"
);
// eslint-disable-next-line no-console
console.error(e);
}
};
try {
this._watching = true;
for (const fn of this.watchers) {
await fn(this.triggerChange);
}
// Now we're about to build the first schema, any further
// `triggerChange` calls should be honoured.
ignoreChangeTriggers = false;
if (listener) {
this.on("schema", listener);
}
this.emit("schema", this.buildSchema());
} catch (e) {
try {
this._busy = false;
// Abort abort!
await this.unwatchSchema();
} catch (e2) {
console.error(
`Error when unwatching schema after error during schema build: ${e}`
);
}
throw e;
}
} finally {
this._busy = false;
}
}
async unwatchSchema() {
if (this._busy) {
throw new Error("An operation is in progress");
}
if (!this._watching) {
throw new Error("We're not watching this schema!");
}
this._busy = true;
try {
const listener = this._explicitSchemaListener;
this._explicitSchemaListener = null;
if (listener) {
this.removeListener("schema", listener);
}
if (this.triggerChange) {
for (const fn of this.unwatchers) {
await fn(this.triggerChange);
}
}
this.triggerChange = null;
this._watching = false;
} finally {
this._busy = false;
}
}
}
export default SchemaBuilder;