From e2825f9d0e0808e21e01d1bcc3973021011d6912 Mon Sep 17 00:00:00 2001 From: Michael Graham <38390185+michaeldgraham@users.noreply.github.com> Date: Wed, 18 Nov 2020 11:38:26 -0800 Subject: [PATCH] Custom nested mutations using @cypher on input fields (#542) * blocks generated translation of input objects to cypher parameters * clones the gql definitions object to prevent errors with repeated augmentation * adds INPUT_FIELD_DEFINITION to @cypher locations * adds support for nested and sibling @cypher on INPUT_FIELD_DEFINITION * adds test paths for nested @cypher input * helpers for @cypher input tests * test schema for @cypher input * simplifies import path, removes misplaced config * test helpers for @cypher input (experimental) * test schema for @cypher input (experimental) * updates @cypher directive * updates @cypher directive * updates @cypher directive * @cypher input tests * @cypher input tests (experimental) --- package.json | 2 +- src/augment/augment.js | 3 +- src/augment/directives.js | 5 +- src/translate.js | 484 ++++- src/utils.js | 8 +- test/helpers/custom/customSchemaTest.js | 125 ++ test/helpers/custom/testSchema.js | 183 ++ .../helpers/experimental/augmentSchemaTest.js | 6 +- .../experimental/custom/customSchemaTest.js | 127 ++ .../helpers/experimental/custom/testSchema.js | 189 ++ test/helpers/testSchema.js | 2 +- test/unit/augmentSchemaTest.test.js | 4 +- test/unit/custom/cypherTest.test.js | 742 ++++++++ .../experimental/augmentSchemaTest.test.js | 4 +- .../experimental/custom/cypherTest.test.js | 1628 +++++++++++++++++ 15 files changed, 3459 insertions(+), 53 deletions(-) create mode 100644 test/helpers/custom/customSchemaTest.js create mode 100644 test/helpers/custom/testSchema.js create mode 100644 test/helpers/experimental/custom/customSchemaTest.js create mode 100644 test/helpers/experimental/custom/testSchema.js create mode 100644 test/unit/custom/cypherTest.test.js create mode 100644 test/unit/experimental/custom/cypherTest.test.js diff --git a/package.json b/package.json index 9c10ddd5..3fb0a0d6 100755 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build-with-sourcemaps": "babel src --presets @babel/preset-env --out-dir dist --source-maps", "precommit": "lint-staged", "prepare": "npm run build", - "test": "nyc --reporter=lcov ava test/unit/augmentSchemaTest.test.js test/unit/configTest.test.js test/unit/assertSchema.test.js test/unit/cypherTest.test.js test/unit/filterTest.test.js test/unit/filterTests.test.js test/unit/experimental/augmentSchemaTest.test.js test/unit/experimental/cypherTest.test.js", + "test": "nyc --reporter=lcov ava test/unit/augmentSchemaTest.test.js test/unit/configTest.test.js test/unit/assertSchema.test.js test/unit/cypherTest.test.js test/unit/filterTest.test.js test/unit/filterTests.test.js test/unit/custom/cypherTest.test.js test/unit/experimental/augmentSchemaTest.test.js test/unit/experimental/cypherTest.test.js test/unit/experimental/custom/cypherTest.test.js", "parse-tck": "babel-node test/helpers/tck/parseTck.js", "test-tck": "nyc ava --fail-fast test/unit/filterTests.test.js", "report-coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov", diff --git a/src/augment/augment.js b/src/augment/augment.js index 48576042..008d88ef 100644 --- a/src/augment/augment.js +++ b/src/augment/augment.js @@ -18,6 +18,7 @@ import { import { augmentDirectiveDefinitions } from './directives'; import { extractResolversFromSchema, augmentResolvers } from './resolvers'; import { addAuthDirectiveImplementations } from '../auth'; +import _ from 'lodash'; /** * The main export for augmenting an SDL document @@ -40,7 +41,7 @@ export const makeAugmentedExecutableSchema = ({ let definitions = []; if (isParsedTypeDefs) { // Print if we recieved parsed type definitions in a GraphQL Document - definitions = typeDefs.definitions; + definitions = _.cloneDeep(typeDefs.definitions); } else { // Otherwise parse the SDL and get its definitions definitions = parse(typeDefs).definitions; diff --git a/src/augment/directives.js b/src/augment/directives.js index 94d3e6ef..79aabe31 100644 --- a/src/augment/directives.js +++ b/src/augment/directives.js @@ -315,7 +315,10 @@ const directiveDefinitionBuilderMap = { } } ], - locations: [DirectiveLocation.FIELD_DEFINITION] + locations: [ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.INPUT_FIELD_DEFINITION + ] }; }, [DirectiveDefinition.RELATION]: ({ config }) => { diff --git a/src/translate.js b/src/translate.js index a926b94b..d704a368 100644 --- a/src/translate.js +++ b/src/translate.js @@ -51,6 +51,7 @@ import { isScalarType, isEnumType, isObjectType, + isInputObjectType, isInterfaceType } from 'graphql'; import { @@ -62,7 +63,10 @@ import { } from './selections'; import _ from 'lodash'; import neo4j from 'neo4j-driver'; -import { isUnionTypeDefinition } from './augment/types/types'; +import { + isUnionTypeDefinition, + isRelationshipType +} from './augment/types/types'; import { getFederatedOperationData, setCompoundKeyFilter, @@ -76,7 +80,11 @@ import { import { isPrimaryKeyField, isUniqueField, - isIndexedField + isIndexedField, + isRelationField, + getDirective, + DirectiveDefinition, + getDirectiveArgument } from './augment/directives'; import { analyzeMutationArguments, @@ -87,6 +95,7 @@ import { isRelationshipMutationOutputType, isReflexiveRelationshipOutputType } from './augment/types/relationship/query'; +import { query } from 'express'; const derivedTypesParamName = schemaTypeName => `${schemaTypeName}_derivedTypes`; @@ -1459,7 +1468,8 @@ export const translateMutation = ({ mutationTypeCypherDirective, variableName, orderByValue, - outerSkipLimit + outerSkipLimit, + typeMap }); } else if (isCreateMutation(resolveInfo)) { [translation, translationParams] = nodeCreate({ @@ -1480,7 +1490,8 @@ export const translateMutation = ({ selections, params, variableName, - typeName + typeName, + typeMap }); } else if (isAddMutation(resolveInfo)) { [translation, translationParams] = relationshipCreate({ @@ -1551,7 +1562,8 @@ const customMutation = ({ usesFragments, resolveInfo, orderByValue, - outerSkipLimit + outerSkipLimit, + typeMap }) => { const cypherParams = getCypherParams(context); const safeVariableName = safeVar(variableName); @@ -1565,9 +1577,16 @@ const customMutation = ({ ), cypherParams ); + const args = getMutationArguments(resolveInfo); const cypherQueryArg = mutationTypeCypherDirective.arguments.find(x => { return x.name.value === 'statement'; }); + const nestedStatements = translateNestedMutations({ + args, + dataParams: params, + typeMap, + isRoot: true + }); const [subQuery, subParams] = buildCypherSelection({ selections, variableName, @@ -1599,7 +1618,9 @@ const customMutation = ({ query = `CALL apoc.cypher.doIt("${ cypherQueryArg.value.value }", ${argString}) YIELD value - ${!isScalarField ? labelPredicate : ''}AS ${safeVariableName} + ${!isScalarField ? labelPredicate : ''}AS ${safeVariableName}${ + nestedStatements ? nestedStatements : '' + } RETURN ${ !isScalarField ? `${mapProjection} AS ${safeVariableName}${orderByClause}${outerSkipLimit}` @@ -1609,7 +1630,9 @@ const customMutation = ({ query = `CALL apoc.cypher.doIt("${ cypherQueryArg.value.value }", ${argString}) YIELD value - WITH ${listVariable}AS ${safeVariableName} + WITH ${listVariable}AS ${safeVariableName}${ + nestedStatements ? nestedStatements : '' + } RETURN ${safeVariableName} ${ !isScalarField ? `{${ @@ -1634,8 +1657,6 @@ const customMutation = ({ return [query, { ...params }]; }; -// Generated API -// Node Create - Update - Delete const nodeCreate = ({ resolveInfo, schemaType, @@ -1647,17 +1668,12 @@ const nodeCreate = ({ additionalLabels, typeMap }) => { - const safeVariableName = safeVar(variableName); - const safeLabelName = safeLabel([typeName, ...additionalLabels]); - let statements = []; let args = getMutationArguments(resolveInfo); - const fieldMap = schemaType.getFields(); - const fields = Object.values(fieldMap).map(field => field.astNode); - const primaryKey = getPrimaryKey({ fields }); - let createStatement = ``; const dataArgument = args.find(arg => arg.name.value === 'data'); let paramKey = 'params'; let dataParams = params[paramKey]; + let nestedStatements = ''; + // handle differences with experimental input object argument format if (dataArgument) { // config.experimental const unwrappedType = unwrapNamedType({ type: dataArgument.type }); @@ -1665,35 +1681,61 @@ const nodeCreate = ({ const inputType = typeMap[name]; const inputValues = inputType.getFields(); // get the input value AST definitions of the .data input object - args = Object.values(inputValues).map(arg => arg.astNode); // use the .data key instead of the existing .params format paramKey = 'data'; dataParams = dataParams[paramKey]; - // elevate .data to top level - params.data = dataParams; + // elevate .data to top level so it matches "data" argument + params = { + ...params, + ...params.params, + data: dataParams + }; // remove .params entry delete params.params; + // translate nested mutations discovered in input object arguments + nestedStatements = translateNestedMutations({ + args, + dataParams: params, + typeMap, + isRoot: true + }); + args = Object.values(inputValues).map(arg => arg.astNode); } else { - dataParams = params.params; + // translate nested mutations discovered in input object arguments + nestedStatements = translateNestedMutations({ + args, + dataParams, + typeMap, + paramVariable: paramKey, + isRoot: true + }); } + // use apoc.create.uuid() to set a default value for @id field, // if no value for it is provided in dataParams - statements = setPrimaryKeyValue({ + const fieldMap = schemaType.getFields(); + const fields = Object.values(fieldMap).map(field => field.astNode); + const primaryKey = getPrimaryKey({ fields }); + const primaryKeyStatement = setPrimaryKeyValue({ args, - statements, params: dataParams, primaryKey }); + // build Cypher for root CREATE statement + const safeVariableName = safeVar(variableName); + const safeLabelName = safeLabel([typeName, ...additionalLabels]); const paramStatements = buildCypherParameters({ args, - statements, + statements: primaryKeyStatement, params, paramKey, - resolveInfo + resolveInfo, + typeMap }); - createStatement = `CREATE (${safeVariableName}:${safeLabelName} {${paramStatements.join( + const createStatement = `CREATE (${safeVariableName}:${safeLabelName} {${paramStatements.join( ',' )}})`; + // translate selection set const [subQuery, subParams] = buildCypherSelection({ selections, variableName, @@ -1702,13 +1744,333 @@ const nodeCreate = ({ cypherParams: getCypherParams(context) }); params = { ...params, ...subParams }; + const translation = `${createStatement}${ + nestedStatements + ? ` + WITH * + ${nestedStatements}` + : '' + }`; const query = ` - ${createStatement} + ${translation} RETURN ${safeVariableName} {${subQuery}} AS ${safeVariableName} `; return [query, params]; }; +const translateNestedMutations = ({ + args = [], + dataParams = {}, + paramVariable, + typeMap = {}, + isRoot = false +}) => { + const mappedDataParams = mapMutationParams({ params: dataParams }); + const statements = []; + args.forEach(arg => { + const argName = arg.name.value; + const typeName = unwrapNamedType({ type: arg.type }).name; + const inputType = typeMap[typeName]; + if (isInputObjectType(inputType) && dataParams[argName] !== undefined) { + let paramName = argName; + if (isRoot) paramName = paramVariable; + const statement = translateNestedMutation({ + paramName, + dataParams: mappedDataParams, + args: [arg], + paramVariable, + typeMap, + isRoot + }); + if (statement.length) { + // inputType has at least one @cypher input field + statements.push(...statement); + } else { + // inputType did not have a @cypher input field, so keep looking + const nestedParams = mappedDataParams[argName]; + const nestedArgs = Object.values(inputType.getFields()).map( + arg => arg.astNode + ); + const statement = translateNestedMutation({ + isNestedParam: true, + paramName: argName, + args: nestedArgs, + dataParams: nestedParams, + paramVariable, + typeMap, + isRoot + }); + if (statement.length) statements.push(...statement); + } + } + }); + return statements.join('\n'); +}; + +const translateNestedMutation = ({ + args = [], + paramName, + isRoot, + isNestedParam = false, + dataParams = {}, + paramVariable, + typeMap = {} +}) => { + const translations = []; + // For each defined argument + args.forEach(arg => { + const argName = arg.name.value; + const argumentTypeName = unwrapNamedType({ type: arg.type }).name; + const argumentType = typeMap[argumentTypeName]; + const argValue = dataParams[argName]; + if (isInputObjectType(argumentType) && argValue !== undefined) { + Object.keys(argValue).forEach(name => { + translations.push( + ...translateNestedMutationInput({ + name, + argName, + argValue, + argumentType, + paramName, + paramVariable, + isRoot, + isNestedParam, + typeMap + }) + ); + }); + } + }); + return translations; +}; + +const translateNestedMutationInput = ({ + name, + argName, + argValue, + argumentType, + paramName, + paramVariable, + isRoot, + isNestedParam, + typeMap +}) => { + const translations = []; + const inputFields = argumentType.getFields(); + const inputField = inputFields[name]; + if (inputField) { + const inputFieldAst = inputFields[name].astNode; + const inputFieldTypeName = unwrapNamedType({ type: inputFieldAst.type }) + .name; + const inputFieldType = typeMap[inputFieldTypeName]; + const customCypher = getDirective({ + directives: inputFieldAst.directives, + name: DirectiveDefinition.CYPHER + }); + if (isInputObjectType(inputFieldType)) { + if (customCypher) { + const inputFieldTypeName = inputFieldType.name; + const statement = getDirectiveArgument({ + directive: customCypher, + name: 'statement' + }); + let nestedParamVariable = paramVariable; + if (isRoot) { + nestedParamVariable = paramName; + } else if (isNestedParam) { + nestedParamVariable = `${nestedParamVariable}.${paramName}`; + } + const translated = buildMutationSubQuery({ + inputFieldTypeName, + inputFieldType, + statement, + name, + paramVariable: nestedParamVariable, + argName, + argValue, + typeMap, + isRoot, + isNestedParam + }); + if (translated) translations.push(translated); + } else if (isNestedParam) { + // keep looking + const nestedArgs = Object.values(argumentType.getFields()).map( + arg => arg.astNode + ); + let nestedParamVariable = `${ + paramVariable ? `${paramVariable}.` : '' + }${paramName}`; + let nestedParamName = argName; + if (isRoot) { + nestedParamName = `${paramName ? `${paramName}.` : ''}${argName}`; + } + const statement = translateNestedMutation({ + isNestedParam: true, + isRoot, + paramName: nestedParamName, + args: nestedArgs, + dataParams: argValue, + paramVariable: nestedParamVariable, + typeMap + }); + const nestedStatements = statement.join('\n'); + if (nestedStatements) translations.push(nestedStatements); + } + } + } + return translations; +}; + +const augmentCypherSubQuery = ({ statement, inputFieldTypeName }) => { + const singleLine = statement.replace(/\r?\n|\r/g, ' '); + // find every WITH and add a newline character after each WITH + const newlinedWithClauses = singleLine.replace(/\r?WITH|\r/gi, '\nWITH'); + // split the statement based on newlines + const splitOnWithClauses = newlinedWithClauses.split('\n'); + // get the last line, check if it is a WITH clause + const lastWithClause = splitOnWithClauses[splitOnWithClauses.length - 1]; + const endsWithWithClause = lastWithClause.startsWith('WITH'); + let continueWith = `WITH ${inputFieldTypeName} AS _${inputFieldTypeName}`; + if (endsWithWithClause) { + const trimmed = lastWithClause.trim(); + const withRemoved = trimmed.substr(4); + const firstCommentIndex = withRemoved.indexOf('//'); + const firstParamIndex = withRemoved.indexOf(inputFieldTypeName); + // inputFieldTypeName exist in the provided WITH clause + if (firstParamIndex !== -1) { + if (firstCommentIndex !== -1) { + // it might be in a // comment though, for some reason + if (firstParamIndex > firstCommentIndex) { + // it is, so add it + splitOnWithClauses[splitOnWithClauses.length - 1] = `${continueWith}${ + withRemoved ? `, ${withRemoved}` : '' + }`; + } + } + } else { + // it does not exist, so add it + splitOnWithClauses[splitOnWithClauses.length - 1] = `${continueWith}${ + withRemoved ? `, ${withRemoved}` : '' + }`; + } + } else { + // default + splitOnWithClauses.push(continueWith); + } + return splitOnWithClauses.join('\n'); +}; + +const buildMutationSubQuery = ({ + inputFieldTypeName, + inputFieldType, + statement, + name, + paramVariable, + argName, + argValue, + typeMap, + isRoot +}) => { + let statements = ''; + const inputFieldTypeFields = inputFieldType.getFields(); + const nestedArgs = Object.values(inputFieldTypeFields).map( + arg => arg.astNode + ); + const nestedDataParams = argValue[name]; + const mappedDataParams = mapMutationParams({ params: nestedDataParams }); + const nestedMutationStatements = translateNestedMutations({ + args: nestedArgs, + dataParams: mappedDataParams, + paramVariable: inputFieldTypeName, + typeMap + }); + if (nestedMutationStatements) { + const augmentedStatement = augmentCypherSubQuery({ + statement, + inputFieldTypeName + }); + // persist the parameter variable only if there are further + // nested translations + statements = `${augmentedStatement}${nestedMutationStatements}`; + } else { + // otherwise, we are at a leaf endpoint + statements = statement; + } + // generalized solution for possible edge case where the current and + // nested input type names are the same + if (!isRoot && paramVariable) { + paramVariable = `_${paramVariable}`; + } + let paramPath = `${ + paramVariable ? `${paramVariable}.` : '' + }${argName}.${name}`; + if (isRoot) paramPath = `$${paramPath}`; + return cypherSubQuery({ + paramPath, + paramVariable: inputFieldTypeName, + argName, + name, + statements + }); +}; + +const cypherSubQuery = ({ + paramPath = '', + paramVariable = '', + argName, + name, + statements = '' +}) => { + return ` +CALL { + WITH * + UNWIND ${paramPath} AS ${paramVariable} + ${statements} + RETURN COUNT(*) AS _${argName}_${name}_ +}`; +}; + +const mapMutationParams = ({ params = {} }) => { + return Object.entries(params).reduce((mapped, [name, param]) => { + if (param === null) { + mapped[name] = true; + } else { + mapped[name] = mapMutationParam({ param }); + } + return mapped; + }, {}); +}; + +const mapMutationParam = ({ param }) => { + let mapped = {}; + if (Array.isArray(param)) { + const firstElement = param[0]; + if (typeof firstElement === 'object' && !Array.isArray(firstElement)) { + param.forEach(listObject => { + const subMapped = mapMutationParams({ + params: listObject + }); + mapped = { + ...mapped, + ...subMapped + }; + }); + // list of object values + return mapped; + } else { + // list argument of non-object values + return true; + } + } else if (typeof param === 'object') { + if (param === null) return true; + return mapMutationParams({ + params: param + }); + } + return true; +}; + const nodeMergeOrUpdate = ({ resolveInfo, variableName, @@ -1722,6 +2084,9 @@ const nodeMergeOrUpdate = ({ }) => { const safeVariableName = safeVar(variableName); const args = getMutationArguments(resolveInfo); + let paramKey = 'params'; + let dataParams = params[paramKey]; + let nestedStatements = ''; const selectionArgument = args.find(arg => arg.name.value === 'where'); const dataArgument = args.find(arg => arg.name.value === 'data'); @@ -1744,7 +2109,7 @@ const nodeMergeOrUpdate = ({ if (selectionArgument && dataArgument) { // config.experimental // no need to use .params key in this argument design - params = params.params; + params = dataParams; const [propertyStatements, generatePrimaryKey] = translateNodeInputArgument( { selectionArgument, @@ -1774,9 +2139,9 @@ const nodeMergeOrUpdate = ({ params, paramKey: 'where', resolveInfo, - cypherParams: getCypherParams(context) + cypherParams: getCypherParams(context), + typeMap }); - // generatePrimaryKey is either empty or contains a call to apoc.create.uuid for @id key const onCreateProps = [...propertyStatements, ...generatePrimaryKey]; let onCreateStatements = ``; if (onCreateProps.length > 0) { @@ -1802,18 +2167,32 @@ ON MATCH ${onMatchStatements}\n`; params = { ...params, ...serializedFilter }; } + nestedStatements = translateNestedMutations({ + args, + dataParams, + typeMap, + isRoot: true + }); } else { + nestedStatements = translateNestedMutations({ + args, + dataParams, + paramVariable: paramKey, + typeMap, + isRoot: true + }); const [primaryKeyParam, updateParams] = splitSelectionParameters( params, primaryKeyArgName, - 'params' + paramKey ); paramUpdateStatements = buildCypherParameters({ args, params: updateParams, - paramKey: 'params', + paramKey: paramKey, resolveInfo, - cypherParams: getCypherParams(context) + cypherParams: getCypherParams(context), + typeMap }); query = `${cypherOperation} (${safeVariableName}:${safeLabelName}{${primaryKeyArgName}: $params.${primaryKeyArgName}}) `; @@ -1833,7 +2212,13 @@ ${onMatchStatements}\n`; cypherParams: getCypherParams(context) }); params = { ...params, ...subParams }; - query += `RETURN ${safeVariableName} {${subQuery}} AS ${safeVariableName}`; + query = `${query}${ + nestedStatements + ? ` + WITH * + ${nestedStatements}` + : '' + }RETURN ${safeVariableName} {${subQuery}} AS ${safeVariableName}`; return [query, params]; }; @@ -1843,6 +2228,7 @@ const nodeDelete = ({ variableName, typeName, schemaType, + typeMap, params }) => { const safeVariableName = safeVar(variableName); @@ -1867,6 +2253,12 @@ const nodeDelete = ({ } else { matchStatement = `MATCH (${safeVariableName}:${safeLabelName} {${primaryKeyArgName}: $${primaryKeyArgName}})`; } + const nestedStatements = translateNestedMutations({ + args, + dataParams: params, + typeMap, + isRoot: true + }); const [subQuery, subParams] = buildCypherSelection({ selections, variableName, @@ -1875,12 +2267,23 @@ const nodeDelete = ({ }); params = { ...params, ...subParams }; const deletionVariableName = safeVar(`${variableName}_toDelete`); - // Cannot execute a map projection on a deleted node in Neo4j - // so the projection is executed and aliased before the delete - const query = `${matchStatement} + let query = ''; + if (nestedStatements) { + // Cannot execute a map projection on a deleted node in Neo4j + // so the projection is executed and aliased before the delete + query = `${matchStatement} +${nestedStatements} +WITH ${safeVariableName} AS ${deletionVariableName}, ${safeVariableName} {${subQuery}} AS ${safeVariableName} +DETACH DELETE ${deletionVariableName} +RETURN ${safeVariableName}`; + } else { + // Cannot execute a map projection on a deleted node in Neo4j + // so the projection is executed and aliased before the delete + query = `${matchStatement} WITH ${safeVariableName} AS ${deletionVariableName}, ${safeVariableName} {${subQuery}} AS ${safeVariableName} DETACH DELETE ${deletionVariableName} RETURN ${safeVariableName}`; + } return [query, params]; }; @@ -1903,7 +2306,8 @@ const translateNodeInputArgument = ({ params, paramKey: 'data', resolveInfo, - cypherParams: getCypherParams(context) + cypherParams: getCypherParams(context), + typeMap }); let primaryKeyStatement = []; if (isMergeMutation(resolveInfo)) { @@ -2101,7 +2505,8 @@ const relationshipCreate = ({ args: dataFields, params, paramKey: 'data', - resolveInfo + resolveInfo, + typeMap }); params = { ...params, ...subParams }; let query = ` @@ -2403,7 +2808,8 @@ const relationshipMergeOrUpdate = ({ args: dataFields, params, paramKey: 'data', - resolveInfo + resolveInfo, + typeMap }); let cypherOperation = ''; diff --git a/src/utils.js b/src/utils.js index 23d26613..d85cd5ef 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,4 @@ -import { parse, Kind } from 'graphql'; +import { parse, Kind, isInputObjectType } from 'graphql'; import { unwrapNamedType, isListTypeField } from './augment/fields'; import { Neo4jTypeFormatted } from './augment/types/types'; import { @@ -424,7 +424,8 @@ export const buildCypherParameters = ({ args, statements = [], params, - paramKey + paramKey, + typeMap }) => { const dataParams = paramKey ? params[paramKey] : params; const paramKeys = dataParams ? Object.keys(dataParams) : []; @@ -436,6 +437,7 @@ export const buildCypherParameters = ({ if (fieldAst) { const unwrappedType = unwrapNamedType({ type: fieldAst.type }); const fieldTypeName = unwrappedType.name; + const innerSchemaType = typeMap[fieldTypeName]; if (isNeo4jTypeInput(fieldTypeName)) { paramStatements = buildNeo4jTypeCypherParameters({ paramStatements, @@ -445,7 +447,7 @@ export const buildCypherParameters = ({ paramName, fieldTypeName }); - } else { + } else if (!isInputObjectType(innerSchemaType)) { // normal case paramStatements.push( `${paramName}:$${paramKey ? `${paramKey}.` : ''}${paramName}` diff --git a/test/helpers/custom/customSchemaTest.js b/test/helpers/custom/customSchemaTest.js new file mode 100644 index 00000000..984a6501 --- /dev/null +++ b/test/helpers/custom/customSchemaTest.js @@ -0,0 +1,125 @@ +import { graphql } from 'graphql'; +import { makeExecutableSchema } from 'graphql-tools'; +import _ from 'lodash'; +import { + cypherQuery, + cypherMutation, + makeAugmentedSchema, + augmentTypeDefs +} from '../../../src/index'; +import { printSchemaDocument } from '../../../src/augment/augment'; +import { testSchema } from './testSchema'; + +export function cypherTestRunner( + t, + graphqlQuery, + graphqlParams, + expectedCypherQuery, + expectedCypherParams +) { + const checkCypherQuery = (object, params, ctx, resolveInfo) => { + const [query, queryParams] = cypherQuery(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + const deserializedParams = JSON.parse(JSON.stringify(queryParams)); + t.deepEqual(deserializedParams, expectedCypherParams); + }; + + const checkCypherMutation = (object, params, ctx, resolveInfo) => { + const [query, queryParams] = cypherMutation(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + const deserializedParams = JSON.parse(JSON.stringify(queryParams)); + t.deepEqual(deserializedParams, expectedCypherParams); + }; + + const resolvers = { + Mutation: { + CreateUser: checkCypherMutation, + DeleteUser: checkCypherMutation, + MergeUser: checkCypherMutation, + Custom: checkCypherMutation + } + }; + let augmentedTypeDefs = augmentTypeDefs(testSchema, { + auth: true + }); + const schema = makeExecutableSchema({ + typeDefs: augmentedTypeDefs, + resolvers, + resolverValidationOptions: { + requireResolversForResolveType: false + } + }); + + // query the test schema with the test query, assertion is in the resolver + return graphql( + schema, + graphqlQuery, + null, + { + cypherParams: { + userId: 'user-id' + } + }, + graphqlParams + ); +} + +export function augmentedSchemaCypherTestRunner( + t, + graphqlQuery, + graphqlParams, + expectedCypherQuery, + expectedCypherParams +) { + const checkCypherQuery = (object, params, ctx, resolveInfo) => { + const [query, queryParams] = cypherQuery(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + const deserializedParams = JSON.parse(JSON.stringify(queryParams)); + t.deepEqual(deserializedParams, expectedCypherParams); + }; + const checkCypherMutation = (object, params, ctx, resolveInfo) => { + const [query, queryParams] = cypherMutation(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + const deserializedParams = JSON.parse(JSON.stringify(queryParams)); + t.deepEqual(deserializedParams, expectedCypherParams); + }; + + const resolvers = { + Mutation: { + CreateUser: checkCypherMutation, + DeleteUser: checkCypherMutation, + MergeUser: checkCypherMutation, + Custom: checkCypherMutation + } + }; + + const cypherTestTypeDefs = printSchemaDocument({ + schema: makeAugmentedSchema({ + typeDefs: testSchema, + resolvers: {}, + config: { + auth: true + } + }) + }); + + const augmentedSchema = makeExecutableSchema({ + typeDefs: cypherTestTypeDefs, + resolvers, + resolverValidationOptions: { + requireResolversForResolveType: false + } + }); + + return graphql( + augmentedSchema, + graphqlQuery, + null, + { + cypherParams: { + userId: 'user-id' + } + }, + graphqlParams + ); +} diff --git a/test/helpers/custom/testSchema.js b/test/helpers/custom/testSchema.js new file mode 100644 index 00000000..b46ba43c --- /dev/null +++ b/test/helpers/custom/testSchema.js @@ -0,0 +1,183 @@ +import { gql } from 'apollo-server'; +import { cypher } from '../../../src/index'; + +export const testSchema = gql` + type User { + idField: ID! @id + name: String + names: [String] + birthday: DateTime + liked: [Movie!] @relation(name: "RATING", direction: OUT) + uniqueString: String @unique + createdAt: DateTime + modifiedAt: DateTime + } + + type Movie { + id: ID! @id + title: String! @unique + likedBy: [User!] @relation(name: "RATING", direction: IN) + custom: String + } + + type Query { + User: [User!] + Movie: [Movie!] + } + + type Mutation { + CreateUser(idField: ID, name: String, names: [String], birthday: DateTime, uniqueString: String, liked: UserLiked, sideEffects: OnUserCreate): User + MergeUser(idField: ID!, name: String, names: [String], birthday: DateTime, uniqueString: String, liked: UserLiked, sideEffects: OnUserMerge): User + DeleteUser(idField: ID!, liked: UserLiked): User + Custom(id: ID!, sideEffects: CustomSideEffects, computed: CustomComputed): Custom @cypher(${cypher` + MERGE (custom: Custom { + id: $id + }) + RETURN custom + `}) + } + + type Custom { + id: ID! @id + computed: Int + nested: [Custom] @relation(name: "RELATED", direction: OUT) + } + + input CustomData { + id: ID! + nested: CustomSideEffects + } + + input CustomComputed { + computed: ComputeComputed + } + + input CustomComputedInput { + value: Int! + } + + input ComputeComputed { + multiply: CustomComputedInput @cypher(${cypher` + SET custom.computed = CustomComputedInput.value * 10 + `}) + } + + input CustomSideEffects { + create: [CustomData] @cypher(${cypher` + MERGE (custom)-[:RELATED]->(subCustom: Custom { + id: CustomData.id + }) + WITH subCustom AS custom + `}) + } + + input UserWhere { + idField: ID + } + + input UserCreate { + idField: ID + name: String + names: [String] + birthday: DateTime + uniqueString: String + liked: UserLiked + } + + input OnUserCreate { + createdAt: CreatedAt @cypher(${cypher` + SET user.createdAt = datetime(CreatedAt.datetime) + `}) + } + + input OnUserMerge { + mergedAt: CreatedAt @cypher(${cypher` + SET user.modifiedAt = datetime(CreatedAt.datetime) + `}) + } + + input CreatedAt { + datetime: DateTime! + } + + input UserMerge { + where: UserWhere + data: UserCreate + } + + input UserLiked { + create: [MovieCreate!] @cypher(${cypher` + CREATE (user)-[:RATING]->(movie: Movie { + id: MovieCreate.id, + title: MovieCreate.title + }) + WITH movie + `}) + nestedCreate: [MovieCreate!] @cypher(${cypher` + CREATE (user)-[:RATING]->(movie: Movie { + id: MovieCreate.customLayer.movie.id, + title: MovieCreate.customLayer.movie.title, + custom: MovieCreate.customLayer.custom + }) + WITH movie + `}) + merge: [MovieMerge!] @cypher(${cypher` + MERGE (movie: Movie { + id: MovieMerge.where.id + }) + ON CREATE + SET movie.title = MovieMerge.data.title + MERGE (user)-[:RATING]->(movie) + WITH movie + `}) + delete: [MovieWhere!] @cypher(${cypher` + MATCH (user)-[:RATING]->(movie: Movie { id: MovieWhere.id }) + DETACH DELETE movie + `}) + } + + input MovieMerge { + where: MovieWhere + data: MovieCreate + } + + input MovieWhere { + id: ID! + } + + input MovieCreate { + id: ID + title: String + likedBy: MovieLikedBy + customLayer: MovieCreateParamLayer + } + + input MovieCreateParamLayer { + custom: String + movie: MovieCreate + } + + input MovieLikedBy { + create: [UserCreate!] @cypher(${cypher` + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString + }) + `}) + merge: [UserMerge!] @cypher(${cypher` + MERGE (user: User { + idField: UserMerge.where.idField + }) + ON CREATE + SET user.name = UserMerge.data.name, + user.uniqueString = UserMerge.data.uniqueString + MERGE (movie)<-[:RATING]-(user) + `}) + } + + enum Role { + reader + user + admin + } +`; diff --git a/test/helpers/experimental/augmentSchemaTest.js b/test/helpers/experimental/augmentSchemaTest.js index 92a6424a..d59bb43d 100644 --- a/test/helpers/experimental/augmentSchemaTest.js +++ b/test/helpers/experimental/augmentSchemaTest.js @@ -8,7 +8,7 @@ import { augmentTypeDefs } from '../../../src/index'; import { printSchemaDocument } from '../../../src/augment/augment'; -import { testSchema } from '../../helpers/experimental/testSchema'; +import { testSchema } from './testSchema'; // Optimization to prevent schema augmentation from running for every test const cypherTestTypeDefs = printSchemaDocument({ @@ -192,10 +192,6 @@ export function augmentedSchemaCypherTestRunner( resolvers, resolverValidationOptions: { requireResolversForResolveType: false - }, - config: { - auth: true, - experimental: true } }); diff --git a/test/helpers/experimental/custom/customSchemaTest.js b/test/helpers/experimental/custom/customSchemaTest.js new file mode 100644 index 00000000..b646bc1d --- /dev/null +++ b/test/helpers/experimental/custom/customSchemaTest.js @@ -0,0 +1,127 @@ +import { graphql } from 'graphql'; +import { makeExecutableSchema } from 'graphql-tools'; +import _ from 'lodash'; +import { + cypherQuery, + cypherMutation, + makeAugmentedSchema, + augmentTypeDefs +} from '../../../../src/index'; +import { printSchemaDocument } from '../../../../src/augment/augment'; +import { testSchema } from './testSchema'; + +export function cypherTestRunner( + t, + graphqlQuery, + graphqlParams, + expectedCypherQuery, + expectedCypherParams +) { + const checkCypherQuery = (object, params, ctx, resolveInfo) => { + const [query, queryParams] = cypherQuery(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + const deserializedParams = JSON.parse(JSON.stringify(queryParams)); + t.deepEqual(deserializedParams, expectedCypherParams); + }; + + const checkCypherMutation = (object, params, ctx, resolveInfo) => { + const [query, queryParams] = cypherMutation(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + const deserializedParams = JSON.parse(JSON.stringify(queryParams)); + t.deepEqual(deserializedParams, expectedCypherParams); + }; + + const resolvers = { + Mutation: { + CreateUser: checkCypherMutation, + DeleteUser: checkCypherMutation, + MergeUser: checkCypherMutation, + Custom: checkCypherMutation + } + }; + let augmentedTypeDefs = augmentTypeDefs(testSchema, { + auth: true, + experimental: true + }); + const schema = makeExecutableSchema({ + typeDefs: augmentedTypeDefs, + resolvers, + resolverValidationOptions: { + requireResolversForResolveType: false + } + }); + + // query the test schema with the test query, assertion is in the resolver + return graphql( + schema, + graphqlQuery, + null, + { + cypherParams: { + userId: 'user-id' + } + }, + graphqlParams + ); +} + +export function augmentedSchemaCypherTestRunner( + t, + graphqlQuery, + graphqlParams, + expectedCypherQuery, + expectedCypherParams +) { + const checkCypherQuery = (object, params, ctx, resolveInfo) => { + const [query, queryParams] = cypherQuery(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + const deserializedParams = JSON.parse(JSON.stringify(queryParams)); + t.deepEqual(deserializedParams, expectedCypherParams); + }; + const checkCypherMutation = (object, params, ctx, resolveInfo) => { + const [query, queryParams] = cypherMutation(params, ctx, resolveInfo); + t.is(query, expectedCypherQuery); + const deserializedParams = JSON.parse(JSON.stringify(queryParams)); + t.deepEqual(deserializedParams, expectedCypherParams); + }; + + const resolvers = { + Mutation: { + CreateUser: checkCypherMutation, + DeleteUser: checkCypherMutation, + MergeUser: checkCypherMutation, + Custom: checkCypherMutation + } + }; + + const cypherTestTypeDefs = printSchemaDocument({ + schema: makeAugmentedSchema({ + typeDefs: testSchema, + resolvers: {}, + config: { + auth: true, + experimental: true + } + }) + }); + + const augmentedSchema = makeExecutableSchema({ + typeDefs: cypherTestTypeDefs, + resolvers, + resolverValidationOptions: { + requireResolversForResolveType: false + } + }); + + return graphql( + augmentedSchema, + graphqlQuery, + null, + { + cypherParams: { + userId: 'user-id' + } + }, + graphqlParams + ); +} diff --git a/test/helpers/experimental/custom/testSchema.js b/test/helpers/experimental/custom/testSchema.js new file mode 100644 index 00000000..cb2c5323 --- /dev/null +++ b/test/helpers/experimental/custom/testSchema.js @@ -0,0 +1,189 @@ +import { gql } from 'apollo-server'; +import { cypher } from '../../../../src/index'; + +export const testSchema = gql` + type User { + idField: ID! @id + name: String + names: [String] + birthday: DateTime + liked: [Movie!] @relation(name: "RATING", direction: OUT) + uniqueString: String @unique + createdAt: DateTime + modifiedAt: DateTime + int: Int + } + + type Movie { + id: ID! @id + title: String! @unique + likedBy: [User!] @relation(name: "RATING", direction: IN) + custom: String + } + + type Query { + User: [User!] + Movie: [Movie!] + } + + type Mutation { + CreateUser(data: UserCreate!): User + MergeUser(where: UserWhere!, data: UserCreate!): User + DeleteUser(where: UserWhere!, liked: UserLiked): User + Custom(id: ID!, sideEffects: CustomSideEffects, computed: CustomComputed): Custom @cypher(${cypher` + MERGE (custom: Custom { + id: $id + }) + RETURN custom + `}) + } + + type Custom { + id: ID! @id + computed: Int + nested: [Custom] @relation(name: "RELATED", direction: OUT) + } + + input CustomData { + id: ID! + nested: CustomSideEffects + } + + input CustomComputed { + computed: ComputeComputed + } + + input CustomComputedInput { + value: Int! + } + + input ComputeComputed { + multiply: CustomComputedInput @cypher(${cypher` + SET custom.computed = CustomComputedInput.value * 10 + `}) + } + + input CustomSideEffects { + create: [CustomData] @cypher(${cypher` + MERGE (custom)-[:RELATED]->(subCustom: Custom { + id: CustomData.id + }) + WITH subCustom AS custom + `}) + } + + input UserWhere { + idField: ID + } + + input UserCreate { + idField: ID + name: String + names: [String] + birthday: DateTime + uniqueString: String + liked: UserLiked + onUserCreate: OnUserCreate + onUserMerge: OnUserMerge + } + + input OnUserCreate { + nested: OnUserCreate + int: Int + createdAt: CreatedAt @cypher(${cypher` + SET user.int = $data.onUserCreate.int + SET user.createdAt = datetime(CreatedAt.datetime) + `}) + } + + input OnUserMerge { + mergedAt: CreatedAt @cypher(${cypher` + SET user.modifiedAt = datetime(CreatedAt.datetime) + `}) + } + + input CreatedAt { + datetime: DateTime! + } + + input UserMerge { + where: UserWhere + data: UserCreate + } + + input UserLiked { + create: [MovieCreate!] @cypher(${cypher` + CREATE (user)-[:RATING]->(movie: Movie { + id: MovieCreate.id, + title: MovieCreate.title + }) + WITH movie + `}) + nestedCreate: [MovieCreate!] @cypher(${cypher` + CREATE (user)-[:RATING]->(movie: Movie { + id: MovieCreate.customLayer.data.id, + title: MovieCreate.customLayer.data.title, + custom: MovieCreate.customLayer.custom + }) + WITH movie + `}) + merge: [MovieMerge!] @cypher(${cypher` + MERGE (movie: Movie { + id: MovieMerge.where.id + }) + ON CREATE + SET movie.title = MovieMerge.data.title + MERGE (user)-[:RATING]->(movie) + WITH movie + `}) + delete: [MovieWhere!] @cypher(${cypher` + MATCH (user)-[:RATING]->(movie: Movie { id: MovieWhere.id }) + DETACH DELETE movie + `}) + } + + input MovieMerge { + where: MovieWhere + data: MovieCreate + } + + input MovieWhere { + id: ID! + } + + input MovieCreate { + id: ID + title: String + likedBy: MovieLikedBy + customLayer: MovieCreateParamLayer + } + + input MovieCreateParamLayer { + custom: String + data: MovieCreate + } + + input MovieLikedBy { + create: [UserCreate!] @cypher(${cypher` + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString + }) + `}) + merge: [UserMerge!] @cypher(${cypher` + MERGE (user: User { + idField: UserMerge.where.idField + }) + ON CREATE + SET user.name = UserMerge.data.name, + user.uniqueString = UserMerge.data.uniqueString + MERGE (movie)<-[:RATING]-(user) + `}) + } + + enum Role { + reader + user + admin + } +`; diff --git a/test/helpers/testSchema.js b/test/helpers/testSchema.js index 444eac12..a9d2f0f3 100644 --- a/test/helpers/testSchema.js +++ b/test/helpers/testSchema.js @@ -6,7 +6,7 @@ export const testSchema = ` block description """ - directive @cypher(statement: String) on FIELD_DEFINITION + directive @cypher(statement: String) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION "Object type line description" type Movie diff --git a/test/unit/augmentSchemaTest.test.js b/test/unit/augmentSchemaTest.test.js index 06ed5577..f72c2c08 100644 --- a/test/unit/augmentSchemaTest.test.js +++ b/test/unit/augmentSchemaTest.test.js @@ -6001,7 +6001,9 @@ test.cb('Test augmented schema', t => { block description """ - directive @cypher(statement: String) on FIELD_DEFINITION + directive @cypher( + statement: String + ) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @relation( name: String diff --git a/test/unit/custom/cypherTest.test.js b/test/unit/custom/cypherTest.test.js new file mode 100644 index 00000000..f0ba09ae --- /dev/null +++ b/test/unit/custom/cypherTest.test.js @@ -0,0 +1,742 @@ +import test from 'ava'; +import { + cypherTestRunner, + augmentedSchemaCypherTestRunner +} from '../../helpers/custom/customSchemaTest'; + +const CYPHER_PARAMS = { + userId: 'user-id' +}; + +test('Create node mutation with nested @cypher', t => { + const graphQLQuery = `mutation { + CreateUser( + idField: "user-1" + liked: { + create: [ + { + id: "movie-1" + title: "title-1" + } + { + id: "movie-2" + title: "title-2" + } + { + id: "movie-3" + title: "title-4" + } + { + id: "movie-4" + title: "title-4" + } + ] + } + ) { + idField + liked { + id + title + } + } + } + `, + expectedCypherQuery = ` + CREATE (\`user\`:\`User\` {idField:$params.idField}) + WITH * + +CALL { + WITH * + UNWIND $params.liked.create AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { + id: MovieCreate.id, + title: MovieCreate.title +}) +WITH movie + RETURN COUNT(*) AS _liked_create_ +} + RETURN \`user\` { .idField ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title }] } AS \`user\` + `, + expectedParams = { + params: { + idField: 'user-1', + liked: { + create: [ + { + id: 'movie-1', + title: 'title-1' + }, + { + id: 'movie-2', + title: 'title-2' + }, + { + id: 'movie-3', + title: 'title-4' + }, + { + id: 'movie-4', + title: 'title-4' + } + ] + } + }, + first: -1, + offset: 0 + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Create node mutation with nested @cypher, skipping 1 non-@mutation input object', t => { + const graphQLQuery = `mutation { + CreateUser( + idField: "user-1" + liked: { + nestedCreate: [ + { + customLayer: { + custom: "custom-1" + movie: { + id: "movie-1" + title: "title-1" + } + } + } + { + customLayer: { + custom: "custom-2" + movie: { + id: "movie-2" + title: "title-2" + } + } + } + { + customLayer: { + custom: "custom-3" + movie: { + id: "movie-3" + title: "title-3" + } + } + } + { + customLayer: { + custom: "custom-4" + movie: { + id: "movie-4" + title: "title-4" + } + } + } + ] + } + ) { + idField + liked { + id + title + custom + } + } + } + `, + expectedCypherQuery = ` + CREATE (\`user\`:\`User\` {idField:$params.idField}) + WITH * + +CALL { + WITH * + UNWIND $params.liked.nestedCreate AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { + id: MovieCreate.customLayer.movie.id, + title: MovieCreate.customLayer.movie.title, + custom: MovieCreate.customLayer.custom +}) +WITH movie + RETURN COUNT(*) AS _liked_nestedCreate_ +} + RETURN \`user\` { .idField ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title , .custom }] } AS \`user\` + `, + expectedParams = { + params: { + idField: 'user-1', + liked: { + nestedCreate: [ + { + customLayer: { + custom: 'custom-1', + movie: { + id: 'movie-1', + title: 'title-1' + } + } + }, + { + customLayer: { + custom: 'custom-2', + movie: { + id: 'movie-2', + title: 'title-2' + } + } + }, + { + customLayer: { + custom: 'custom-3', + movie: { + id: 'movie-3', + title: 'title-3' + } + } + }, + { + customLayer: { + custom: 'custom-4', + movie: { + id: 'movie-4', + title: 'title-4' + } + } + } + ] + } + }, + first: -1, + offset: 0 + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Delete node mutation with @cypher deleting related node', t => { + const graphQLQuery = `mutation { + DeleteUser( + idField: "a" + liked: { + delete: { + id: "movie-1" + } + } + ) { + idField + liked { + id + } + } + } + `, + expectedCypherQuery = `MATCH (\`user\`:\`User\` {idField: $idField}) + +CALL { + WITH * + UNWIND $liked.delete AS MovieWhere + MATCH (user)-[:RATING]->(movie: Movie { id: MovieWhere.id }) +DETACH DELETE movie + RETURN COUNT(*) AS _liked_delete_ +} +WITH \`user\` AS \`user_toDelete\`, \`user\` { .idField ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id }] } AS \`user\` +DETACH DELETE \`user_toDelete\` +RETURN \`user\``, + expectedParams = { + idField: 'a', + liked: { + delete: [ + { + id: 'movie-1' + } + ] + }, + first: -1, + offset: 0 + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Create node mutation with multiple nested @cypher', t => { + const graphQLQuery = `mutation { + CreateUser( + idField: "a" + name: "Ada" + uniqueString: "b" + birthday: { year: 2020, month: 11, day: 10 } + names: ["A", "B"] + liked: { + create: [ + { + id: "movie-1" + title: "title-1" + likedBy: { + create: [ + { name: "Alan", uniqueString: "x" } + { name: "Ada", uniqueString: "y" } + ] + } + } + { + id: "movie-2" + title: "title-2" + likedBy: { + create: [ + { name: "Alan", uniqueString: "a" } + { name: "Ada", uniqueString: "b" } + ] + } + } + ] + } + sideEffects: { + createdAt: { datetime: { year: 2020, month: 11, day: 13 } } + } + ) { + idField + uniqueString + liked { + id + title + likedBy { + name + uniqueString + } + } + createdAt { + formatted + } + } + } + `, + expectedCypherQuery = ` + CREATE (\`user\`:\`User\` {idField:$params.idField,name:$params.name,names:$params.names,birthday: datetime($params.birthday),uniqueString:$params.uniqueString}) + WITH * + +CALL { + WITH * + UNWIND $params.liked.create AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { id: MovieCreate.id, title: MovieCreate.title }) +WITH MovieCreate AS _MovieCreate, movie +CALL { + WITH * + UNWIND _MovieCreate.likedBy.create AS UserCreate + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString +}) + RETURN COUNT(*) AS _likedBy_create_ +} + RETURN COUNT(*) AS _liked_create_ +} + +CALL { + WITH * + UNWIND $params.sideEffects.createdAt AS CreatedAt + SET user.createdAt = datetime(CreatedAt.datetime) + RETURN COUNT(*) AS _sideEffects_createdAt_ +} + RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] ,createdAt: { formatted: toString(\`user\`.createdAt) }} AS \`user\` + `, + expectedParams = { + params: { + idField: 'a', + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + create: [ + { + id: 'movie-1', + title: 'title-1', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'x' + }, + { + name: 'Ada', + uniqueString: 'y' + } + ] + } + }, + { + id: 'movie-2', + title: 'title-2', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'a' + }, + { + name: 'Ada', + uniqueString: 'b' + } + ] + } + } + ] + }, + sideEffects: { + createdAt: { + datetime: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 13, + high: 0 + } + } + } + } + }, + first: -1, + offset: 0 + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Merge node mutation with multiple nested @cypher', t => { + const graphQLQuery = `mutation { + MergeUser( + idField: "a" + name: "Ada" + uniqueString: "b" + birthday: { year: 2020, month: 11, day: 10 } + names: ["A", "B"] + liked: { + create: [ + { + id: "movie-1" + title: "title-1" + likedBy: { + create: [ + { name: "Alan", uniqueString: "x" } + { name: "Ada", uniqueString: "y" } + ] + } + } + { + id: "movie-2" + title: "title-2" + likedBy: { + create: [ + { name: "Alan", uniqueString: "a" } + { name: "Ada", uniqueString: "b" } + ] + } + } + ] + } + sideEffects: { + mergedAt: { datetime: { year: 2020, month: 11, day: 13 } } + } + ) { + idField + uniqueString + liked { + id + title + likedBy { + name + uniqueString + } + } + createdAt { + formatted + } + } + } + `, + expectedCypherQuery = `MERGE (\`user\`:\`User\`{idField: $params.idField}) + SET \`user\` += {name:$params.name,names:$params.names,birthday: datetime($params.birthday),uniqueString:$params.uniqueString} + WITH * + +CALL { + WITH * + UNWIND $params.liked.create AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { id: MovieCreate.id, title: MovieCreate.title }) +WITH MovieCreate AS _MovieCreate, movie +CALL { + WITH * + UNWIND _MovieCreate.likedBy.create AS UserCreate + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString +}) + RETURN COUNT(*) AS _likedBy_create_ +} + RETURN COUNT(*) AS _liked_create_ +} + +CALL { + WITH * + UNWIND $params.sideEffects.mergedAt AS CreatedAt + SET user.modifiedAt = datetime(CreatedAt.datetime) + RETURN COUNT(*) AS _sideEffects_mergedAt_ +}RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] ,createdAt: { formatted: toString(\`user\`.createdAt) }} AS \`user\``, + expectedParams = { + params: { + idField: 'a', + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + create: [ + { + id: 'movie-1', + title: 'title-1', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'x' + }, + { + name: 'Ada', + uniqueString: 'y' + } + ] + } + }, + { + id: 'movie-2', + title: 'title-2', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'a' + }, + { + name: 'Ada', + uniqueString: 'b' + } + ] + } + } + ] + }, + sideEffects: { + mergedAt: { + datetime: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 13, + high: 0 + } + } + } + } + }, + first: -1, + offset: 0 + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Custom @cypher mutation with multiple nested @cypher (experimental api)', t => { + const graphQLQuery = `mutation { + Custom( + id: "a" + sideEffects: { + create: [ + { id: "b", nested: { create: [{ id: "d" }, { id: "e" }] } } + { id: "c", nested: { create: [{ id: "f" }, { id: "g" }] } } + ] + } + computed: { + computed: { + multiply: { + value: 5 + } + } + } + ) { + id + computed + nested { + id + nested { + id + } + } + } + } + `, + expectedCypherQuery = `CALL apoc.cypher.doIt("MERGE (custom: Custom { + id: $id +}) +RETURN custom", {id:$id, sideEffects:$sideEffects, computed:$computed, first:$first, offset:$offset, cypherParams: $cypherParams}) YIELD value + WITH apoc.map.values(value, [keys(value)[0]])[0] AS \`custom\` +CALL { + WITH * + UNWIND $sideEffects.create AS CustomData + MERGE (custom)-[:RELATED]->(subCustom: Custom { id: CustomData.id }) +WITH CustomData AS _CustomData, subCustom AS custom +CALL { + WITH * + UNWIND _CustomData.nested.create AS CustomData + MERGE (custom)-[:RELATED]->(subCustom: Custom { + id: CustomData.id +}) +WITH subCustom AS custom + RETURN COUNT(*) AS _nested_create_ +} + RETURN COUNT(*) AS _sideEffects_create_ +} + +CALL { + WITH * + UNWIND $computed.computed.multiply AS CustomComputedInput + SET custom.computed = CustomComputedInput.value * 10 + RETURN COUNT(*) AS _computed_multiply_ +} + RETURN \`custom\` { .id , .computed ,nested: [(\`custom\`)-[:\`RELATED\`]->(\`custom_nested\`:\`Custom\`) | \`custom_nested\` { .id ,nested: [(\`custom_nested\`)-[:\`RELATED\`]->(\`custom_nested_nested\`:\`Custom\`) | \`custom_nested_nested\` { .id }] }] } AS \`custom\``, + expectedParams = { + id: 'a', + sideEffects: { + create: [ + { + id: 'b', + nested: { + create: [ + { + id: 'd' + }, + { + id: 'e' + } + ] + } + }, + { + id: 'c', + nested: { + create: [ + { + id: 'f' + }, + { + id: 'g' + } + ] + } + } + ] + }, + computed: { + computed: { + multiply: { + value: { + low: 5, + high: 0 + } + } + } + }, + first: -1, + offset: 0, + cypherParams: CYPHER_PARAMS + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); diff --git a/test/unit/experimental/augmentSchemaTest.test.js b/test/unit/experimental/augmentSchemaTest.test.js index 42eeaf5a..f2f4a653 100644 --- a/test/unit/experimental/augmentSchemaTest.test.js +++ b/test/unit/experimental/augmentSchemaTest.test.js @@ -700,7 +700,9 @@ test.cb('Test augmented schema', t => { OUT } - directive @cypher(statement: String) on FIELD_DEFINITION + directive @cypher( + statement: String + ) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION directive @relation( name: String diff --git a/test/unit/experimental/custom/cypherTest.test.js b/test/unit/experimental/custom/cypherTest.test.js new file mode 100644 index 00000000..48ba6cf8 --- /dev/null +++ b/test/unit/experimental/custom/cypherTest.test.js @@ -0,0 +1,1628 @@ +import test from 'ava'; +import { + cypherTestRunner, + augmentedSchemaCypherTestRunner +} from '../../../helpers/experimental/custom/customSchemaTest'; + +const CYPHER_PARAMS = { + userId: 'user-id' +}; + +test('Create node mutation with nested @cypher input 2 levels deep (experimental api)', t => { + const graphQLQuery = `mutation { + CreateUser( + data: { + idField: "a" + name: "Ada" + uniqueString: "b" + birthday: { + year: 2020 + month: 11 + day: 10 + } + names: ["A", "B"] + liked: { + create: [ + { + id: "movie-1" + title: "title-1" + likedBy: { + create: [ + { name: "Alan", uniqueString: "x" } + { name: "Ada", uniqueString: "y" } + ] + } + } + { + id: "movie-2" + title: "title-2" + likedBy: { + create: [ + { name: "Alan", uniqueString: "a" } + { name: "Ada", uniqueString: "b" } + ] + } + } + ] + } + } + ) { + idField + uniqueString + liked { + id + title + likedBy { + name + uniqueString + } + } + } + } + `, + expectedCypherQuery = ` + CREATE (\`user\`:\`User\` {idField:$data.idField,name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString}) + WITH * + +CALL { + WITH * + UNWIND $data.liked.create AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { id: MovieCreate.id, title: MovieCreate.title }) +WITH MovieCreate AS _MovieCreate, movie +CALL { + WITH * + UNWIND _MovieCreate.likedBy.create AS UserCreate + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString +}) + RETURN COUNT(*) AS _likedBy_create_ +} + RETURN COUNT(*) AS _liked_create_ +} + RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] } AS \`user\` + `, + expectedParams = { + first: -1, + offset: 0, + data: { + idField: 'a', + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + create: [ + { + id: 'movie-1', + title: 'title-1', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'x' + }, + { + name: 'Ada', + uniqueString: 'y' + } + ] + } + }, + { + id: 'movie-2', + title: 'title-2', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'a' + }, + { + name: 'Ada', + uniqueString: 'b' + } + ] + } + } + ] + } + } + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Create node with nested @cypher input 2 levels down through same input type (experimental api)', t => { + const graphQLQuery = `mutation { + CreateUser( + data: { + idField: "a" + name: "Ada" + uniqueString: "b" + birthday: { year: 2020, month: 11, day: 10 } + names: ["A", "B"] + onUserCreate: { + int: 5 + nested: { createdAt: { datetime: { year: 2020, month: 11, day: 14 } } } + } + } + ) { + idField + name + uniqueString + birthday { + formatted + } + names + int + createdAt { + formatted + } + } + } + `, + expectedCypherQuery = ` + CREATE (\`user\`:\`User\` {idField:$data.idField,name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString}) + WITH * + +CALL { + WITH * + UNWIND $data.onUserCreate.nested.createdAt AS CreatedAt + SET user.int = $data.onUserCreate.int +SET user.createdAt = datetime(CreatedAt.datetime) + RETURN COUNT(*) AS _nested_createdAt_ +} + RETURN \`user\` { .idField , .name , .uniqueString ,birthday: { formatted: toString(\`user\`.birthday) }, .names , .int ,createdAt: { formatted: toString(\`user\`.createdAt) }} AS \`user\` + `, + expectedParams = { + first: -1, + offset: 0, + data: { + idField: 'a', + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + onUserCreate: { + nested: { + createdAt: { + datetime: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 14, + high: 0 + } + } + } + }, + int: { + low: 5, + high: 0 + } + } + } + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Merge node with @cypher nested 2 levels deep, skipping 1 non-@mutation input object (experimental api)', t => { + const graphQLQuery = `mutation { + MergeUser( + where: { + idField: "a" + } + data: { + name: "Ada" + uniqueString: "b" + birthday: { + year: 2020 + month: 11 + day: 10 + } + names: ["A", "B"] + liked: { + merge: [ + { + where: { + id: "movie-1" + } + data: { + title: "title-1" + likedBy: { + create: [ + { name: "Alan", uniqueString: "x" } + { name: "Ada", uniqueString: "y" } + ] + } + } + } + { + where: { + id: "movie-2" + } + data: { + title: "title-2" + likedBy: { + create: [ + { name: "Alan", uniqueString: "a" } + { name: "Ada", uniqueString: "b" } + ] + } + } + } + ] + } + } + ) { + idField + uniqueString + liked { + id + title + likedBy { + name + uniqueString + } + } + } + } + `, + expectedCypherQuery = `MERGE (\`user\`:\`User\`{idField:$where.idField}) +ON CREATE + SET \`user\` += {name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString} +ON MATCH + SET \`user\` += {name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString} + WITH * + +CALL { + WITH * + UNWIND $data.liked.merge AS MovieMerge + MERGE (movie: Movie { id: MovieMerge.where.id }) ON CREATE SET movie.title = MovieMerge.data.title MERGE (user)-[:RATING]->(movie) +WITH MovieMerge AS _MovieMerge, movie +CALL { + WITH * + UNWIND _MovieMerge.data.likedBy.create AS UserCreate + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString +}) + RETURN COUNT(*) AS _likedBy_create_ +} + RETURN COUNT(*) AS _liked_merge_ +}RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] } AS \`user\``, + expectedParams = { + where: { + idField: 'a' + }, + data: { + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + merge: [ + { + where: { + id: 'movie-1' + }, + data: { + title: 'title-1', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'x' + }, + { + name: 'Ada', + uniqueString: 'y' + } + ] + } + } + }, + { + where: { + id: 'movie-2' + }, + data: { + title: 'title-2', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'a' + }, + { + name: 'Ada', + uniqueString: 'b' + } + ] + } + } + } + ] + } + } + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Merge node mutation with complex @cypher nested 2 levels deep, skipping 1 non-@mutation input object (experimental api)', t => { + const graphQLQuery = `mutation { + MergeUser( + where: { + idField: "a" + } + data: { + name: "Ada" + uniqueString: "b" + birthday: { + year: 2020 + month: 11 + day: 10 + } + names: ["A", "B"] + liked: { + merge: [ + { + where: { + id: "movie-1" + } + data: { + title: "title-1" + likedBy: { + merge: [ + { + where: { + idField: "b" + } + data: { + name: "Alan" + uniqueString: "x" + } + } + { + where: { + idField: "c" + } + data: { + name: "Ada" + uniqueString: "y" + } + } + ] + } + } + } + { + where: { + id: "movie-2" + } + data: { + title: "title-2" + likedBy: { + merge: [ + { + where: { + idField: "b" + } + data: { + name: "Alan" + uniqueString: "x" + } + } + { + where: { + idField: "c" + } + data: { + name: "Ada" + uniqueString: "y" + } + } + ] + } + } + } + ] + } + } + ) { + idField + uniqueString + liked { + id + title + likedBy { + name + uniqueString + } + } + } + } + `, + expectedCypherQuery = `MERGE (\`user\`:\`User\`{idField:$where.idField}) +ON CREATE + SET \`user\` += {name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString} +ON MATCH + SET \`user\` += {name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString} + WITH * + +CALL { + WITH * + UNWIND $data.liked.merge AS MovieMerge + MERGE (movie: Movie { id: MovieMerge.where.id }) ON CREATE SET movie.title = MovieMerge.data.title MERGE (user)-[:RATING]->(movie) +WITH MovieMerge AS _MovieMerge, movie +CALL { + WITH * + UNWIND _MovieMerge.data.likedBy.merge AS UserMerge + MERGE (user: User { + idField: UserMerge.where.idField +}) +ON CREATE + SET user.name = UserMerge.data.name, + user.uniqueString = UserMerge.data.uniqueString +MERGE (movie)<-[:RATING]-(user) + RETURN COUNT(*) AS _likedBy_merge_ +} + RETURN COUNT(*) AS _liked_merge_ +}RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] } AS \`user\``, + expectedParams = { + where: { + idField: 'a' + }, + data: { + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + merge: [ + { + where: { + id: 'movie-1' + }, + data: { + title: 'title-1', + likedBy: { + merge: [ + { + where: { + idField: 'b' + }, + data: { + name: 'Alan', + uniqueString: 'x' + } + }, + { + where: { + idField: 'c' + }, + data: { + name: 'Ada', + uniqueString: 'y' + } + } + ] + } + } + }, + { + where: { + id: 'movie-2' + }, + data: { + title: 'title-2', + likedBy: { + merge: [ + { + where: { + idField: 'b' + }, + data: { + name: 'Alan', + uniqueString: 'x' + } + }, + { + where: { + idField: 'c' + }, + data: { + name: 'Ada', + uniqueString: 'y' + } + } + ] + } + } + } + ] + } + } + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Merge node mutation with @cypher nested 3 levels deep, skipping 2 non-@mutation input objects (experimental api)', t => { + const graphQLQuery = `mutation { + CreateUser( + data: { + idField: "a" + name: "Ada" + uniqueString: "b" + birthday: { year: 2020, month: 11, day: 10 } + names: ["A", "B"] + liked: { + nestedCreate: [ + { + customLayer: { + custom: "custom-1" + data: { + id: "movie-1" + title: "title-1" + likedBy: { + create: [ + { name: "Alan", uniqueString: "x" } + { name: "Ada", uniqueString: "y" } + ] + } + } + } + } + { + customLayer: { + custom: "custom-2" + data: { + id: "movie-2" + title: "title-2" + likedBy: { + create: [ + { name: "Alan", uniqueString: "a" } + { name: "Ada", uniqueString: "b" } + ] + } + } + } + } + ] + } + } + ) { + idField + uniqueString + liked { + id + title + custom + likedBy { + name + uniqueString + } + } + } + } + `, + expectedCypherQuery = ` + CREATE (\`user\`:\`User\` {idField:$data.idField,name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString}) + WITH * + +CALL { + WITH * + UNWIND $data.liked.nestedCreate AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { id: MovieCreate.customLayer.data.id, title: MovieCreate.customLayer.data.title, custom: MovieCreate.customLayer.custom }) +WITH MovieCreate AS _MovieCreate, movie +CALL { + WITH * + UNWIND _MovieCreate.customLayer.data.likedBy.create AS UserCreate + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString +}) + RETURN COUNT(*) AS _likedBy_create_ +} + RETURN COUNT(*) AS _liked_nestedCreate_ +} + RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title , .custom ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] } AS \`user\` + `, + expectedParams = { + first: -1, + offset: 0, + data: { + idField: 'a', + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + nestedCreate: [ + { + customLayer: { + custom: 'custom-1', + data: { + id: 'movie-1', + title: 'title-1', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'x' + }, + { + name: 'Ada', + uniqueString: 'y' + } + ] + } + } + } + }, + { + customLayer: { + custom: 'custom-2', + data: { + id: 'movie-2', + title: 'title-2', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'a' + }, + { + name: 'Ada', + uniqueString: 'b' + } + ] + } + } + } + } + ] + } + } + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Delete node mutation with @cypher deleting related node (experimental api)', t => { + const graphQLQuery = `mutation { + DeleteUser( + where: { + idField: "a", + } + liked: { + delete: { + id: "movie-1" + } + } + ) { + idField + } + } + `, + expectedCypherQuery = `MATCH (\`user\`:\`User\`) WHERE (\`user\`.idField = $where.idField) + +CALL { + WITH * + UNWIND $liked.delete AS MovieWhere + MATCH (user)-[:RATING]->(movie: Movie { id: MovieWhere.id }) +DETACH DELETE movie + RETURN COUNT(*) AS _liked_delete_ +} +WITH \`user\` AS \`user_toDelete\`, \`user\` { .idField } AS \`user\` +DETACH DELETE \`user_toDelete\` +RETURN \`user\``, + expectedParams = { + where: { + idField: 'a' + }, + liked: { + delete: [ + { + id: 'movie-1' + } + ] + }, + first: -1, + offset: 0 + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Sequence of custom nested @cypher (experimental api)', t => { + const graphQLQuery = `mutation { + MergeUser( + where: { + idField: "a" + } + data: { + name: "Ada" + uniqueString: "b" + birthday: { + year: 2020 + month: 11 + day: 10 + } + names: ["A", "B"] + liked: { + merge: [ + { + where: { + id: "movie-1" + } + data: { + title: "title-1" + likedBy: { + merge: [ + { + where: { + idField: "b" + } + data: { + name: "Alan" + uniqueString: "x" + } + } + { + where: { + idField: "c" + } + data: { + name: "Ada" + uniqueString: "y" + } + } + ] + } + } + } + { + where: { + id: "movie-2" + } + data: { + title: "title-2" + likedBy: { + merge: [ + { + where: { + idField: "b" + } + data: { + name: "Alan" + uniqueString: "x" + } + } + { + where: { + idField: "c" + } + data: { + name: "Ada" + uniqueString: "y" + } + } + ] + } + } + } + ] + create: [ + { + id: "movie-1" + title: "title-1" + likedBy: { + create: [ + { name: "Alan", uniqueString: "x" } + { name: "Ada", uniqueString: "y" } + ] + } + } + { + id: "movie-2" + title: "title-2" + likedBy: { + create: [ + { name: "Alan", uniqueString: "a" } + { name: "Ada", uniqueString: "b" } + ] + } + } + ] + } + } + ) { + idField + uniqueString + liked { + id + title + likedBy { + name + uniqueString + } + } + } + } + `, + expectedCypherQuery = `MERGE (\`user\`:\`User\`{idField:$where.idField}) +ON CREATE + SET \`user\` += {name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString} +ON MATCH + SET \`user\` += {name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString} + WITH * + +CALL { + WITH * + UNWIND $data.liked.create AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { id: MovieCreate.id, title: MovieCreate.title }) +WITH MovieCreate AS _MovieCreate, movie +CALL { + WITH * + UNWIND _MovieCreate.likedBy.create AS UserCreate + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString +}) + RETURN COUNT(*) AS _likedBy_create_ +} + RETURN COUNT(*) AS _liked_create_ +} + +CALL { + WITH * + UNWIND $data.liked.merge AS MovieMerge + MERGE (movie: Movie { id: MovieMerge.where.id }) ON CREATE SET movie.title = MovieMerge.data.title MERGE (user)-[:RATING]->(movie) +WITH MovieMerge AS _MovieMerge, movie +CALL { + WITH * + UNWIND _MovieMerge.data.likedBy.merge AS UserMerge + MERGE (user: User { + idField: UserMerge.where.idField +}) +ON CREATE + SET user.name = UserMerge.data.name, + user.uniqueString = UserMerge.data.uniqueString +MERGE (movie)<-[:RATING]-(user) + RETURN COUNT(*) AS _likedBy_merge_ +} + RETURN COUNT(*) AS _liked_merge_ +}RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] } AS \`user\``, + expectedParams = { + where: { + idField: 'a' + }, + data: { + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + create: [ + { + id: 'movie-1', + title: 'title-1', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'x' + }, + { + name: 'Ada', + uniqueString: 'y' + } + ] + } + }, + { + id: 'movie-2', + title: 'title-2', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'a' + }, + { + name: 'Ada', + uniqueString: 'b' + } + ] + } + } + ], + merge: [ + { + where: { + id: 'movie-1' + }, + data: { + title: 'title-1', + likedBy: { + merge: [ + { + where: { + idField: 'b' + }, + data: { + name: 'Alan', + uniqueString: 'x' + } + }, + { + where: { + idField: 'c' + }, + data: { + name: 'Ada', + uniqueString: 'y' + } + } + ] + } + } + }, + { + where: { + id: 'movie-2' + }, + data: { + title: 'title-2', + likedBy: { + merge: [ + { + where: { + idField: 'b' + }, + data: { + name: 'Alan', + uniqueString: 'x' + } + }, + { + where: { + idField: 'c' + }, + data: { + name: 'Ada', + uniqueString: 'y' + } + } + ] + } + } + } + ] + } + } + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Create node with multiple nested @cypher (experimental api)', t => { + const graphQLQuery = `mutation { + CreateUser( + data: { + idField: "a" + name: "Ada" + uniqueString: "b" + birthday: { year: 2020, month: 11, day: 10 } + names: ["A", "B"] + liked: { + create: [ + { + id: "movie-1" + title: "title-1" + likedBy: { + create: [ + { name: "Alan", uniqueString: "x" } + { name: "Ada", uniqueString: "y" } + ] + } + } + { + id: "movie-2" + title: "title-2" + likedBy: { + create: [ + { name: "Alan", uniqueString: "a" } + { name: "Ada", uniqueString: "b" } + ] + } + } + ] + } + onUserCreate: { + createdAt: { datetime: { year: 2020, month: 11, day: 13 } } + } + } + ) { + idField + uniqueString + liked { + id + title + likedBy { + name + uniqueString + } + } + createdAt { + formatted + } + } + } + `, + expectedCypherQuery = ` + CREATE (\`user\`:\`User\` {idField:$data.idField,name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString}) + WITH * + +CALL { + WITH * + UNWIND $data.liked.create AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { id: MovieCreate.id, title: MovieCreate.title }) +WITH MovieCreate AS _MovieCreate, movie +CALL { + WITH * + UNWIND _MovieCreate.likedBy.create AS UserCreate + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString +}) + RETURN COUNT(*) AS _likedBy_create_ +} + RETURN COUNT(*) AS _liked_create_ +} + +CALL { + WITH * + UNWIND $data.onUserCreate.createdAt AS CreatedAt + SET user.int = $data.onUserCreate.int +SET user.createdAt = datetime(CreatedAt.datetime) + RETURN COUNT(*) AS _onUserCreate_createdAt_ +} + RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] ,createdAt: { formatted: toString(\`user\`.createdAt) }} AS \`user\` + `, + expectedParams = { + first: -1, + offset: 0, + data: { + idField: 'a', + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + create: [ + { + id: 'movie-1', + title: 'title-1', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'x' + }, + { + name: 'Ada', + uniqueString: 'y' + } + ] + } + }, + { + id: 'movie-2', + title: 'title-2', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'a' + }, + { + name: 'Ada', + uniqueString: 'b' + } + ] + } + } + ] + }, + onUserCreate: { + createdAt: { + datetime: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 13, + high: 0 + } + } + } + } + } + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Merge node with multiple nested @cypher (experimental api)', t => { + const graphQLQuery = `mutation { + MergeUser( + where: { + idField: "a" + } + data: { + name: "Ada" + uniqueString: "b" + birthday: { year: 2020, month: 11, day: 10 } + names: ["A", "B"] + liked: { + create: [ + { + id: "movie-1" + title: "title-1" + likedBy: { + create: [ + { name: "Alan", uniqueString: "x" } + { name: "Ada", uniqueString: "y" } + ] + } + } + { + id: "movie-2" + title: "title-2" + likedBy: { + create: [ + { name: "Alan", uniqueString: "a" } + { name: "Ada", uniqueString: "b" } + ] + } + } + ] + } + onUserMerge: { + mergedAt: { datetime: { year: 2020, month: 11, day: 13 } } + } + } + ) { + idField + uniqueString + liked { + id + title + likedBy { + name + uniqueString + } + } + createdAt { + formatted + } + } + } + `, + expectedCypherQuery = `MERGE (\`user\`:\`User\`{idField:$where.idField}) +ON CREATE + SET \`user\` += {name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString} +ON MATCH + SET \`user\` += {name:$data.name,names:$data.names,birthday: datetime($data.birthday),uniqueString:$data.uniqueString} + WITH * + +CALL { + WITH * + UNWIND $data.liked.create AS MovieCreate + CREATE (user)-[:RATING]->(movie: Movie { id: MovieCreate.id, title: MovieCreate.title }) +WITH MovieCreate AS _MovieCreate, movie +CALL { + WITH * + UNWIND _MovieCreate.likedBy.create AS UserCreate + CREATE (movie)<-[:RATING]-(user:User { + name: UserCreate.name, + uniqueString: UserCreate.uniqueString +}) + RETURN COUNT(*) AS _likedBy_create_ +} + RETURN COUNT(*) AS _liked_create_ +} + +CALL { + WITH * + UNWIND $data.onUserMerge.mergedAt AS CreatedAt + SET user.modifiedAt = datetime(CreatedAt.datetime) + RETURN COUNT(*) AS _onUserMerge_mergedAt_ +}RETURN \`user\` { .idField , .uniqueString ,liked: [(\`user\`)-[:\`RATING\`]->(\`user_liked\`:\`Movie\`) | \`user_liked\` { .id , .title ,likedBy: [(\`user_liked\`)<-[:\`RATING\`]-(\`user_liked_likedBy\`:\`User\`) | \`user_liked_likedBy\` { .name , .uniqueString }] }] ,createdAt: { formatted: toString(\`user\`.createdAt) }} AS \`user\``, + expectedParams = { + where: { + idField: 'a' + }, + data: { + name: 'Ada', + names: ['A', 'B'], + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 10, + high: 0 + } + }, + uniqueString: 'b', + liked: { + create: [ + { + id: 'movie-1', + title: 'title-1', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'x' + }, + { + name: 'Ada', + uniqueString: 'y' + } + ] + } + }, + { + id: 'movie-2', + title: 'title-2', + likedBy: { + create: [ + { + name: 'Alan', + uniqueString: 'a' + }, + { + name: 'Ada', + uniqueString: 'b' + } + ] + } + } + ] + }, + onUserMerge: { + mergedAt: { + datetime: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 11, + high: 0 + }, + day: { + low: 13, + high: 0 + } + } + } + } + } + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Custom @cypher mutation with multiple nested @cypher (experimental api)', t => { + const graphQLQuery = `mutation { + Custom( + id: "a" + sideEffects: { + create: [ + { id: "b", nested: { create: [{ id: "d" }, { id: "e" }] } } + { id: "c", nested: { create: [{ id: "f" }, { id: "g" }] } } + ] + } + computed: { + computed: { + multiply: { + value: 5 + } + } + } + ) { + id + computed + nested { + id + nested { + id + } + } + } + } + `, + expectedCypherQuery = `CALL apoc.cypher.doIt("MERGE (custom: Custom { + id: $id +}) +RETURN custom", {id:$id, sideEffects:$sideEffects, computed:$computed, first:$first, offset:$offset, cypherParams: $cypherParams}) YIELD value + WITH apoc.map.values(value, [keys(value)[0]])[0] AS \`custom\` +CALL { + WITH * + UNWIND $sideEffects.create AS CustomData + MERGE (custom)-[:RELATED]->(subCustom: Custom { id: CustomData.id }) +WITH CustomData AS _CustomData, subCustom AS custom +CALL { + WITH * + UNWIND _CustomData.nested.create AS CustomData + MERGE (custom)-[:RELATED]->(subCustom: Custom { + id: CustomData.id +}) +WITH subCustom AS custom + RETURN COUNT(*) AS _nested_create_ +} + RETURN COUNT(*) AS _sideEffects_create_ +} + +CALL { + WITH * + UNWIND $computed.computed.multiply AS CustomComputedInput + SET custom.computed = CustomComputedInput.value * 10 + RETURN COUNT(*) AS _computed_multiply_ +} + RETURN \`custom\` { .id , .computed ,nested: [(\`custom\`)-[:\`RELATED\`]->(\`custom_nested\`:\`Custom\`) | \`custom_nested\` { .id ,nested: [(\`custom_nested\`)-[:\`RELATED\`]->(\`custom_nested_nested\`:\`Custom\`) | \`custom_nested_nested\` { .id }] }] } AS \`custom\``, + expectedParams = { + id: 'a', + sideEffects: { + create: [ + { + id: 'b', + nested: { + create: [ + { + id: 'd' + }, + { + id: 'e' + } + ] + } + }, + { + id: 'c', + nested: { + create: [ + { + id: 'f' + }, + { + id: 'g' + } + ] + } + } + ] + }, + computed: { + computed: { + multiply: { + value: { + low: 5, + high: 0 + } + } + } + }, + first: -1, + offset: 0, + cypherParams: CYPHER_PARAMS + }; + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +});