Skip to content

Instantly share code, notes, and snippets.

@voodooattack
Last active January 5, 2020 09:57
Show Gist options
  • Save voodooattack/ce5f0afb5515ab5a153e535ac20698da to your computer and use it in GitHub Desktop.
Save voodooattack/ce5f0afb5515ab5a153e535ac20698da to your computer and use it in GitHub Desktop.
GraphQL-Sequelize auto-model functionality. Directly translates the schema language into database models via directives.
const { parse, visit, print, Kind, BREAK } = require('graphql/language');
const { buildASTSchema } = require('graphql/utilities');
const { addResolveFunctionsToSchema } = require('graphql-tools');
const Sequelize = require('sequelize');
const { graphql } = require('graphql');
const jexl = require('jexl');
const deepAssign = require('deep-assign');
const { resolver: sequelizeResolver } = require('graphql-sequelize');
const { inspect } = require('util');
const log = (msg) => console.log(inspect(msg, { colors: true, depth: null }));
/**
* Combine the fields of two or more AST nodes, does no error checking!
* @param types An array with types to combine.
* @returns {*}
*/
function combineASTTypes(types) {
return types.reduce((p, n) => Object.assign(p, n, { fields: n.fields.concat(p.fields || []) }), {});
}
/**
* Combine multiple AST schemas into one. This will consolidate the Query, Mutation, and Subscription types if found.
* @param schemas An array with the schemas to combine.
* @returns {*}
*/
function combineASTSchemas(schemas) {
const result = { kind: 'Document', definitions: [] };
const queries = [], mutations = [], subscription = [];
const withoutRootTypes = schemas.map(schema => visit(schema, {
enter(node /*, key, parent, path, ancestors*/) {
if (node.kind === 'ObjectTypeDefinition') {
if (node.name.value == 'Query') {
queries.push(node);
return null;
} else if (node.name.value == 'Mutation') {
mutations.push(node);
return null;
} else if (node.name.value == 'Subscription') {
subscription.push(node);
return null;
}
}
}
}));
const query = combineASTTypes(queries);
const mutation = combineASTTypes(mutations);
if (queries.length)
result.definitions.push(query);
if (mutations.length)
result.definitions.push(mutation);
if (subscription.length)
result.definitions.push(subscription);
withoutRootTypes.forEach(schema =>
result.definitions = [...result.definitions, ...schema.definitions]);
return result;
}
/**
* Calls directives with a `resolveStatic` hook at the time of parsing.
* @param ast GraphQL schema AST.
* @param directives The directives collection.
* @param resolvers (In/Out) The resolvers for this fragment.
* @param context The shared `this` object for all resolvers.
* @param throwOnMissing Should we throw if an unknown directive is encountered?
* @returns {*} Revised AST as transformed by the directives.
*/
function applyDirectivesToAST(ast, directives, resolvers = {}, context = {}, throwOnMissing = true) {
const transformedAST = visit(ast, {
enter(node, key, parent, path, ancestors) {
if (node.directives && node.directives.length) {
let current = node;
node.directives.forEach(directive => {
if (!current) return;
const directiveName = directive.name.value;
if (directiveName in directives) {
const staticFunctions = directives[directiveName].resolveStatic;
if (staticFunctions.enter) {
const ret = staticFunctions.enter.call(context, current, directive, {
key, parent, path, ancestors, resolvers
});
if (typeof ret !== typeof undefined)
current = ret;
}
} else if (throwOnMissing)
throw new Error(`Unknown directive '${directiveName}'`);
});
return current;
}
},
leave(node, key, parent, path, ancestors) {
if (node.directives && node.directives.length) {
let current = node;
node.directives.forEach(directive => {
if (!current) return;
const directiveName = directive.name.value;
if (directiveName in directives) {
const staticFunctions = directives[directiveName].resolveStatic;
if (staticFunctions.leave) {
const ret = staticFunctions.leave.call(context, current, directive, {
key, parent, path, ancestors, resolvers
});
if (typeof ret !== typeof undefined)
current = ret;
}
}
});
return current;
}
}
});
Object.keys(directives).map(key => directives[key]).forEach(({ resolveStatic }) =>
resolveStatic.finalize && resolveStatic.finalize.apply(context, transformedAST));
return transformedAST;
}
/**
* Builds and combines a GraphQL schema from textual fragments.
* @param schemaFragments String array of GraphQL fragments.
* @param resolvers Resolvers to attach to the schema.
* @param directives Any directives to apply while parsing.
*/
function buildSchema(schemaFragments, resolvers, directives) {
const initialAST = schemaFragments.map(fragment => parse(fragment));
const combinedAST = combineASTSchemas(initialAST);
const transformedAST = applyDirectivesToAST(combinedAST, directives, resolvers);
console.log(print(transformedAST));
const builtSchema = buildASTSchema(transformedAST);
addResolveFunctionsToSchema(builtSchema, resolvers);
return builtSchema;
}
function fetchType(name, ast) {
let currentNode;
visit(ast, {
enter(node) {
if (node.kind.endsWith('TypeDefinition') && node.name.value === name) {
currentNode = node;
return BREAK;
}
}
});
return currentNode;
}
function cloneASTType(node) {
const str = print(node);
return parse(str).definitions[0];
}
/**
* Transform an AST type to an input.
* @param type The type to transform.
* @param newName The new name.
* @param ast The GraphQL AST
* @param exclude Fields to exclude.
* @param optional Fields to make optional.
* @param generatedInputHistory (INTERNAL) Used internally to prevent recursion.
* @returns {*}
*/
function transformASTTypeToInput(type, { newName, ast, exclude = [], optional = [] }, generatedInputHistory = []) {
return visit(type, {
enter(node, key, parent, path, ancestors) {
let copy = deepAssign({}, node);
copy.directives = [];
switch (copy.kind) {
case Kind.OBJECT_TYPE_DEFINITION:
copy.kind = Kind.INPUT_OBJECT_TYPE_DEFINITION;
copy.name = deepAssign({}, copy.name);
copy.name.value = newName;
break;
case Kind.FIELD_DEFINITION:
if (exclude.indexOf(node.name.value) != -1)
return null; // Delete this node
copy.kind = Kind.INPUT_VALUE_DEFINITION;
const fieldName = copy.name.value;
let typeName = null;
visit(copy, {
[Kind.NAMED_TYPE](typeNode) { typeName = typeNode.name.value; }
});
const fieldType = fetchType(typeName, ast);
if (fieldType && fieldType.kind == Kind.OBJECT_TYPE_DEFINITION) {
const inputName = fieldType.name.value + 'AsInput';
if (generatedInputHistory.indexOf(inputName) == -1) {
generatedInputHistory.push(inputName);
if (!fetchType(inputName, ast) && fieldType.name.value != type.name.value) {
const newInput = transformASTTypeToInput(fieldType, { newName: inputName, ast }, generatedInputHistory);
ast.definitions.push(newInput);
}
}
copy = visit(copy, {
[Kind.NAMED_TYPE](typeNode) {
const newNode = deepAssign({}, typeNode);
newNode.name = deepAssign({}, newNode.name);
newNode.name.value = inputName;
return newNode;
}
});
if (optional.indexOf(fieldName) != -1) {
while (copy.type.kind == Kind.NON_NULL_TYPE) {
copy.type = deepAssign({}, copy.type.type);
}
}
}
break;
}
return copy;
}
});
}
function ASTTypeToSequelize(astType, { model, models, field, ast }) {
const GraphQLTypes = {
'ID': Sequelize.INTEGER,
'String': Sequelize.STRING,
'Int': Sequelize.INTEGER,
'Float': Sequelize.FLOAT,
'Boolean': Sequelize.BOOLEAN,
// CUSTOM TYPES
'Date': Sequelize.DATE,
'Value': Sequelize.TEXT
};
let type = {}, list = false;
if (astType.kind == 'NonNullType') {
type.allowNull = false;
astType = astType.type;
}
if (astType.kind == 'ListType') {
list = true;
astType = astType.type;
}
if (astType.kind == 'NonNullType') {
astType = astType.type;
}
if (astType.kind == 'NamedType' && GraphQLTypes[astType.name.value]) {
type.type = GraphQLTypes[astType.name.value];
if (astType.name.value == 'Value') { // JSON
type.get = function JSONGetter() {
const value = this.getDataValue(field.name.value);
return value ? JSON.parse(value) : value;
};
type.set = function enumSetter(value) {
this.setDataValue(field.name.value, JSON.stringify(value));
};
} else if (list)
type.type = Sequelize.ARRAY(type.type);
return type;
}
const targetType = fetchType(astType.name.value, ast);
if (targetType.kind === 'EnumTypeDefinition') {
const values = targetType.values.map(val => val.name.value);
type.type = Sequelize.ENUM(...values);
if (list) {
type.type = Sequelize.TEXT;
type.get = function enumGetter() {
const value = this.getDataValue(field.name.value);
return value ? value.split(',') : value;
};
type.set = function enumSetter(value) {
this.setDataValue(field.name.value, value.join(','));
}
}
return type;
} else if (targetType.kind === 'ObjectTypeDefinition') {
model.relations[field.name.value] = {
field,
type: list ? 'belongsToMany' : 'belongsTo',
target: targetType.name.value,
attributes: { as: field.name.value, through: `${model.name}_${field.name.value}_${targetType.name.value}` }
};
} else {
throw new Error(`Unsupported type definition '${targetType.kind}'`);
}
}
const sequelize = new Sequelize(null, null, null, { dialect: 'sqlite', storage: 'test.sqlite' });
function applyRelationships(models) {
Object.keys(models).forEach(key => {
const model = models[key];
const modelDef = model.def;
Object.keys(model.relations).forEach(relation => {
const { type, target, field, attributes } = model.relations[relation];
let relationship = null, relationshipOptions = {};
switch (type) {
case 'belongsTo':
relationship = models[target].def.belongsTo(modelDef, attributes);
break;
case 'belongsToMany':
relationship = models[target].def.belongsToMany(modelDef, attributes);
if (field.type.kind == Kind.NON_NULL_TYPE) {
relationshipOptions = { after(result) { return result || []; } }
}
modelDef.belongsTo(models[target].def, attributes);
break;
}
if (relationship)
model.resolvers[field.name.value] = sequelizeResolver(relationship, relationshipOptions);
});
});
}
const directives = {
/**
* Marks a type as a database model.
*/
model: {
name: 'model',
description: 'Marks a type as a database model.',
resolveStatic:
{
finalize() {
applyRelationships(this.models);
},
enter(node, directive, { resolvers, ancestors }) {
this.models = this.models || {};
const models = this.models;
const [ ast ] = ancestors;
if (node.kind !== 'ObjectTypeDefinition')
throw new Error(`Only object types can be marked as models.`);
resolvers[node.name.value] = resolvers[node.name.value] || {};
const model = this.model = this.models[node.name.value] = {
name: node.name.value,
resolvers: resolvers[node.name.value],
def: null,
attributes: {},
relations: {},
with: ['create', 'list', 'view', 'update', 'delete']
};
directive.arguments.forEach(argument => {
switch (argument.name.value) {
case 'with':
model.with = argument.value.value.split(' ');
break;
default:
throw new Error(`Unknown argument '${argument.name.value}' specified in \`model\` directive.`);
}
});
visit(node, {
FieldDefinition(field) {
const type = ASTTypeToSequelize(field.type, { field, models, model, ast });
if (type)
model.attributes[field.name.value] = type;
}
});
},
leave(node, directive, { resolvers, ancestors }) {
const model = this.model;
const [ ast ] = ancestors;
const name = node.name.value;
resolvers.Query = resolvers.Query || {};
resolvers.Mutation = resolvers.Mutation || {};
resolvers[node.name.value] = resolvers[node.name.value] || {};
const modelDef = this.model.def = sequelize.define(name, this.model.attributes);
const queries = [], mutations = [];
this.model.with.forEach(method => {
switch (method) {
case 'view':
queries.push(`view${name}(id: ID!): ${name}`);
resolvers.Query[`view${name}`] = sequelizeResolver(modelDef);
break;
case 'list':
queries.push(`list${name}(where: Value): [${name}]`);
resolvers.Query[`list${name}`] = sequelizeResolver(modelDef);
break;
case 'delete':
mutations.push(`delete${name}(id: ID!): Boolean`);
resolvers.Mutation[`delete${name}`] = function _delete(_, { id }) {
return sequelize.model(name).destroy({ id });
};
break;
case 'update':
let updatePayload = fetchType(`${name}UpdatePayload`, ast);
if (!updatePayload) {
updatePayload = transformASTTypeToInput(node, {
newName: `${name}UpdatePayload`,
ast,
optional: Object.keys(model.relations)
});
ast.definitions.push(updatePayload);
}
mutations.push(`update${name}(id: ID!, payload: ${name}UpdatePayload!): ${name}`);
resolvers.Mutation[`update${name}`] = function update(_, { id, payload }) {
return sequelize.model(name).update({ id }, payload);
};
break;
case 'create':
let createPayload = fetchType(`${name}CreatePayload`, ast);
if (!createPayload) {
createPayload = transformASTTypeToInput(node, {
newName: `${name}CreatePayload`,
ast,
exclude: ['id'],
optional: Object.keys(model.relations)
});
ast.definitions.push(createPayload);
}
mutations.push(`create${name}(payload: ${name}CreatePayload!): ${name}`);
resolvers.Mutation[`create${name}`] = function create(_, { payload }) {
return sequelize.model(name).create(payload);
};
break;
default:
throw new Error(`Unknown method '${method}' specified in \`model\` directive.`);
}
});
let query = fetchType('Query', ast), mutation = fetchType('Mutation', ast);
if (!query && queries.length) {
query = parse('type Query { }').definitions[0];
ast.definitions.push(query);
}
if (!mutation && mutations.length) {
mutation = parse('type Mutation { }').definitions[0];
ast.definitions.push(mutation);
}
if (queries.length) {
const queryFields = parse(`
type Query {
${queries.join('\n')}
}
`).definitions[0];
query.fields = query.fields.concat(queryFields.fields);
}
if (mutations.length) {
const mutationFields = parse(`
type Mutation {
${mutations.join('\n')}
}
`).definitions[0];
mutation.fields = mutation.fields.concat(mutationFields.fields);
}
this.model = null;
}
}
},
/**
* Marks a database column as primary key
*/
primary: {
resolveStatic: {
enter(node) {
this.model.attributes[node.name.value].primaryKey = true;
}
}
},
/**
* Marks a database column as auto-incrementing
*/
increments: {
resolveStatic: {
enter(node) {
this.model.attributes[node.name.value].autoIncrement = true;
}
}
},
/**
* Marks a database column as unique
*/
unique: {
resolveStatic: {
enter(node) {
this.model.attributes[node.name.value].unique = true;
}
}
},
/**
* Marks a database column as virtual
*/
virtual: {
resolveStatic: {
enter(node, directive) {
const { model } = this;
delete model.attributes[node.name.value];
directive.arguments.forEach(argument => {
switch (argument.name.value) {
case 'expr':
model.resolvers[node.name.value] = function (it) {
return jexl.eval(argument.value.value, it)
};
break;
default:
throw new Error(`Unknown argument '${argument.name.value}' specified in \`virtual\` directive.`);
}
});
}
}
},
};
function parseValueLiteral({ kind, value, values, fields }) {
switch (kind) {
case Kind.STRING:
case Kind.BOOLEAN:
return value;
case Kind.INT:
case Kind.FLOAT:
return parseFloat(value);
case Kind.OBJECT: {
const value = Object.create(null);
fields.forEach(field => {
value[field.name.value] = parseValueLiteral(field.value);
});
return value;
}
case Kind.LIST:
return values.map(parseValueLiteral);
default:
return null;
}
}
const File = `
type File @model
{
name: String!
uploadPath: String!
mime: String
user: User!
}
`;
const User = `
scalar Value
enum Role {
User
Moderator
Administrator
}
type User @model(with: "create list view update delete")
{
id: ID! @primary @increments
email: String! @unique
firstName: String!
lastName: String!
fullName: String @virtual(expr: "lastName + ', ' + firstName")
friends: [User!]!
roles: [Role!]!
files: [File!]!
meta: Value
}
`;
const schema = buildSchema([User, File], {
Value: {
__parseLiteral: parseValueLiteral,
__serialize: value => value,
__parseValue: value => value,
},
}, directives);
const queries = [
{
query: `
mutation($payload: UserCreatePayload!) {
createUser(payload: $payload) {
id
email
firstName
lastName
roles
meta
friends {
id,
firstName
}
}
}
`,
variables: {
payload: {
email: '[email protected]',
firstName: 'Abdullah',
lastName: 'Ali',
roles: ['Administrator', 'Moderator'],
meta: {
test: 'Value'
}
}
}
},
{
query: `
query {
listUser {
id
email
firstName
lastName
fullName
roles
meta
friends {
id,
firstName
}
}
}
`
}
];
sequelize.sync({ force: true }).then(() => {
return Promise.all(queries.map(({ query, variables }) =>
graphql(schema, query, {}, {}, variables))).then(result => {
log(result)
}).catch(console.error);
});
{
"name": "gql-directives",
"version": "1.0.0",
"description": "",
"main": "gql-directives.js",
"dependencies": {
"deep-assign": "^2.0.0",
"graphql": "^0.7.2",
"graphql-relay": "^0.4.4",
"graphql-sequelize": "^4.0.5",
"graphql-tools": "^0.8.1",
"jexl": "^1.1.4",
"sequelize": "^3.27.0",
"sqlite3": "^3.1.8"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment