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: 'voodooattack@hotmail.com', 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); });