Last active
January 5, 2020 09:57
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
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 | |
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); | |
}); | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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