objection-graphql
Version:
Automatically generates GraphQL schema for objection.js models and allows to extend the schema with custom mutations and subscriptions
501 lines (398 loc) • 14.1 kB
JavaScript
const _ = require('lodash');
const utils = require('./utils');
const objection = require('objection');
const graphqlRoot = require('graphql');
const jsonSchemaUtils = require('./jsonSchema');
const defaultArgFactories = require('./argFactories');
const { GraphQLObjectType, GraphQLSchema, GraphQLList } = graphqlRoot;
// Default arguments that are excluded from the relation arguments.
const OMIT_FROM_RELATION_ARGS = [
// We cannot use `range` in the relation arguments since the relations are fetched
// for multiple objects at a time. Limiting the result set would limit the combined
// result, and not the individual model's relation.
'range',
];
const GRAPHQL_META_FIELDS = [
'__typename',
];
// GraphQL AST node types.
const KIND_FRAGMENT_SPREAD = 'FragmentSpread';
const KIND_VARIABLE = 'Variable';
class SchemaBuilder {
constructor() {
this.models = {};
this.typeCache = {};
this.filterIndex = 1;
this.argFactories = [];
this.enableSelectFiltering = true;
this.defaultArgNameMap = {
eq: 'Eq',
gt: 'Gt',
gte: 'Gte',
lt: 'Lt',
lte: 'Lte',
like: 'Like',
isNull: 'IsNull',
likeNoCase: 'LikeNoCase',
in: 'In',
notIn: 'NotIn',
orderBy: 'orderBy',
orderByDesc: 'orderByDesc',
range: 'range',
limit: 'limit',
offset: 'offset',
};
}
model(modelClass, opt) {
opt = opt || {};
if (!modelClass.jsonSchema) {
throw new Error('modelClass must have a jsonSchema');
}
this.models[modelClass.tableName] = {
modelClass,
fields: null,
args: null,
opt,
};
return this;
}
allModels(models) {
models.forEach(model => this.model(model));
return this;
}
defaultArgNames(defaultArgNameMap) {
this.defaultArgNameMap = Object.assign({}, this.defaultArgNameMap, defaultArgNameMap);
return this;
}
argFactory(argFactory) {
this.argFactories.push(argFactory);
return this;
}
selectFiltering(enable) {
this.enableSelectFiltering = !!enable;
return this;
}
extendWithMutations(mutations) {
if (!(mutations instanceof GraphQLObjectType || mutations instanceof Function)) {
throw new TypeError('mutations should be a function or an object of type GraphQLObjectType');
}
this.mutation = mutations;
return this;
}
extendWithMiddleware(middleware) {
if (!(middleware instanceof Function)) {
throw new TypeError('middleware should be a function');
}
this.middleware = middleware;
return this;
}
extendWithSubscriptions(subscriptions) {
if (!(subscriptions instanceof GraphQLObjectType || subscriptions instanceof Function)) {
throw new TypeError('subscriptions should be a function or an object of type GraphQLObjectType');
}
this.subscription = subscriptions;
return this;
}
setBuilderOptions(options) {
this.builderOptions = options;
return this;
}
build() {
_.forOwn(this.models, (modelData) => {
modelData.fields = jsonSchemaUtils.jsonSchemaToGraphQLFields(modelData.modelClass.jsonSchema, {
include: modelData.opt.include,
exclude: modelData.opt.exclude,
typeNamePrefix: utils.typeNameForModel(modelData.modelClass),
typeCache: this.typeCache,
});
modelData.args = this._argsForModel(modelData);
});
const schemaSetup = {
query: new GraphQLObjectType({
name: 'Query',
fields: () => {
const fields = {};
_.forOwn(this.models, (modelData) => {
const defaultFieldName = fieldNameForModel(modelData.modelClass);
const singleFieldName = modelData.opt.fieldName || defaultFieldName;
const listFieldName = modelData.opt.listFieldName || (`${defaultFieldName}s`);
fields[singleFieldName] = this._rootSingleField(modelData);
fields[listFieldName] = this._rootListField(modelData);
});
return fields;
},
}),
};
if (this.mutation) {
if (this.mutation instanceof Function) {
schemaSetup.mutation = this.mutation(this);
} else {
schemaSetup.mutation = this.mutation;
}
}
if (this.subscription) {
if (this.subscription instanceof Function) {
schemaSetup.subscription = this.mutation(this);
} else {
schemaSetup.subscription = this.subscription;
}
}
return new GraphQLSchema(schemaSetup);
}
_argsForModel(modelData) {
const factories = defaultArgFactories(this.defaultArgNameMap, { typeCache: this.typeCache }).concat(this.argFactories);
return factories.reduce((args, factory) => Object.assign(args, factory(modelData.fields, modelData.modelClass)), {});
}
_middlewareResolver(modelData, extraQuery) {
if (this.middleware) {
return this.middleware(this._resolverForModel(modelData, extraQuery), modelData, extraQuery);
}
return this._resolverForModel(modelData, extraQuery);
}
_rootSingleField(modelData) {
return {
type: this._typeForModel(modelData),
args: modelData.args,
resolve: this._middlewareResolver(modelData, (query) => {
query.first();
}),
};
}
_rootListField(modelData) {
return {
type: new GraphQLList(this._typeForModel(modelData)),
args: modelData.args,
resolve: this._middlewareResolver(modelData),
};
}
_typeForModel(modelData) {
const typeName = utils.typeNameForModel(modelData.modelClass);
if (!this.typeCache[typeName]) {
this.typeCache[typeName] = new GraphQLObjectType({
name: typeName,
fields: () => Object.assign({}, this._attrFields(modelData), this._relationFields(modelData)),
});
}
return this.typeCache[typeName];
}
_attrFields(modelData) {
return modelData.fields;
}
_relationFields(modelData) {
const fields = {};
_.forOwn(modelData.modelClass.getRelations(), (relation) => {
const relationModel = this.models[relation.relatedModelClass.tableName];
if (!relationModel) {
// If the relation model has not been given for the builder using `model()` method
// we don't handle the relations that have that class.
return;
}
if (utils.isExcluded(relationModel.opt, relation.name)) {
// If the property by the relation's name has been excluded, skip this relation.
return;
}
fields[relation.name] = this._relationField(relationModel, relation);
});
return fields;
}
_relationField(modelData, relation) {
if (relation instanceof objection.HasOneRelation
|| relation instanceof objection.BelongsToOneRelation
|| relation instanceof objection.HasOneThroughRelation) {
return {
type: this._typeForModel(modelData),
args: _.omit(modelData.args, OMIT_FROM_RELATION_ARGS),
};
} else if (relation instanceof objection.HasManyRelation || relation instanceof objection.ManyToManyRelation) {
return {
type: new GraphQLList(this._typeForModel(modelData)),
args: _.omit(modelData.args, OMIT_FROM_RELATION_ARGS),
};
}
throw new Error(`relation type "${relation.constructor.name}" is not supported`);
}
_resolverForModel(modelData, extraQuery) {
return (ctx, ignore1, ignore2, data) => {
ctx = ctx || {};
const { modelClass } = modelData;
const ast = (data.fieldASTs || data.fieldNodes)[0];
const eager = this._buildEager(ast, modelClass, data);
const argFilter = this._filterForArgs(ast, modelClass, data.variableValues);
const selectFilter = this._filterForSelects(ast, modelClass, data);
const builder = modelClass.query(ctx.knex);
if (this.builderOptions && this.builderOptions.skipUndefined) {
builder.skipUndefined();
}
if (ctx.onQuery) {
ctx.onQuery(builder, ctx);
}
if (argFilter) {
builder.modify(argFilter);
}
if (selectFilter) {
builder.modify(selectFilter);
}
if (extraQuery) {
builder.modify(extraQuery);
}
if (eager.expression) {
builder.eager(eager.expression, eager.filters);
}
return builder.then(toJson);
};
}
_buildEager(astNode, modelClass, astRoot) {
const eagerExpr = this._buildEagerSegment(astNode, modelClass, astRoot);
if (eagerExpr.expression.length) {
eagerExpr.expression = `[${eagerExpr.expression}]`;
}
return eagerExpr;
}
_buildEagerSegment(astNode, modelClass, astRoot) {
const filters = {};
const relations = modelClass.getRelations();
let expression = '';
for (let i = 0, l = astNode.selectionSet.selections.length; i < l; i += 1) {
const selectionNode = astNode.selectionSet.selections[i];
const relation = relations[selectionNode.name.value];
if (relation) {
expression = this._buildEagerRelationSegment(selectionNode, relation, expression, filters, astRoot);
} else if (selectionNode.kind === KIND_FRAGMENT_SPREAD) {
expression = this._buildEagerFragmentSegment(selectionNode, modelClass, expression, filters, astRoot);
}
}
return {
expression,
filters,
};
}
_buildEagerRelationSegment(selectionNode, relation, expression, filters, astRoot) {
let relExpr = selectionNode.name.value;
const selectFilter = this._filterForSelects(selectionNode, relation.relatedModelClass, astRoot);
const filterNames = [];
if (selectFilter) {
this.filterIndex += 1;
const filterName = `s${this.filterIndex}`;
filterNames.push(filterName);
filters[filterName] = selectFilter;
}
if (selectionNode.arguments.length) {
const argFilter = this._filterForArgs(selectionNode, relation.relatedModelClass, astRoot.variableValues);
if (argFilter) {
this.filterIndex += 1;
const filterName = `f${this.filterIndex}`;
filterNames.push(filterName);
filters[filterName] = argFilter;
}
}
if (filterNames.length) {
relExpr += `(${filterNames.join(', ')})`;
}
const subExpr = this._buildEager(selectionNode, relation.relatedModelClass, astRoot);
if (subExpr.expression.length) {
relExpr += `.${subExpr.expression}`;
Object.assign(filters, subExpr.filters);
}
if (expression.length) {
expression += ', ';
}
return expression + relExpr;
}
_buildEagerFragmentSegment(selectionNode, modelClass, expression, filters, astRoot) {
const fragmentSelection = astRoot.fragments[selectionNode.name.value];
const fragmentExpr = this._buildEagerSegment(fragmentSelection, modelClass, astRoot);
let fragmentExprString = '';
if (fragmentExpr.expression.length) {
fragmentExprString += fragmentExpr.expression;
Object.assign(filters, fragmentExpr.filters);
}
if (expression.length) {
expression += ', ';
}
return expression + fragmentExprString;
}
_filterForArgs(astNode, modelClass, variables) {
const args = astNode.arguments;
if (args.length === 0) {
return null;
}
const modelData = this.models[modelClass.tableName];
const argObjects = new Array(args.length);
for (let i = 0, l = args.length; i < l; i += 1) {
const arg = args[i];
const value = this._argValue(arg.value, variables);
argObjects[i] = {
name: arg.name.value,
value,
};
}
return (builder) => {
for (let i = 0, l = argObjects.length; i < l; i += 1) {
const arg = argObjects[i];
if (!(typeof arg.value === 'undefined' && builder.internalOptions().skipUndefined)) {
modelData.args[arg.name].query(builder, arg.value);
}
}
};
}
_argValue(value, variables) {
if (value.kind === KIND_VARIABLE) {
return variables[value.name.value];
} else if ('value' in value) {
return value.value;
} else if (Array.isArray(value.values)) {
return value.values.map(curValue => this._argValue(curValue, variables));
}
throw new Error(`objection-graphql cannot handle argument value ${JSON.stringify(value)}`);
}
_filterForSelects(astNode, modelClass, astRoot) {
if (!this.enableSelectFiltering) {
return null;
}
const relations = modelClass.getRelations();
const { virtualAttributes } = modelClass;
const selects = this._collectSelects(astNode, relations, virtualAttributes, astRoot.fragments, []);
if (selects.length === 0) {
return null;
}
return (builder) => {
const { jsonSchema } = modelClass;
builder.select(selects.map((it) => {
const col = modelClass.propertyNameToColumnName(it);
if (jsonSchema.properties[it]) {
return `${builder.tableRefFor(modelClass)}.${col}`;
}
return col;
}));
};
}
_collectSelects(astNode, relations, virtuals, fragments, selects) {
for (let i = 0, l = astNode.selectionSet.selections.length; i < l; i += 1) {
const selectionNode = astNode.selectionSet.selections[i];
if (selectionNode.kind === KIND_FRAGMENT_SPREAD) {
this._collectSelects(fragments[selectionNode.name.value], relations, virtuals, fragments, selects);
} else {
const relation = relations[selectionNode.name.value];
const isMetaField = GRAPHQL_META_FIELDS.indexOf(selectionNode.name.value) !== -1;
if (!relation && !isMetaField && !_.includes(virtuals, selectionNode.name.value)) {
selects.push(selectionNode.name.value);
}
}
}
return selects;
}
}
function fieldNameForModel(modelClass) {
return _.camelCase(utils.typeNameForModel(modelClass));
}
function toJson(result) {
if (_.isArray(result)) {
for (let i = 0, l = result.length; i < l; i += 1) {
result[i] = result[i].$toJson();
}
} else {
result = result && result.$toJson();
}
return result;
}
module.exports = SchemaBuilder;