diff --git a/src/augment.js b/src/augment.js new file mode 100644 index 00000000..6293fe94 --- /dev/null +++ b/src/augment.js @@ -0,0 +1,877 @@ +import { neo4jgraphql } from './index'; +import { parse, print } from 'graphql'; +import { + createOperationMap, + createRelationMap, + getNamedType, + getPrimaryKey, + getFieldDirective, + getRelationTypeDirectiveArgs, + getFieldArgumentsFromAst, + getRelationMutationPayloadFieldsFromAst, + getRelationDirection, + getRelationName, + getTypeDirective, + isBasicScalar, + isListType, + isKind, + isNonNullType, + isNodeType, + parseFieldSdl +} from './utils'; + +export const augmentTypeMap = (typeMap) => { + const types = Object.keys(typeMap); + typeMap = initializeOperationTypes(types, typeMap); + const queryMap = createOperationMap(typeMap.Query); + const mutationMap = createOperationMap(typeMap.Mutation); + typeMap = computeRelationTypeDirectiveDefaults(typeMap); + const relationMap = createRelationMap(typeMap); + let astNode = {}; + Object.keys(typeMap).forEach(t => { + astNode = typeMap[t]; + astNode = augmentType(astNode, typeMap); + typeMap = possiblyAddTypeInput(astNode, typeMap); + typeMap = possiblyAddQuery(astNode, typeMap, queryMap); + typeMap = possiblyAddOrderingEnum(astNode, typeMap); + typeMap = possiblyAddTypeMutations(astNode, typeMap, mutationMap); + typeMap = possiblyAddRelationMutations(astNode, typeMap, mutationMap, relationMap); + typeMap[t] = astNode; + }); + typeMap = augmentQueryArguments(typeMap); + return typeMap; +} + +const augmentType = (astNode, typeMap) => { + if(isNodeType(astNode)) { + astNode.fields = addOrReplaceNodeIdField(astNode, "ID"); + astNode.fields = possiblyAddTypeFieldArguments(astNode, typeMap); + } + return astNode; +} + +const augmentQueryArguments = (typeMap) => { + const queryMap = createOperationMap(typeMap.Query); + let args = []; + let valueTypeName = ""; + let valueType = {}; + let field = {}; + let queryNames = Object.keys(queryMap); + if(queryNames.length > 0) { + queryNames.forEach(t => { + field = queryMap[t]; + valueTypeName = getNamedType(field).name.value; + valueType = typeMap[valueTypeName]; + if(isNodeType(valueType) && isListType(field)) { + args = field.arguments; + queryMap[t].arguments = possiblyAddArgument(args, "first", "Int"); + queryMap[t].arguments = possiblyAddArgument(args, "offset", "Int"); + queryMap[t].arguments = possiblyAddArgument(args, "orderBy", `_${valueTypeName}Ordering`); + } + }); + typeMap.Query.fields = Object.values(queryMap); + } + return typeMap; +} + +export const augmentResolvers = (queryResolvers, mutationResolvers, typeMap) => { + let resolvers = {}; + const queryMap = createOperationMap(typeMap.Query); + queryResolvers = possiblyAddResolvers(queryMap, queryResolvers) + if(Object.keys(queryResolvers).length > 0) { + resolvers.Query = queryResolvers; + } + const mutationMap = createOperationMap(typeMap.Mutation); + mutationResolvers = possiblyAddResolvers(mutationMap, mutationResolvers) + if(Object.keys(mutationResolvers).length > 0) { + resolvers.Mutation = mutationResolvers; + } + return resolvers; +} + +export const possiblyAddArgument = (args, fieldName, fieldType) => { + const fieldIndex = args.findIndex(e => e.name.value === fieldName); + if(fieldIndex === -1) { + args.push({ + "kind": "InputValueDefinition", + "name": { + "kind": "Name", + "value": fieldName, + }, + "type": { + "kind": "NamedType", + "name": { + "kind": "Name", + "value": fieldType, + }, + }, + "directives": [], + }); + } + return args; +}; + +const possiblyAddResolvers = (operationTypeMap, resolvers) => { + let operationName = ""; + return Object.keys(operationTypeMap).reduce( (acc, t) => { + // if no resolver provided for this operation type field + operationName = operationTypeMap[t].name.value; + if(acc[operationName] === undefined) { + acc[operationName] = neo4jgraphql; + } + return acc; + }, resolvers); +} + +const possiblyAddTypeInput = (astNode, typeMap) => { + const inputName = `_${astNode.name.value}Input`; + if(isNodeType(astNode)) { + if(typeMap[inputName] === undefined) { + const pk = getPrimaryKey(astNode); + if(pk) { + typeMap[inputName] = parse(` + input ${inputName} { ${ + pk.name.value + }: ${ + // Always exactly require the pk of a node type + getNamedType(pk).name.value + }! }`).definitions[0]; + } + } + } + else if(getTypeDirective(astNode, "relation")) { + if(typeMap[inputName] === undefined) { + let fieldName = ""; + let fieldValueType = ""; + let isRequired = false; + const hasSomePropertyField = astNode.fields.find(e => e.name.value !== "from" && e.name.value !== "to"); + if(hasSomePropertyField) { + typeMap[inputName] = parse(`input ${inputName} {${ + astNode.fields.reduce( (acc, t) => { + fieldName = t.name.value; + isRequired = isNonNullType(t); + if(fieldName !== "_id" && fieldName !== "to" + && fieldName !== "from" && !getFieldDirective(t, "cypher")) { + fieldValueType = getNamedType(t).name.value; + // TODO allow custom scalars or enums? + if(isBasicScalar(fieldValueType)) { + acc.push(`${t.name.value}: ${fieldValueType}${isRequired ? '!' : ''}`); + } + } + return acc; + }, []).join('\n') + }}`).definitions[0]; + } + } + } + return typeMap; +} + +const possiblyAddQuery = (astNode, typeMap, queryMap) => { + if(isNodeType(astNode)) { + const name = astNode.name.value; + if(queryMap[name] === undefined) { + typeMap.Query.fields.push({ + "kind": "FieldDefinition", + "name": { + "kind": "Name", + "value": name + }, + "arguments": createQueryArguments(astNode, typeMap), + "type": { + "kind": "ListType", + "type": { + "kind": "NamedType", + "name": { + "kind": "Name", + "value": name + } + } + }, + "directives": [], + }); + } + } + return typeMap; +} + +const possiblyAddOrderingEnum = (astNode, typeMap) => { + if(isNodeType(astNode)) { + const name = `_${astNode.name.value}Ordering`; + const values = createOrderingFields(astNode.fields, typeMap); + // Add ordering enum if it does not exist already and if + // there is at least one basic scalar field on this type + if(typeMap[name] === undefined && values.length > 0) { + typeMap[name] = { + kind: "EnumTypeDefinition", + name: { + kind: "Name", + value: name + }, + directives: [], + values: values + }; + } + } + return typeMap; +} + +const possiblyAddTypeMutations = (astNode, typeMap, mutationMap) => { + if(isNodeType(astNode)) { + typeMap = possiblyAddTypeMutation(`Create`, astNode, typeMap, mutationMap); + typeMap = possiblyAddTypeMutation(`Update`, astNode, typeMap, mutationMap); + typeMap = possiblyAddTypeMutation(`Delete`, astNode, typeMap, mutationMap); + } + return typeMap; +} + +const possiblyAddRelationMutations = (astNode, typeMap, mutationMap, relationMap) => { + const typeName = astNode.name.value; + const fields = astNode.fields; + const fieldCount = fields ? fields.length : 0; + let relationFieldDirective = {}; + let relationName = ""; + let direction = ""; + let fieldValueName = ""; + let relatedAstNode = {}; + let relationTypeDirective = {}; + let nameArgument = {}; + let fromArgument = {}; + let toArgument = {}; + let fromName = ""; + let toName = ""; + let capitalizedFieldName = ""; + let field = {}; + let fieldIndex = 0; + if(isNodeType(astNode)) { + for(; fieldIndex < fieldCount; ++fieldIndex) { + field = fields[fieldIndex]; + fieldValueName = getNamedType(field).name.value; + capitalizedFieldName = capitalizeName(field.name.value); + relatedAstNode = typeMap[fieldValueName]; + if(relatedAstNode) { + relationTypeDirective = getTypeDirective(relatedAstNode, "relation"); + if(isNodeType(relatedAstNode)) { + relationFieldDirective = getFieldDirective(field, "relation"); + if(relationFieldDirective) { + relationName = getRelationName(relationFieldDirective); + direction = getRelationDirection(relationFieldDirective); + fromName = typeName; + toName = fieldValueName; + if(direction === "IN" || direction === "in") { + let temp = fromName; + fromName = toName; + toName = temp; + } + typeMap = possiblyAddRelationMutationField(typeName, capitalizedFieldName, fromName, toName, mutationMap, typeMap, relationName, relatedAstNode, false); + } + } + else if(relationTypeDirective) { + let typeDirectiveArgs = relationTypeDirective ? relationTypeDirective.arguments : []; + nameArgument = typeDirectiveArgs.find(e => e.name.value === "name"); + fromArgument = typeDirectiveArgs.find(e => e.name.value === "from"); + toArgument = typeDirectiveArgs.find(e => e.name.value === "to"); + relationName = nameArgument.value.value; + fromName = fromArgument.value.value; + toName = toArgument.value.value; + // directive to and from are not the same and neither are equal to this + if(fromName !== toName && toName !== typeName && fromName !== typeName) { + throw new Error(`The '${field.name.value}' field on the '${typeName}' type uses the '${relatedAstNode.name.value}' + but '${relatedAstNode.name.value}' comes from '${fromName}' and goes to '${toName}'`); + } + typeMap = possiblyAddRelationMutationField(typeName, capitalizedFieldName, fromName, toName, mutationMap, typeMap, relationName, relatedAstNode, true); + typeMap = possiblyAddNonSymmetricRelationshipType(relatedAstNode, capitalizedFieldName, typeName, typeMap); + fields[fieldIndex] = replaceRelationTypeValue(field, capitalizedFieldName, typeName); + } + } + }; + } + return typeMap; +} + +const possiblyAddTypeFieldArguments = (astNode, typeMap) => { + const fields = astNode.fields; + let relationTypeName = ""; + let relationType = {}; + let args = []; + fields.forEach(field => { + relationTypeName = getNamedType(field).name.value; + relationType = typeMap[relationTypeName]; + if(isNodeType(relationType) + && isListType(field) + && (getFieldDirective(field, "relation") || getFieldDirective(field, "cypher"))) { + args = field.arguments; + field.arguments = possiblyAddArgument(args, "first", "Int"); + field.arguments = possiblyAddArgument(args, "offset", "Int"); + field.arguments = possiblyAddArgument(args, "orderBy", `_${relationTypeName}Ordering`); + } + }); + return fields; +} + +const possiblyAddObjectType = (typeMap, name) => { + if(typeMap[name] === undefined) { + typeMap[name] = { + kind: 'ObjectTypeDefinition', + name: { + kind: 'Name', + value: name + }, + interfaces: [], + directives: [], + fields: [] + }; + } + return typeMap; +} + +const createOrderingFields = (fields, typeMap) => { + let type = {}; + return fields.reduce( (acc, t) => { + type = getNamedType(t); + if(isBasicScalar(type.name.value)) { + acc.push({ + kind: 'EnumValueDefinition', + name: { + kind: "Name", + value: `${t.name.value}_asc` + }, + directives: [] + }); + acc.push({ + kind: 'EnumValueDefinition', + name: { + kind: "Name", + value: `${t.name.value}_desc` + }, + directives: [] + }); + } + return acc; + }, []); +} + +const buildAllFieldArguments = (namePrefix, astNode, typeMap) => { + let fields = []; + let type = {}; + let fieldName = ""; + let valueTypeName = ""; + let valueType = {}; + switch(namePrefix) { + case 'Create': { + let firstIdField = undefined; + astNode.fields.reduce( (acc, t) => { + type = getNamedType(t); + fieldName = t.name.value; + valueTypeName = type.name.value; + valueType = typeMap[valueTypeName]; + // If this field is not _id, and not a list, + // and is not computed, and either a basic scalar + // or an enum + if(fieldName !== "_id" + && !isListType(t) + && !getFieldDirective(t, "cypher") + && (isBasicScalar(valueTypeName) + || isKind(valueType, "EnumTypeDefinition"))) { + // Require if required + if(isNonNullType(t)) { + // Regardless of whether it is NonNullType, + // don't require the first ID field discovered + if(valueTypeName === "ID" && !firstIdField) { + // will only be true once, this field will + // by default recieve an auto-generated uuid, + // if no value is provided + firstIdField = t; + acc.push({ + "kind": "InputValueDefinition", + "name": { + "kind": "Name", + "value": fieldName + }, + "type": type, + "directives": [], + }); + } + else { + acc.push({ + "kind": "InputValueDefinition", + "name": { + "kind": "Name", + "value": fieldName + }, + "type": { + "kind": "NonNullType", + type: type + }, + "directives": [], + }); + } + } + else { + acc.push({ + "kind": "InputValueDefinition", + "name": { + "kind": "Name", + "value": fieldName + }, + "type": type, + "directives": [], + }); + } + } + return acc; + }, fields); + break; + } + case 'Update': { + const primaryKey = getPrimaryKey(astNode); + let augmentedFields = []; + if(primaryKey) { + // Primary key field is first field and required + const primaryKeyName = primaryKey.name.value; + const primaryKeyType = getNamedType(primaryKey); + augmentedFields.push({ + "kind": "InputValueDefinition", + "name": { + "kind": "Name", + "value": primaryKeyName + }, + "type": { + "kind": "NonNullType", + "type": primaryKeyType + }, + "directives": [], + }); + astNode.fields.reduce( (acc, t) => { + type = getNamedType(t); + fieldName = t.name.value; + valueTypeName = type.name.value; + valueType = typeMap[valueTypeName]; + // If this field is not the primary key, and not _id, + // and not a list, and not computed, and either a basic + // scalar or an enum + if(fieldName !== primaryKeyName + && fieldName !== "_id" + && !isListType(t) + && !getFieldDirective(t, "cypher") + && (isBasicScalar(valueTypeName) + || isKind(valueType, "EnumTypeDefinition"))) { + acc.push({ + "kind": "InputValueDefinition", + "name": { + "kind": "Name", + "value": fieldName + }, + "type": type, + "directives": [], + }); + } + return acc; + }, augmentedFields); + // Use if there is at least one field other than + // the primaryKey field used for node selection + if(augmentedFields.length > 1) { + fields = augmentedFields; + } + } + break; + } + case 'Delete': { + const primaryKey = getPrimaryKey(astNode); + const primaryKeyName = primaryKey.name.value; + const primaryKeyType = getNamedType(primaryKey); + fields.push({ + "kind": "InputValueDefinition", + "name": { + "kind": "Name", + "value": primaryKeyName + }, + "type": { + "kind": "NonNullType", + "type": { + "kind": "NamedType", + "name": { + "kind": "Name", + "value": primaryKeyType.name.value + } + } + }, + "directives": [] + }); + break; + } + } + return fields; +} + +const possiblyAddTypeMutation = (namePrefix, astNode, typeMap, mutationMap) => { + const typeName = astNode.name.value; + const mutationName = namePrefix + typeName; + // Only generate if the mutation named mutationName does not already exist + if(mutationMap[mutationName] === undefined) { + let args = buildAllFieldArguments(namePrefix, astNode, typeMap); + if(args.length > 0) { + typeMap.Mutation.fields.push({ + "kind": "FieldDefinition", + "name": { + "kind": "Name", + "value": mutationName + }, + "arguments": args, + "type": { + "kind": "NamedType", + "name": { + "kind": "Name", + "value": typeName + } + }, + "directives": [], + }); + } + } + return typeMap; +} + +const replaceRelationTypeValue = (field, capitalizedFieldName, typeName) => { + const isList = isListType(field); + let type = { + "kind": "NamedType", + "name": { + "kind": "Name", + "value": `_${typeName}${capitalizedFieldName}` + } + }; + if(isList) { + type = { + kind: "ListType", + type: type + }; + } + field.type = type; + return field; +} + +const possiblyAddNonSymmetricRelationshipType = (relationAstNode, capitalizedFieldName, typeName, typeMap) => { + const fieldTypeName = `_${typeName}${capitalizedFieldName}`; + if(!typeMap[fieldTypeName]) { + let fieldName = ""; + let fieldValueName = ""; + let fromField = {}; + let toField = {}; + let fromValue = ""; + let toValue = ""; + let fields = relationAstNode.fields; + const relationTypeDirective = getRelationTypeDirectiveArgs(relationAstNode); + if(relationTypeDirective) { + const relationPropertyFields = fields.reduce( (acc, t) => { + fieldValueName = getNamedType(t).name.value; + fieldName = t.name.value; + if(fieldName === "from") { + fromValue = fieldValueName; + fromField = t; + } + else if(fieldName === "to") { + toValue = fieldValueName; + toField = t; + } + else { + // Exclude .to and .from, but gather them from along the way + // using previous branches above + acc.push(`${fieldName}: ${fieldValueName} ${print(t.directives)}`); + } + return acc; + }, []).join('\n'); + + typeMap[fieldTypeName] = parse(` + type ${fieldTypeName} ${print(relationAstNode.directives)} { + ${relationPropertyFields} + ${getRelatedTypeSelectionFields(typeName, fromValue, fromField, toValue, toField)} + } + `); + } + } + return typeMap; +} + +const getRelatedTypeSelectionFields = (typeName, fromValue, fromField, toValue, toField) => { + // TODO identify and handle ambiguity of relation type symmetry, Person FRIEND_OF Person, etc. + // if(typeName === fromValue && typeName === toValue) { + // return ` + // from${fromField.arguments.length > 0 + // ? `(${getFieldArgumentsFromAst(fromField)})` + // : ''}: ${fromValue} + // to${toField.arguments.length > 0 + // ? `(${getFieldArgumentsFromAst(toField)})` + // : ''}: ${toValue}`; + // } + return typeName === fromValue + // If this is the from, the allow selecting the to + ? `${toValue}(${getFieldArgumentsFromAst(toField, toValue)}): ${toValue}` + // else this is the to, so allow selecting the from + : typeName === toValue + ? `${fromValue}(${getFieldArgumentsFromAst(fromField, fromValue)}): ${fromValue}` + : ''; +} + +const addOrReplaceNodeIdField = (astNode, valueType) => { + const fields = astNode ? astNode.fields : []; + const index = fields.findIndex(e => e.name.value === '_id'); + const definition = { + "kind": "FieldDefinition", + "name": { + "kind": "Name", + "value": "_id" + }, + "arguments": [], + "type": { + "kind": "NamedType", + "name": { + "kind": "Name", + "value": valueType + } + }, + "directives": [] + };`` + // If it has already been provided, replace it to force valueType, + // else add it as the last field + index >= 0 + ? fields.splice(index, 1, definition) + : fields.push(definition) + return fields; +} + +const possiblyAddRelationMutationField = (typeName, capitalizedFieldName, fromName, toName, mutationMap, typeMap, relationName, relatedAstNode, relationHasProps) => { + const mutationTypes = ["Add", "Remove"]; + let mutationName = ''; + let payloadTypeName = ''; + let hasSomePropertyField = false; + mutationTypes.forEach(action => { + mutationName = `${action}${typeName}${capitalizedFieldName}`; + // Prevents overwriting + if(mutationMap[mutationName] === undefined) { + payloadTypeName = `_${mutationName}Payload`; + hasSomePropertyField = relatedAstNode.fields.find(e => e.name.value !== "from" && e.name.value !== "to"); + // If we know we should expect data properties (from context: relationHasProps) + // and if there is at least 1 field that is not .to or .from (hasSomePropertyField) + // and if we are generating the add relation mutation, then add the .data argument + const shouldUseRelationDataArgument = relationHasProps && hasSomePropertyField && action === "Add"; + // Relation mutation type + typeMap.Mutation.fields.push(parseFieldSdl(` + ${mutationName}(from: _${fromName}Input!, to: _${toName}Input!${ + shouldUseRelationDataArgument + ? `, data: _${relatedAstNode.name.value}Input!` + : '' + }): ${payloadTypeName} @MutationMeta(relationship: "${relationName}", from: "${fromName}", to: "${toName}") + `)); + // Prevents overwriting + if(typeMap[payloadTypeName] === undefined) { + typeMap[payloadTypeName] = parse(` + type ${payloadTypeName} @relation(name: "${relationName}", from: "${fromName}", to: "${toName}") { + from: ${fromName} + to: ${toName} + ${shouldUseRelationDataArgument + ? getRelationMutationPayloadFieldsFromAst(relatedAstNode) + : '' + } + } + `); + } + } + }); + return typeMap; +} + +const capitalizeName = (name) => { + return name.charAt(0).toUpperCase() + name.substr(1); +} + +const createQueryArguments = (astNode, typeMap) => { + let type = {}; + let valueTypeName = ""; + astNode.fields = addOrReplaceNodeIdField(astNode, "Int"); + return astNode.fields.reduce( (acc, t) => { + type = getNamedType(t); + valueTypeName = type.name.value; + if(isQueryArgumentFieldType(type, typeMap[valueTypeName])) { + acc.push({ + "kind": "InputValueDefinition", + "name": { + "kind": "Name", + "value": t.name.value + }, + "type": type, + "directives": [], + }); + } + return acc; + }, []); +} + +const isQueryArgumentFieldType = (type, valueType) => { + return isBasicScalar(type.name.value) + || isKind(valueType, "EnumTypeDefinition"); +} + +const initializeOperationTypes = (types, typeMap) => { + if(types.length > 0) { + typeMap = possiblyAddObjectType(typeMap, "Query"); + typeMap = possiblyAddObjectType(typeMap, "Mutation"); + } + return typeMap; +} + +const computeRelationTypeDirectiveDefaults = (typeMap) => { + let astNode = {}; + let fields = []; + let name = ""; + let to = {}; + let from = {}; + let fromTypeName = ""; + let toTypeName = ""; + let fromAstNode = {}; + let toAstNode = ""; + let typeDirective = {}; + let relationName = ""; + let toName = ""; + let fromName = ""; + let typeDirectiveIndex = -1; + Object.keys(typeMap).forEach(typeName => { + astNode = typeMap[typeName]; + name = astNode.name.value; + fields = astNode.fields; + to = fields ? fields.find(e => e.name.value === "to") : undefined; + from = fields ? fields.find(e => e.name.value === "from") : undefined; + if(to && !from) throw new Error(`Relationship type ${name} has a 'to' field but no corresponding 'from' field`); + if(from && !to) throw new Error(`Relationship type ${name} has a 'from' field but no corresponding 'to' field`); + if(from && to) { + // get values of .to and .from fields + fromTypeName = getNamedType(from).name.value; + toTypeName = getNamedType(to).name.value; + // get the astNodes of those object values + fromAstNode = typeMap[fromTypeName]; + toAstNode = typeMap[toTypeName]; + // assume the default relationship name + relationName = transformRelationName(astNode); + // get its relation type directive + typeDirectiveIndex = astNode.directives.findIndex(e => e.name.value === "relation"); + if(typeDirectiveIndex >= 0) { + typeDirective = astNode.directives[typeDirectiveIndex]; + // get the arguments of type directive + let args = typeDirective ? typeDirective.arguments : []; + if(args.length > 0) { + // get its name argument + let nameArg = args.find(e => e.name.value === "name"); + if(nameArg) { + relationName = nameArg.value.value; + } + } + // replace it if it exists in order to force correct configuration + // TODO use sdl instead + astNode.directives[typeDirectiveIndex] = { + kind: 'Directive', + name: { kind: 'Name', + value: 'relation', + }, + arguments: [ + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'name' + }, + value: { + kind: 'StringValue', + value: relationName + } + }, + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'from' + }, + value: { + kind: 'StringValue', + value: fromTypeName + } + }, + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'to' + }, + value: { + kind: 'StringValue', + value: toTypeName + } + } + ] + }; + } + else { + astNode.directives.push({ + kind: 'Directive', + name: { kind: 'Name', + value: 'relation', + }, + arguments: [ + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'name' + }, + value: { + kind: 'StringValue', + value: relationName + } + }, + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'from' + }, + value: { + kind: 'StringValue', + value: fromTypeName + } + }, + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'to' + }, + value: { + kind: 'StringValue', + value: toTypeName + } + } + ] + }); + } + typeMap[typeName] = astNode; + } + }); + return typeMap; +} + +const transformRelationName = (relatedAstNode) => { + const name = relatedAstNode.name.value; + let char = ''; + let uppercased = ''; + return Object.keys(name).reduce( (acc, t) => { + char = name.charAt(t); + uppercased = char.toUpperCase(); + if(char === uppercased && t > 0) { + // already uppercased + acc.push(`_${uppercased}`); + } + else { + acc.push(uppercased) + } + return acc; + }, []).join(''); +} diff --git a/src/augmentSchema.js b/src/augmentSchema.js index d70d4f21..6403fe9a 100644 --- a/src/augmentSchema.js +++ b/src/augmentSchema.js @@ -1,10 +1,17 @@ import { makeExecutableSchema } from 'graphql-tools'; -import { neo4jgraphql } from './index'; -import { print, parse } from 'graphql'; +import { parse } from 'graphql'; +import { + printTypeMap +} from './utils'; +import { + augmentTypeMap, + augmentResolvers +} from "./augment"; export const augmentedSchema = (typeMap, queryResolvers, mutationResolvers) => { const augmentedTypeMap = augmentTypeMap(typeMap); const augmentedResolvers = augmentResolvers(queryResolvers, mutationResolvers, augmentedTypeMap); + // TODO extract and persist logger and schemaDirectives, at least return makeExecutableSchema({ typeDefs: printTypeMap(augmentedTypeMap), resolvers: augmentedResolvers, @@ -78,748 +85,3 @@ export const extractResolvers = (operationType) => { }, {}) : {}; } - -const augmentTypeMap = (typeMap) => { - const types = Object.keys(typeMap); - typeMap = initializeOperationTypes(types, typeMap); - const queryMap = createOperationMap(typeMap.Query); - const mutationMap = createOperationMap(typeMap.Mutation); - let astNode = {}; - types.forEach(t => { - astNode = typeMap[t]; - if(isTypeForAugmentation(astNode)) { - astNode = augmentType(astNode, typeMap); - typeMap = possiblyAddQuery(astNode, typeMap, queryMap); - typeMap = possiblyAddOrderingEnum(astNode, typeMap); - typeMap = possiblyAddTypeMutations(astNode, typeMap, mutationMap); - typeMap = possiblyAddRelationMutations(astNode, typeMap, mutationMap); - typeMap[t] = astNode; - } - }); - typeMap = augmentQueryArguments(typeMap); - return typeMap; -} - -const possiblyAddTypeMutations = (astNode, typeMap, mutationMap) => { - typeMap = possiblyAddTypeMutation(`Create`, astNode, typeMap, mutationMap); - typeMap = possiblyAddTypeMutation(`Update`, astNode, typeMap, mutationMap); - typeMap = possiblyAddTypeMutation(`Delete`, astNode, typeMap, mutationMap); - return typeMap; -} - -const augmentQueryArguments = (typeMap) => { - const queryMap = createOperationMap(typeMap.Query); - let args = []; - let valueTypeName = ""; - let valueType = {}; - let field = {}; - let queryNames = Object.keys(queryMap); - if(queryNames.length > 0) { - queryNames.forEach(t => { - field = queryMap[t]; - valueTypeName = getNamedType(field).name.value; - valueType = typeMap[valueTypeName]; - if(isTypeForAugmentation(valueType) && isListType(field)) { - args = field.arguments; - queryMap[t].arguments = possiblyAddArgument(args, "first", "Int"); - queryMap[t].arguments = possiblyAddArgument(args, "offset", "Int"); - queryMap[t].arguments = possiblyAddArgument(args, "orderBy", `_${valueTypeName}Ordering`); - } - }); - typeMap.Query.fields = Object.values(queryMap); - } - return typeMap; -} - -const createOperationMap = (type) => { - const fields = type ? type.fields : []; - return fields.reduce( (acc, t) => { - acc[t.name.value] = t; - return acc; - }, {}); -} - -const printTypeMap = (typeMap) => { - return print({ - "kind": "Document", - "definitions": Object.values(typeMap) - }); -} - -const augmentResolvers = (queryResolvers, mutationResolvers, typeMap) => { - let resolvers = {}; - const queryMap = createOperationMap(typeMap.Query); - queryResolvers = possiblyAddResolvers(queryMap, queryResolvers) - if(Object.keys(queryResolvers).length > 0) { - resolvers.Query = queryResolvers; - } - const mutationMap = createOperationMap(typeMap.Mutation); - mutationResolvers = possiblyAddResolvers(mutationMap, mutationResolvers) - if(Object.keys(mutationResolvers).length > 0) { - resolvers.Mutation = mutationResolvers; - } - return resolvers; -} - -const possiblyAddResolvers = (operationTypeMap, resolvers) => { - let operationName = ""; - return Object.keys(operationTypeMap).reduce( (acc, t) => { - // if no resolver provided for this operation type field - operationName = operationTypeMap[t].name.value; - if(acc[operationName] === undefined) { - acc[operationName] = neo4jgraphql; - } - return acc; - }, resolvers); -} - -const possiblyAddQuery = (astNode, typeMap, queryMap) => { - const name = astNode.name.value; - if(queryMap[name] === undefined) { - typeMap.Query.fields.push({ - "kind": "FieldDefinition", - "name": { - "kind": "Name", - "value": name - }, - "arguments": createQueryArguments(astNode, typeMap), - "type": { - "kind": "ListType", - "type": { - "kind": "NamedType", - "name": { - "kind": "Name", - "value": name - } - } - }, - "directives": [], - }); - } - return typeMap; -} - -const possiblyAddOrderingEnum = (astNode, typeMap) => { - const name = `_${astNode.name.value}Ordering`; - const values = createOrderingFields(astNode.fields, typeMap); - // Add ordering enum if it does not exist already and if - // there is at least one basic scalar field on this type - if(typeMap[name] === undefined && values.length > 0) { - typeMap[name] = { - kind: "EnumTypeDefinition", - name: { - kind: "Name", - value: name - }, - directives: [], - values: values - }; - } - return typeMap; -} - -const initializeOperationTypes = (types, typeMap) => { - if(types.length > 0) { - typeMap = possiblyAddObjectType(typeMap, "Query"); - typeMap = possiblyAddObjectType(typeMap, "Mutation"); - } - return typeMap; -} - -const augmentType = (astNode, typeMap) => { - astNode.fields = addOrReplaceNodeIdField(astNode, "ID"); - astNode.fields = possiblyAddTypeFieldArguments(astNode, typeMap); - return astNode; -} - - -const isListType = (type, isList=false) => { - // Only checks that there is at least one ListType on the way - // to the NamedType - if(!isKind(type, "NamedType")) { - if(isKind(type, "ListType")) { - isList = true; - } - return isListType(type.type, isList); - } - return isList; -} - -const possiblyAddTypeFieldArguments = (astNode, typeMap) => { - const fields = astNode.fields; - let relationTypeName = ""; - let relationType = {}; - let args = []; - fields.forEach(field => { - relationTypeName = getNamedType(field).name.value; - relationType = typeMap[relationTypeName]; - if(isTypeForAugmentation(relationType) - && isListType(field) - && (getDirective(field, "relation") || getDirective(field, "cypher"))) { - args = field.arguments; - field.arguments = possiblyAddArgument(args, "first", "Int"); - field.arguments = possiblyAddArgument(args, "offset", "Int"); - field.arguments = possiblyAddArgument(args, "orderBy", `_${relationTypeName}Ordering`); - } - }); - return fields; -} - -const possiblyAddArgument = (args, fieldName, fieldType) => { - const fieldIndex = args.findIndex(e => e.name.value === fieldName); - if(fieldIndex === -1) { - args.push({ - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": fieldName, - }, - "type": { - "kind": "NamedType", - "name": { - "kind": "Name", - "value": fieldType, - }, - }, - "directives": [], - }); - } - return args; -}; - -const possiblyAddTypeMutation = (namePrefix, astNode, typeMap, mutationMap) => { - const typeName = astNode.name.value; - const mutationName = namePrefix + typeName; - // Only generate if the mutation named mutationName does not already exist - if(mutationMap[mutationName] === undefined) { - let args = buildAllFieldArguments(namePrefix, astNode, typeMap); - if(args.length > 0) { - typeMap.Mutation.fields.push({ - "kind": "FieldDefinition", - "name": { - "kind": "Name", - "value": mutationName - }, - "arguments": args, - "type": { - "kind": "NamedType", - "name": { - "kind": "Name", - "value": typeName - } - }, - "directives": [], - }); - } - } - return typeMap; -} - -const isNonNullType = (type, isRequired=false, parent={}) => { - if(!isKind(type, "NamedType")) { - return isNonNullType(type.type, isRequired, type); - } - if(isKind(parent, "NonNullType")) { - isRequired = true; - } - return isRequired; -} - -const isKind = (type, kind) => { - return type && type.kind === kind; -} - -const buildAllFieldArguments = (namePrefix, astNode, typeMap) => { - let fields = []; - let type = {}; - let fieldName = ""; - let valueTypeName = ""; - let valueType = {}; - switch(namePrefix) { - case 'Create': { - let firstIdField = undefined; - astNode.fields.reduce( (acc, t) => { - type = getNamedType(t); - fieldName = t.name.value; - valueTypeName = type.name.value; - valueType = typeMap[valueTypeName]; - // If this field is not _id, and not a list, - // and is not computed, and either a basic scalar - // or an enum - if(fieldName !== "_id" - && !isListType(t) - && !getDirective(t, "cypher") - && (isBasicScalar(valueTypeName) - || isKind(valueType, "EnumTypeDefinition"))) { - // Require if required - if(isNonNullType(t)) { - // Regardless of whether it is NonNullType, - // don't require the first ID field discovered - if(valueTypeName === "ID" && !firstIdField) { - // will only be true once, this field will - // by default recieve an auto-generated uuid, - // if no value is provided - firstIdField = t; - acc.push({ - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": fieldName - }, - "type": type, - "directives": [], - }); - } - else { - acc.push({ - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": fieldName - }, - "type": { - "kind": "NonNullType", - type: type - }, - "directives": [], - }); - } - } - else { - acc.push({ - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": fieldName - }, - "type": type, - "directives": [], - }); - } - } - return acc; - }, fields); - break; - } - case 'Update': { - const primaryKey = getPrimaryKey(astNode); - let augmentedFields = []; - if(primaryKey) { - // Primary key field is first field and required - const primaryKeyName = primaryKey.name.value; - const primaryKeyType = getNamedType(primaryKey); - augmentedFields.push({ - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": primaryKeyName - }, - "type": { - "kind": "NonNullType", - "type": primaryKeyType - }, - "directives": [], - }); - astNode.fields.reduce( (acc, t) => { - type = getNamedType(t); - fieldName = t.name.value; - valueTypeName = type.name.value; - valueType = typeMap[valueTypeName]; - // If this field is not the primary key, and not _id, - // and not a list, and not computed, and either a basic - // scalar or an enum - if(fieldName !== primaryKeyName - && fieldName !== "_id" - && !isListType(t) - && !getDirective(t, "cypher") - && (isBasicScalar(valueTypeName) - || isKind(valueType, "EnumTypeDefinition"))) { - acc.push({ - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": fieldName - }, - "type": type, - "directives": [], - }); - } - return acc; - }, augmentedFields); - // Use if there is at least one field other than - // the primaryKey field used for node selection - if(augmentedFields.length > 1) { - fields = augmentedFields; - } - } - break; - } - case 'Delete': { - const primaryKey = getPrimaryKey(astNode); - const primaryKeyName = primaryKey.name.value; - const primaryKeyType = getNamedType(primaryKey); - fields.push({ - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": primaryKeyName - }, - "type": { - "kind": "NonNullType", - "type": { - "kind": "NamedType", - "name": { - "kind": "Name", - "value": primaryKeyType.name.value - } - } - }, - "directives": [] - }); - break; - } - } - return fields; -} - -const firstNonNullAndIdField = (fields) => { - let valueTypeName = ""; - return fields.find(e => { - valueTypeName = getNamedType(e).name.value; - return e.name.value !== '_id' - && e.type.kind === 'NonNullType' - && valueTypeName === 'ID'; - }); -} - -const firstIdField = (fields) => { - let valueTypeName = ""; - return fields.find(e => { - valueTypeName = getNamedType(e).name.value; - return e.name.value !== '_id' - && valueTypeName === 'ID'; - }); -} - -const firstNonNullField = (fields) => { - let valueTypeName = ""; - return fields.find(e => { - valueTypeName = getNamedType(e).name.value; - return valueTypeName === 'NonNullType'; - }); -} - -const firstField = (fields) => { - return fields.find(e => { - return e.name.value !== '_id'; - }); -} - -const getPrimaryKey = (astNode) => { - const fields = astNode.fields; - let pk = firstNonNullAndIdField(fields); - if(!pk) { - pk = firstIdField(fields); - } - if(!pk) { - pk = firstNonNullField(fields); - } - if(!pk) { - pk = firstField(fields); - } - return pk; -} - -const capitalizeName = (name) => { - return name.charAt(0).toUpperCase() + name.substr(1); -} -const possiblyAddRelationMutations = (astNode, typeMap, mutationMap) => { - const typeName = astNode.name.value; - let relationTypeName = ""; - let relationDirective = {}; - let relationName = ""; - let direction = ""; - let capitalizedFieldName = ""; - astNode.fields.forEach(e => { - relationDirective = getDirective(e, "relation"); - if(relationDirective) { - relationName = getRelationName(relationDirective); - direction = getRelationDirection(relationDirective); - relationTypeName = getNamedType(e).name.value; - capitalizedFieldName = capitalizeName(e.name.value); - possiblyAddRelationMutationField( - `Add${typeName}${capitalizedFieldName}`, - astNode, - typeName, - relationTypeName, - direction, - relationName, - typeMap, - mutationMap - ); - possiblyAddRelationMutationField( - `Remove${typeName}${capitalizedFieldName}`, - astNode, - typeName, - relationTypeName, - direction, - relationName, - typeMap, - mutationMap - ); - } - }); - return typeMap; -} - -const getDirective = (field, directive) => { - return field && field.directives.find(e => e.name.value === directive); -}; - -const buildRelationMutationArguments = (astNode, relationTypeName, typeMap) => { - const relationAstNode = typeMap[relationTypeName]; - if(relationAstNode) { - const primaryKey = getPrimaryKey(astNode); - const relationPrimaryKey = getPrimaryKey(relationAstNode); - const relationType = getNamedType(relationPrimaryKey); - return [ - { - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": astNode.name.value.toLowerCase() + primaryKey.name.value - }, - "type": { - "kind": "NonNullType", - "type": getNamedType(primaryKey) - }, - "directives": [], - }, - { - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": relationAstNode.name.value.toLowerCase() + relationPrimaryKey.name.value - }, - "type": { - "kind": "NonNullType", - "type": relationType - }, - "directives": [], - } - ]; - } -} - -const possiblyAddRelationMutationField = ( - mutationName, - astNode, - typeName, - relationTypeName, - direction, - name, - typeMap, - mutationMap) => { - // Only generate if the mutation named mutationName does not already exist, - // and only generate for one direction, OUT, in order to prevent duplication - if(mutationMap[mutationName] === undefined - && (direction === "OUT" || direction === "out")) { - typeMap.Mutation.fields.push({ - "kind": "FieldDefinition", - "name": { - "kind": "Name", - "value": mutationName - }, - "arguments": buildRelationMutationArguments(astNode, relationTypeName, typeMap), - "type": { - "kind": "NamedType", - "name": { - "kind": "Name", - "value": typeName - } - }, - "directives": [ - { - "kind": "Directive", - "name": { - "kind": "Name", - "value": "MutationMeta" - }, - "arguments": [ - { - "kind": "Argument", - "name": { - "kind": "Name", - "value": "relationship" - }, - "value": { - "kind": "StringValue", - "value": name - } - }, - { - "kind": "Argument", - "name": { - "kind": "Name", - "value": "from" - }, - "value": { - "kind": "StringValue", - "value": typeName - } - }, - { - "kind": "Argument", - "name": { - "kind": "Name", - "value": "to" - }, - "value": { - "kind": "StringValue", - "value": relationTypeName - } - }, - ] - } - ], - }); - } - return typeMap; -} - -const addOrReplaceNodeIdField = (astNode, valueType) => { - const fields = astNode ? astNode.fields : []; - const index = fields.findIndex(e => e.name.value === '_id'); - const definition = { - "kind": "FieldDefinition", - "name": { - "kind": "Name", - "value": "_id" - }, - "arguments": [], - "type": { - "kind": "NamedType", - "name": { - "kind": "Name", - "value": valueType, - } - }, - "directives": [], - }; - // If it has already been provided, replace it to force valueType, - // else add it as the last field - index >= 0 - ? fields.splice(index, 1, definition) - : fields.push(definition) - return fields; -} - -const getRelationName = (relationDirective) => { - let name = {}; - try { - name = relationDirective.arguments.filter(a => a.name.value === 'name')[0]; - } catch (e) { - // FIXME: should we ignore this error to define default behavior? - throw new Error('No name argument specified on @relation directive'); - } - return name.value.value; -} - -const getRelationDirection = (relationDirective) => { - let direction = {}; - try { - direction = relationDirective.arguments.filter(a => a.name.value === 'direction')[0]; - } catch (e) { - // FIXME: should we ignore this error to define default behavior? - throw new Error('No direction argument specified on @relation directive'); - } - return direction.value.value; -} - -const possiblyAddObjectType = (typeMap, name) => { - if(typeMap[name] === undefined) { - typeMap[name] = { - kind: 'ObjectTypeDefinition', - name: { - kind: 'Name', - value: name - }, - interfaces: [], - directives: [], - fields: [] - }; - } - return typeMap; -} - -const isBasicScalar = (name) => { - return name === "ID" || name === "String" - || name === "Float" || name === "Int" || name === "Boolean"; -} - -const isQueryArgumentFieldType = (type, valueType) => { - return isBasicScalar(type.name.value) - || isKind(valueType, "EnumTypeDefinition"); -} - -const createQueryArguments = (astNode, typeMap) => { - let type = {}; - let valueTypeName = ""; - astNode.fields = addOrReplaceNodeIdField(astNode, "Int"); - return astNode.fields.reduce( (acc, t) => { - type = getNamedType(t); - valueTypeName = type.name.value; - if(isQueryArgumentFieldType(type, typeMap[valueTypeName])) { - acc.push({ - "kind": "InputValueDefinition", - "name": { - "kind": "Name", - "value": t.name.value - }, - "type": type, - "directives": [], - }); - } - return acc; - }, []); -} - -const isTypeForAugmentation = (astNode) => { - // TODO: check for @ignore and @model directives - return astNode && astNode.kind === "ObjectTypeDefinition" - && astNode.name.value !== "Query" - && astNode.name.value !== "Mutation"; -} - -const getNamedType = (type) => { - if(type.kind !== "NamedType") { - return getNamedType(type.type); - } - return type; -} - -const createOrderingFields = (fields, typeMap) => { - let type = {}; - return fields.reduce( (acc, t) => { - type = getNamedType(t); - if(isBasicScalar(type.name.value)) { - acc.push({ - kind: 'EnumValueDefinition', - name: { - kind: "Name", - value: `${t.name.value}_asc` - }, - directives: [] - }); - acc.push({ - kind: 'EnumValueDefinition', - name: { - kind: "Name", - value: `${t.name.value}_desc` - }, - directives: [] - }); - } - return acc; - }, []); -} diff --git a/src/index.js b/src/index.js index ab882c34..b106b08b 100644 --- a/src/index.js +++ b/src/index.js @@ -12,7 +12,8 @@ import { isDeleteMutation, isMutation, lowFirstLetter, - typeIdentifiers + typeIdentifiers, + parameterizeRelationFields } from './utils'; import { buildCypherSelection } from './selections'; import { @@ -21,6 +22,7 @@ import { augmentedSchema, makeAugmentedExecutableSchema } from './augmentSchema'; +import { getNamedType } from 'graphql'; import { checkRequestError } from './auth'; export async function neo4jgraphql( @@ -51,6 +53,7 @@ export async function neo4jgraphql( try { result = await session.run(query, cypherParams); + console.log("result: "+JSON.stringify(result, null, 2)); } finally { session.close(); } @@ -140,11 +143,12 @@ export function cypherQuery( .filter(predicate => !!predicate) .join(' AND '); const predicate = predicateClauses ? `WHERE ${predicateClauses} ` : ''; - + query = `MATCH (${variableName}:${typeName} ${argString}) ${predicate}` + // ${variableName} { ${selection} } as ${variableName}`; `RETURN ${variableName} {${subQuery}} AS ${variableName}${orderByValue} ${outerSkipLimit}`; + } return [query, { ...nonNullParams, ...subParams }]; @@ -270,46 +274,57 @@ export function cypherMutation( 'Missing required argument in MutationMeta directive (relationship, from, or to)' ); } + //TODO: need to handle one-to-one and one-to-many - const fromType = fromTypeArg.value.value, - toType = toTypeArg.value.value, - fromVar = lowFirstLetter(fromType), - toVar = lowFirstLetter(toType), - relationshipName = relationshipNameArg.value.value, - fromParam = resolveInfo.schema + const args = resolveInfo.schema .getMutationType() .getFields() - [resolveInfo.fieldName].astNode.arguments[0].name.value.substr( - fromVar.length - ), - toParam = resolveInfo.schema - .getMutationType() - .getFields() - [resolveInfo.fieldName].astNode.arguments[1].name.value.substr( - toVar.length - ); + [resolveInfo.fieldName].astNode.arguments; + + const typeMap = resolveInfo.schema.getTypeMap(); + + // TODO write some getters to reduce similar code between this and isRemoveMutation + const fromType = fromTypeArg.value.value; + const fromVar = `${lowFirstLetter(fromType)}_from`; + const fromInputArg = args.find(e => e.name.value === "from").type; + const fromInputAst = typeMap[getNamedType(fromInputArg).type.name.value].astNode; + const fromParam = fromInputAst.fields[0].name.value; + + const toType = toTypeArg.value.value; + const toVar = `${lowFirstLetter(toType)}_to`; + const toInputArg = args.find(e => e.name.value === "to").type; + const toInputAst = typeMap[getNamedType(toInputArg).type.name.value].astNode; + const toParam = toInputAst.fields[0].name.value; + + const relationshipName = relationshipNameArg.value.value; + const lowercased = relationshipName.toLowerCase(); + const dataInputArg = args.find(e => e.name.value === "data"); + const dataInputAst = dataInputArg ? typeMap[getNamedType(dataInputArg.type).type.name.value].astNode : undefined; + const relationPropertyArguments = dataInputAst ? parameterizeRelationFields(dataInputAst.fields) : undefined; const [subQuery, subParams] = buildCypherSelection({ initial: '', selections, - variableName, + variableName: lowercased, + fromVar, + toVar, schemaType, resolveInfo, paramIndex: 1 }); params = { ...params, ...subParams }; + query = ` + MATCH (${fromVar}:${fromType} {${fromParam}: $from.${fromParam}}) + MATCH (${toVar}:${toType} {${toParam}: $to.${toParam}}) + CREATE (${fromVar})-[${lowercased}_relation:${relationshipName}${ + relationPropertyArguments + ? ` {${relationPropertyArguments}}` + : '' + }]->(${toVar}) + RETURN ${lowercased}_relation { ${subQuery} } AS ${schemaType}; + `; - query = `MATCH (${fromVar}:${fromType} {${fromParam}: $${ - resolveInfo.schema.getMutationType().getFields()[resolveInfo.fieldName] - .astNode.arguments[0].name.value - }}) - MATCH (${toVar}:${toType} {${toParam}: $${ - resolveInfo.schema.getMutationType().getFields()[resolveInfo.fieldName] - .astNode.arguments[1].name.value - }}) - CREATE (${fromVar})-[:${relationshipName}]->(${toVar}) - RETURN ${fromVar} {${subQuery}} AS ${fromVar};`; } else if (isUpdateMutation(resolveInfo)) { const idParam = resolveInfo.schema.getMutationType().getFields()[ resolveInfo.fieldName @@ -390,25 +405,28 @@ RETURN ${variableName}`; 'Missing required argument in MutationMeta directive (relationship, from, or to)' ); } - //TODO: need to handle one-to-one and one-to-many - const fromType = fromTypeArg.value.value, - toType = toTypeArg.value.value, - fromVar = lowFirstLetter(fromType), - toVar = lowFirstLetter(toType), - relationshipName = relationshipNameArg.value.value, - fromParam = resolveInfo.schema - .getMutationType() - .getFields() - [resolveInfo.fieldName].astNode.arguments[0].name.value.substr( - fromVar.length - ), - toParam = resolveInfo.schema + //TODO: need to handle one-to-one and one-to-many + const args = resolveInfo.schema .getMutationType() .getFields() - [resolveInfo.fieldName].astNode.arguments[1].name.value.substr( - toVar.length - ); + [resolveInfo.fieldName].astNode.arguments; + + const typeMap = resolveInfo.schema.getTypeMap(); + + const fromType = fromTypeArg.value.value; + const fromVar = `${lowFirstLetter(fromType)}_from`; + const fromInputArg = args.find(e => e.name.value === "from").type; + const fromInputAst = typeMap[getNamedType(fromInputArg).type.name.value].astNode; + const fromParam = fromInputAst.fields[0].name.value; + + const toType = toTypeArg.value.value; + const toVar = `${lowFirstLetter(toType)}_to`; + const toInputArg = args.find(e => e.name.value === "to").type; + const toInputAst = typeMap[getNamedType(toInputArg).type.name.value].astNode; + const toParam = toInputAst.fields[0].name.value; + + const relationshipName = relationshipNameArg.value.value; const [subQuery, subParams] = buildCypherSelection({ initial: '', @@ -416,21 +434,29 @@ RETURN ${variableName}`; variableName, schemaType, resolveInfo, - paramIndex: 1 + paramIndex: 1, + rootNodes: { + from: `_${fromVar}`, + to: `_${toVar}` + }, + variableName: schemaType.name === fromType ? `_${toVar}` : `_${fromVar}` }); params = { ...params, ...subParams }; - query = `MATCH (${fromVar}:${fromType} {${fromParam}: $${ - resolveInfo.schema.getMutationType().getFields()[resolveInfo.fieldName] - .astNode.arguments[0].name.value - }}) -MATCH (${toVar}:${toType} {${toParam}: $${ - resolveInfo.schema.getMutationType().getFields()[resolveInfo.fieldName] - .astNode.arguments[1].name.value - }}) -OPTIONAL MATCH (${fromVar})-[${fromVar + toVar}:${relationshipName}]->(${toVar}) -DELETE ${fromVar + toVar} -RETURN ${fromVar} {${subQuery}} AS ${fromVar};`; + // WITH COUNT(*) AS scope is used so that relations deletions are finished + // before we possibly query them in the return. If we wanted to actually allow + // the return to query over the deleted relations, we could move the return + // object construction into a WITH statement above the DELETE, then return it + // the delete + query = ` + MATCH (${fromVar}:${fromType} {${fromParam}: $from.${fromParam}}) + MATCH (${toVar}:${toType} {${toParam}: $to.${toParam}}) + OPTIONAL MATCH (${fromVar})-[${fromVar + toVar}:${relationshipName}]->(${toVar}) + DELETE ${fromVar + toVar} + WITH COUNT(*) AS scope, ${fromVar} AS _${fromVar}_from, ${toVar} AS _${toVar}_to + RETURN {${subQuery}} AS ${schemaType}; + `; + } else { // throw error - don't know how to handle this type of mutation throw new Error( diff --git a/src/selections.js b/src/selections.js index 328b4ba2..f054b83a 100644 --- a/src/selections.js +++ b/src/selections.js @@ -9,7 +9,8 @@ import { isArrayType, isGraphqlScalarType, extractSelections, - relationDirective + relationDirective, + getRelationTypeDirectiveArgs } from './utils'; export function buildCypherSelection({ @@ -18,7 +19,8 @@ export function buildCypherSelection({ variableName, schemaType, resolveInfo, - paramIndex = 1 + paramIndex = 1, + rootNodes }) { if (!selections.length) { return [initial, {}]; @@ -38,7 +40,7 @@ export function buildCypherSelection({ const [headSelection, ...tailSelections] = selections; - const tailParams = { + let tailParams = { selections: tailSelections, variableName, schemaType, @@ -72,6 +74,9 @@ export function buildCypherSelection({ const innerSchemaType = innerType(fieldType); // for target "type" aka label const { statement: customCypher } = cypherDirective(schemaType, fieldName); + const typeMap = resolveInfo.schema.getTypeMap(); + const schemaTypeAstNode = typeMap[schemaType].astNode; + // Database meta fields(_id) if (fieldName === '_id') { return recurse({ @@ -83,6 +88,9 @@ export function buildCypherSelection({ // Main control flow if (isGraphqlScalarType(innerSchemaType)) { if (customCypher) { + if(getRelationTypeDirectiveArgs(schemaTypeAstNode)) { + variableName = `${variableName}_relation`; + } return recurse({ initial: `${initial}${fieldName}: apoc.cypher.runFirstColumn("${customCypher}", ${cypherDirectiveArgs( variableName, @@ -102,7 +110,6 @@ export function buildCypherSelection({ } // We have a graphql object type - const nestedVariable = variableName + '_' + fieldName; const skipLimit = computeSkipLimit(headSelection, resolveInfo.variableValues); @@ -121,10 +128,13 @@ export function buildCypherSelection({ let selection; - if (customCypher) { + // Object type field with cypher directive + if(customCypher) { + if(getRelationTypeDirectiveArgs(schemaTypeAstNode)) { + variableName = `${variableName}_relation`; + } // similar: [ x IN apoc.cypher.runFirstColumn("WITH {this} AS this MATCH (this)--(:Genre)--(o:Movie) RETURN o", {this: movie}, true) |x {.title}][1..2]) const fieldIsList = !!fieldType.ofType; - selection = recurse({ initial: `${initial}${fieldName}: ${ fieldIsList ? '' : 'head(' @@ -138,31 +148,108 @@ export function buildCypherSelection({ }${skipLimit} ${commaIfTail}`, ...tailParams }); - } else { + } + else { // graphql object type, no custom cypher - - const { name: relType, direction: relDirection } = relationDirective( - schemaType, - fieldName - ); - const queryParams = innerFilterParams(filterParams); - - selection = recurse({ - initial: `${initial}${fieldName}: ${ - !isArrayType(fieldType) ? 'head(' : '' - }[(${variableName})${ - relDirection === 'in' || relDirection === 'IN' ? '<' : '' - }-[:${relType}]-${ - relDirection === 'out' || relDirection === 'OUT' ? '>' : '' - }(${nestedVariable}:${ - innerSchemaType.name - }${queryParams}) | ${nestedVariable} {${subSelection[0]}}]${ - !isArrayType(fieldType) ? ')' : '' - }${skipLimit} ${commaIfTail}`, - ...tailParams - }); + const relationDirectiveData = getRelationTypeDirectiveArgs(schemaTypeAstNode); + if(relationDirectiveData) { + const fromTypeName = relationDirectiveData.from; + const toTypeName = relationDirectiveData.to; + const isFromField = fieldName === fromTypeName || fieldName === 'from'; + const isToField = fieldName === toTypeName || fieldName === 'to'; + if(isFromField || isToField) { + if(rootNodes && (fieldName === 'from' || fieldName === 'to')) { + // Branch currenlty needed to be explicit about handling the .to and .from + // keys involved with the relation removal mutation, using rootNodes + selection = recurse({ + initial: `${initial}${fieldName}: ${ + !isArrayType(fieldType) ? 'head(' : '' + }[${isFromField ? `${rootNodes.from}_from` : `${rootNodes.to}_to`} {${subSelection[0]}}]${ + !isArrayType(fieldType) ? ')' : '' + }${skipLimit} ${commaIfTail}`, + ...tailParams, + rootNodes, + variableName: isFromField ? rootNodes.to : rootNodes.from + }); + } + else { + selection = recurse({ + initial: `${initial}${fieldName}: ${ + !isArrayType(fieldType) ? 'head(' : '' + }[(:${fieldName === fromTypeName || fieldName === 'from' ? toTypeName : fromTypeName})${ + fieldName === fromTypeName || fieldName === 'from' ? '<' : '' + }-[${variableName}_relation]-${ + fieldName === toTypeName || fieldName === 'to' ? '>' : '' + }(${nestedVariable}:${ + innerSchemaType.name + }${queryParams}) | ${nestedVariable} {${subSelection[0]}}]${ + !isArrayType(fieldType) ? ')' : '' + }${skipLimit} ${commaIfTail}`, + ...tailParams + }); + } + } + } + else { + let { name: relType, direction: relDirection } = relationDirective(schemaType, fieldName); + if(relType && relDirection) { + selection = recurse({ + initial: `${initial}${fieldName}: ${ + !isArrayType(fieldType) ? 'head(' : '' + }[(${variableName})${ + relDirection === 'in' || relDirection === 'IN' ? '<' : '' + }-[:${relType}]-${ + relDirection === 'out' || relDirection === 'OUT' ? '>' : '' + }(${nestedVariable}:${ + innerSchemaType.name + }${queryParams}) | ${nestedVariable} {${subSelection[0]}}]${ + !isArrayType(fieldType) ? ')' : '' + }${skipLimit} ${commaIfTail}`, + ...tailParams + }); + } + else { + const innerSchemaTypeAstNode = typeMap[innerSchemaType].astNode; + const relationDirectiveData = getRelationTypeDirectiveArgs(innerSchemaTypeAstNode); + if(relationDirectiveData) { + const relType = relationDirectiveData.name; + const fromTypeName = relationDirectiveData.from; + const toTypeName = relationDirectiveData.to; + const nestedRelationshipVariable = `${nestedVariable}_relation`; + const schemaTypeName = schemaType.name; + if(fromTypeName !== toTypeName) { + selection = recurse({ + initial: `${initial}${fieldName}: ${ + !isArrayType(fieldType) ? 'head(' : '' + }[(${variableName})${ + schemaTypeName === toTypeName ? '<' : '' + }-[${nestedRelationshipVariable}:${relType}${queryParams}]-${ + schemaTypeName === fromTypeName ? '>' : '' + }(:${ + schemaTypeName === fromTypeName ? toTypeName : fromTypeName + }) | ${nestedRelationshipVariable} {${subSelection[0]}}]${ + !isArrayType(fieldType) ? ')' : '' + }${skipLimit} ${commaIfTail}`, + ...tailParams + }); + } + else { + // Type symmetry limitation, Person FRIEND_OF Person, assume OUT for now + selection = recurse({ + initial: `${initial}${fieldName}: ${ + !isArrayType(fieldType) ? 'head(' : '' + }[(${variableName})-[${nestedRelationshipVariable}:${relType}${queryParams}]->(:${ + schemaTypeName === fromTypeName ? toTypeName : fromTypeName + }) | ${nestedRelationshipVariable} {${subSelection[0]}}]${ + !isArrayType(fieldType) ? ')' : '' + }${skipLimit} ${commaIfTail}`, + ...tailParams + }); + } + } + } + } } - return [selection[0], { ...selection[1], ...subSelection[1] }]; } diff --git a/src/utils.js b/src/utils.js index ae7354a4..596ce85f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,5 @@ -import { resolve } from 'url'; +import { print, parse } from 'graphql'; +import { possiblyAddArgument } from './augment'; function parseArg(arg, variableValues) { switch (arg.value.kind) { @@ -361,3 +362,211 @@ export function fixParamsForAddRelationshipMutation(params, resolveInfo) { return params; } + +export const isKind = (type, kind) => type && type.kind === kind; + +export const isListType = (type, isList=false) => { + if(!isKind(type, "NamedType")) { + if(isKind(type, "ListType")) isList = true; + return isListType(type.type, isList); + } + return isList; +} + +export const parameterizeRelationFields = (fields) => { + let name = ""; + return Object.keys(fields).reduce( (acc, t) => { + name = fields[t].name.value; + acc.push(`${name}:$data.${name}`); + return acc; + }, []).join(','); +} + +export const getRelationTypeDirectiveArgs = (relationshipType) => { + const directive = relationshipType.directives.find(e => e.name.value === "relation"); + return directive ? { + name: directive.arguments.find(e => e.name.value === "name").value.value, + from: directive.arguments.find(e => e.name.value === "from").value.value, + to: directive.arguments.find(e => e.name.value === "to").value.value + } : undefined; +} + +export const getFieldArgumentsFromAst = (field, typeName) => { + const args = field.arguments; + field.arguments = possiblyAddArgument(args, "first", "Int"); + field.arguments = possiblyAddArgument(args, "offset", "Int"); + field.arguments = possiblyAddArgument(args, "orderBy", `_${typeName}Ordering`); + return field.arguments.reduce( (acc, t) => { + acc.push(print(t)); + return acc; + }, []).join('\n'); +} + +export const getRelationMutationPayloadFieldsFromAst = (relatedAstNode) => { + let isList = false; + let fieldName = ""; + return relatedAstNode.fields.reduce( (acc, t) => { + fieldName = t.name.value; + if(fieldName !== "to" && fieldName !== "from") { + isList = isListType(t); + // Use name directly in order to prevent requiring required fields on the payload type + acc.push(`${fieldName}: ${ isList ? '[' : '' }${getNamedType(t).name.value}${ isList ? `]` : '' }${print(t.directives)}`); + } + return acc; + }, []).join('\n'); +} + +export const getNamedType = (type) => { + if(type.kind !== "NamedType") { + return getNamedType(type.type); + } + return type; +} + +export const isBasicScalar = (name) => { + return name === "ID" || name === "String" + || name === "Float" || name === "Int" || name === "Boolean"; +} + +const firstNonNullAndIdField = (fields) => { + let valueTypeName = ""; + return fields.find(e => { + valueTypeName = getNamedType(e).name.value; + return e.name.value !== '_id' + && e.type.kind === 'NonNullType' + && valueTypeName === 'ID'; + }); +} + +const firstIdField = (fields) => { + let valueTypeName = ""; + return fields.find(e => { + valueTypeName = getNamedType(e).name.value; + return e.name.value !== '_id' + && valueTypeName === 'ID'; + }); +} + +const firstNonNullField = (fields) => { + let valueTypeName = ""; + return fields.find(e => { + valueTypeName = getNamedType(e).name.value; + return valueTypeName === 'NonNullType'; + }); +} + +const firstField = (fields) => { + return fields.find(e => { + return e.name.value !== '_id'; + }); +} + +export const getPrimaryKey = (astNode) => { + const fields = astNode.fields; + let pk = firstNonNullAndIdField(fields); + if(!pk) { + pk = firstIdField(fields); + } + if(!pk) { + pk = firstNonNullField(fields); + } + if(!pk) { + pk = firstField(fields); + } + return pk; +} + +export const getTypeDirective = (relatedAstNode, name) => { + return relatedAstNode.directives.find(e => e.name.value === name); +} + +export const getFieldDirective = (field, directive) => { + return field && field.directives.find(e => e.name.value === directive); +}; + +export const isNonNullType = (type, isRequired=false, parent={}) => { + if(!isKind(type, "NamedType")) { + return isNonNullType(type.type, isRequired, type); + } + if(isKind(parent, "NonNullType")) { + isRequired = true; + } + return isRequired; +} + +export const createOperationMap = (type) => { + const fields = type ? type.fields : []; + return fields.reduce( (acc, t) => { + acc[t.name.value] = t; + return acc; + }, {}); +} + +export const isNodeType = (astNode) => { + // TODO: check for @ignore and @model directives + return astNode && astNode.kind === "ObjectTypeDefinition" + && astNode.name.value !== "Query" + && astNode.name.value !== "Mutation" + && getTypeDirective(astNode, "relation") === undefined; +} + +export const parseFieldSdl = (sdl) => { + return sdl + ? parse(`type fieldToParse { ${sdl} }`).definitions[0].fields[0] + : {}; +} + +export const getRelationDirection = (relationDirective) => { + let direction = {}; + try { + direction = relationDirective.arguments.filter(a => a.name.value === 'direction')[0]; + } catch (e) { + // FIXME: should we ignore this error to define default behavior? + throw new Error('No direction argument specified on @relation directive'); + } + return direction.value.value; +} + +export const getRelationName = (relationDirective) => { + let name = {}; + try { + name = relationDirective.arguments.filter(a => a.name.value === 'name')[0]; + } catch (e) { + // FIXME: should we ignore this error to define default behavior? + throw new Error('No name argument specified on @relation directive'); + } + return name.value.value; +} + +export const createRelationMap = (typeMap) => { + let astNode = {}; + let name = ""; + let fields = []; + let fromTypeName = ""; + let toTypeName = ""; + let typeDirective = {}; + return Object.keys(typeMap).reduce( (acc, t) => { + astNode = typeMap[t]; + name = astNode.name.value; + fields = astNode.fields; + typeDirective = getTypeDirective(astNode, "relation"); + if(typeDirective) { + // validate the other fields to make sure theyre not nodes or rel types + fromTypeName = typeDirective.arguments.find(e => e.name.value === "from").value.value; + toTypeName = typeDirective.arguments.find(e => e.name.value === "to").value.value; + acc[name] = { + from: typeMap[fromTypeName], + to: typeMap[toTypeName] + }; + } + return acc; + }, {}); +} + +export const printTypeMap = (typeMap) => { + return print({ + "kind": "Document", + "definitions": Object.values(typeMap) + }); +} +