graphile-build
Version:
Build a GraphQL schema from plugins
397 lines (391 loc) • 16.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _debug = _interopRequireDefault(require("debug"));
var _makeNewBuild = _interopRequireDefault(require("./makeNewBuild"));
var _utils = require("./utils");
var graphql = _interopRequireWildcard(require("graphql"));
var _events = _interopRequireDefault(require("events"));
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
// TODO: when we move to TypeScript, change this to:
// import { EventEmitter } from "events";
const {
GraphQLSchema
} = graphql;
const debug = (0, _debug.default)("graphile-builder");
const INDENT = " ";
class SchemaBuilder extends _events.default {
constructor(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) {
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(hookName, fn, provides, before, after) {
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(build, hookName, input, context, debugStr = "") {
if (!input) {
throw new Error("applyHooks was called with falsy input");
}
this.depth++;
try {
debug(`${INDENT.repeat(this.depth)}[${hookName}${debugStr}]: Running...`);
const hooks = this.hooks[hookName];
if (!hooks) {
throw new Error(`Sorry, '${hookName}' is not a registered hook`);
}
let newObj = input;
for (const hook 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, unlisten) {
if (!listen || !unlisten) {
throw new Error("You must provide both a listener and an unlistener");
}
this.watchers.push(listen);
this.unwatchers.push(unlisten);
}
createBuild() {
const initialBuild = (0, _makeNewBuild.default)(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
(0, _utils.bindAll)(build, Object.keys(build).filter(key => typeof build[key] === "function"));
Object.freeze(build);
this.applyHooks(build, "init", {}, {
scope: {}
});
return build;
}
buildSchema() {
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) {
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;
}
}
}
var _default = SchemaBuilder;
exports.default = _default;
//# sourceMappingURL=SchemaBuilder.js.map