diff --git a/package.json b/package.json index 6d0a00b9..82b53ea4 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/**.test.js --verbose", + "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", "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/input-values.js b/src/augment/input-values.js index 9200b0af..3b519f03 100644 --- a/src/augment/input-values.js +++ b/src/augment/input-values.js @@ -333,7 +333,7 @@ const LogicalFilteringArgument = { /** * Builds the AST definitions for logical filtering arguments */ -const buildLogicalFilterInputValues = ({ typeName = '' }) => { +export const buildLogicalFilterInputValues = ({ typeName = '' }) => { return [ buildInputValue({ name: buildName({ name: LogicalFilteringArgument.AND }), @@ -361,7 +361,7 @@ const buildLogicalFilterInputValues = ({ typeName = '' }) => { /** * Builds the AST definitions for filtering Neo4j property type fields */ -const buildPropertyFilters = ({ +export const buildPropertyFilters = ({ field, fieldName = '', outputType = '', @@ -512,11 +512,10 @@ export const selectUnselectedOrderedFields = ({ ); const orderingArgumentFieldNames = Object.keys(orderedFieldNameMap); orderingArgumentFieldNames.forEach(orderedFieldName => { - if ( - !fieldSelectionSet.some( - field => field.name && field.name.value === orderedFieldName - ) - ) { + const orderedFieldAlreadySelected = fieldSelectionSet.some( + field => field.name && field.name.value === orderedFieldName + ); + if (!orderedFieldAlreadySelected) { // add the field so that its data can be used for ordering // since as it is not actually selected, it will be removed // by default GraphQL post-processing field resolvers diff --git a/src/augment/types/node/mutation.js b/src/augment/types/node/mutation.js index 3a9fdbbd..0746a19f 100644 --- a/src/augment/types/node/mutation.js +++ b/src/augment/types/node/mutation.js @@ -12,7 +12,11 @@ import { useAuthDirective, isCypherField } from '../../directives'; -import { getPrimaryKey } from './selection'; +import { + getPrimaryKey, + buildNodeSelectionInputType, + buildNodeSelectionInputTypes +} from './selection'; import { shouldAugmentType } from '../../augment'; import { OperationType } from '../../types/types'; import { @@ -48,6 +52,7 @@ export const augmentNodeMutationAPI = ({ propertyInputValues, generatedTypeMap, operationTypeMap, + typeDefinitionMap, typeExtensionDefinitionMap, config }) => { @@ -78,89 +83,28 @@ export const augmentNodeMutationAPI = ({ }); }); } - return [operationTypeMap, generatedTypeMap]; -}; - -/** - * Given the results of augmentNodeTypeFields, builds the AST - * definition for a Mutation operation field of a given - * NodeMutation name - */ -const buildNodeMutationField = ({ - mutationType, - mutationAction = '', - primaryKey, - typeName, - propertyInputValues, - operationTypeMap, - typeExtensionDefinitionMap, - config -}) => { - const mutationFields = mutationType.fields; - const mutationName = `${mutationAction}${typeName}`; - const mutationTypeName = mutationType ? mutationType.name.value : ''; - const mutationTypeExtensions = typeExtensionDefinitionMap[mutationTypeName]; - if ( - !getFieldDefinition({ - fields: mutationFields, - name: mutationName - }) && - !getTypeExtensionFieldDefinition({ - typeExtensions: mutationTypeExtensions, - name: typeName - }) - ) { - const mutationConfig = { - name: buildName({ name: mutationName }), - args: buildNodeMutationArguments({ - operationName: mutationAction, - primaryKey, - args: propertyInputValues - }), - type: buildNamedType({ - name: typeName - }), - directives: buildNodeMutationDirectives({ - mutationAction, - typeName, - config - }) - }; - let mutationField = undefined; - let mutationDescriptionUrl = ''; - if (mutationAction === NodeMutation.CREATE) { - mutationField = mutationConfig; - mutationDescriptionUrl = - '[creating](https://neo4j.com/docs/cypher-manual/4.1/clauses/create/#create-nodes)'; - } else if (mutationAction === NodeMutation.UPDATE) { - if (primaryKey && mutationConfig.args.length > 1) { - mutationField = mutationConfig; - mutationDescriptionUrl = - '[updating](https://neo4j.com/docs/cypher-manual/4.1/clauses/set/#set-update-a-property)'; - } - } else if (mutationAction === NodeMutation.MERGE) { - if (primaryKey) { - mutationField = mutationConfig; - mutationDescriptionUrl = - '[merging](https://neo4j.com/docs/cypher-manual/4.1/clauses/merge/#query-merge-node-derived)'; - } - } else if (mutationAction === NodeMutation.DELETE) { - if (primaryKey) { - mutationField = mutationConfig; - mutationDescriptionUrl = - '[deleting](https://neo4j.com/docs/cypher-manual/4.1/clauses/delete/#delete-delete-single-node)'; - } - } - if (mutationField) { - mutationField.description = buildDescription({ - value: `[Generated mutation](${GRANDSTACK_DOCS_SCHEMA_AUGMENTATION}/#${mutationAction.toLowerCase()}) for ${mutationDescriptionUrl} a ${typeName} node.`, - config - }); - mutationFields.push(buildField(mutationField)); - } - operationTypeMap[OperationType.MUTATION].fields = mutationFields; + if (config.experimental === true) { + generatedTypeMap = buildNodeSelectionInputTypes({ + definition, + typeName, + propertyInputValues, + generatedTypeMap, + typeDefinitionMap, + typeExtensionDefinitionMap, + config + }); + } else { + generatedTypeMap = buildNodeSelectionInputType({ + definition, + typeName, + propertyInputValues, + generatedTypeMap, + typeDefinitionMap, + typeExtensionDefinitionMap, + config + }); } - return operationTypeMap; + return [operationTypeMap, generatedTypeMap]; }; /** @@ -258,6 +202,145 @@ const buildNodeMutationArguments = ({ ); }; +const buildNodeMutationObjectArguments = ({ typeName, operationName = '' }) => { + const args = []; + const nodeSelectionConfig = { + name: 'where', + type: { + name: `_${typeName}Where`, + wrappers: { + [TypeWrappers.NON_NULL_NAMED_TYPE]: true + } + } + }; + const propertyInputConfig = { + name: 'data', + type: { + name: `_${typeName}Data`, + wrappers: { + [TypeWrappers.NON_NULL_NAMED_TYPE]: true + } + } + }; + if (operationName === NodeMutation.CREATE) { + args.push(propertyInputConfig); + } else if (operationName === NodeMutation.UPDATE) { + args.push(nodeSelectionConfig); + args.push(propertyInputConfig); + } else if (operationName === NodeMutation.MERGE) { + const keySelectionInputConfig = { + name: 'where', + type: { + name: `_${typeName}Keys`, + wrappers: { + [TypeWrappers.NON_NULL_NAMED_TYPE]: true + } + } + }; + args.push(keySelectionInputConfig); + args.push(propertyInputConfig); + } else if (operationName === NodeMutation.DELETE) { + args.push(nodeSelectionConfig); + } + return args.map(arg => + buildInputValue({ + name: buildName({ name: arg.name }), + type: buildNamedType(arg.type) + }) + ); +}; + +/** + * Given the results of augmentNodeTypeFields, builds the AST + * definition for a Mutation operation field of a given + * NodeMutation name + */ +const buildNodeMutationField = ({ + mutationType, + mutationAction = '', + primaryKey, + typeName, + propertyInputValues, + operationTypeMap, + typeExtensionDefinitionMap, + config +}) => { + const mutationFields = mutationType.fields; + const mutationName = `${mutationAction}${typeName}`; + const mutationTypeName = mutationType ? mutationType.name.value : ''; + const mutationTypeExtensions = typeExtensionDefinitionMap[mutationTypeName]; + if ( + !getFieldDefinition({ + fields: mutationFields, + name: mutationName + }) && + !getTypeExtensionFieldDefinition({ + typeExtensions: mutationTypeExtensions, + name: typeName + }) + ) { + let mutationArgs = []; + if (config.experimental === true) { + mutationArgs = buildNodeMutationObjectArguments({ + typeName, + operationName: mutationAction + }); + } else { + mutationArgs = buildNodeMutationArguments({ + operationName: mutationAction, + primaryKey, + args: propertyInputValues + }); + } + const mutationConfig = { + name: buildName({ name: mutationName }), + args: mutationArgs, + type: buildNamedType({ + name: typeName + }), + directives: buildNodeMutationDirectives({ + mutationAction, + typeName, + config + }) + }; + let mutationField = undefined; + let mutationDescriptionUrl = ''; + if (mutationAction === NodeMutation.CREATE) { + mutationField = mutationConfig; + mutationDescriptionUrl = + '[creating](https://neo4j.com/docs/cypher-manual/4.1/clauses/create/#create-nodes)'; + } else if (mutationAction === NodeMutation.UPDATE) { + if (primaryKey && mutationConfig.args.length > 1) { + mutationField = mutationConfig; + mutationDescriptionUrl = + '[updating](https://neo4j.com/docs/cypher-manual/4.1/clauses/set/#set-update-a-property)'; + } + } else if (mutationAction === NodeMutation.MERGE) { + if (primaryKey) { + mutationField = mutationConfig; + mutationDescriptionUrl = + '[merging](https://neo4j.com/docs/cypher-manual/4.1/clauses/merge/#query-merge-node-derived)'; + } + } else if (mutationAction === NodeMutation.DELETE) { + if (primaryKey) { + mutationField = mutationConfig; + mutationDescriptionUrl = + '[deleting](https://neo4j.com/docs/cypher-manual/4.1/clauses/delete/#delete-delete-single-node)'; + } + } + if (mutationField) { + mutationField.description = buildDescription({ + value: `[Generated mutation](${GRANDSTACK_DOCS_SCHEMA_AUGMENTATION}/#${mutationAction.toLowerCase()}) for ${mutationDescriptionUrl} a ${typeName} node.`, + config + }); + mutationFields.push(buildField(mutationField)); + } + operationTypeMap[OperationType.MUTATION].fields = mutationFields; + } + return operationTypeMap; +}; + /** * Builds the AST definitions for directive instances used by * generated node Mutation fields of NodeMutation names diff --git a/src/augment/types/node/node.js b/src/augment/types/node/node.js index 6879de52..1fba4df6 100644 --- a/src/augment/types/node/node.js +++ b/src/augment/types/node/node.js @@ -6,13 +6,10 @@ import { import { augmentNodeMutationAPI } from './mutation'; import { augmentRelationshipTypeField } from '../relationship/relationship'; import { augmentRelationshipMutationAPI } from '../relationship/mutation'; -import { shouldAugmentType } from '../../augment'; import { - TypeWrappers, unwrapNamedType, isPropertyTypeField, - buildNeo4jSystemIDField, - getTypeFields + buildNeo4jSystemIDField } from '../../fields'; import { FilteringArgument, @@ -33,13 +30,6 @@ import { validateFieldDirectives } from '../../directives'; import { - buildName, - buildNamedType, - buildInputObjectType, - buildInputValue -} from '../../ast'; -import { - OperationType, isNodeType, isRelationshipType, isQueryTypeDefinition, @@ -47,7 +37,6 @@ import { isObjectTypeExtensionDefinition, isInterfaceTypeExtensionDefinition } from '../../types/types'; -import { getPrimaryKey } from './selection'; import { ApolloError } from 'apollo-server-errors'; /** @@ -482,14 +471,7 @@ const augmentNodeTypeAPI = ({ propertyInputValues, generatedTypeMap, operationTypeMap, - typeExtensionDefinitionMap, - config - }); - generatedTypeMap = buildNodeSelectionInputType({ - definition, - typeName, - propertyInputValues, - generatedTypeMap, + typeDefinitionMap, typeExtensionDefinitionMap, config }); @@ -511,53 +493,3 @@ const augmentNodeTypeAPI = ({ }); return [typeDefinitionMap, generatedTypeMap, operationTypeMap]; }; - -/** - * Builds the AST definition of the node input object type used - * by relationship mutations for selecting the nodes of the - * relationship - */ - -const buildNodeSelectionInputType = ({ - definition, - typeName, - propertyInputValues, - generatedTypeMap, - typeExtensionDefinitionMap, - config -}) => { - const mutationTypeName = OperationType.MUTATION; - const mutationTypeNameLower = mutationTypeName.toLowerCase(); - if (shouldAugmentType(config, mutationTypeNameLower, typeName)) { - const fields = getTypeFields({ - typeName, - definition, - typeExtensionDefinitionMap - }); - const primaryKey = getPrimaryKey({ fields }); - const propertyInputName = `_${typeName}Input`; - if (primaryKey) { - const primaryKeyName = primaryKey.name.value; - const primaryKeyInputConfig = propertyInputValues.find( - field => field.name === primaryKeyName - ); - if (primaryKeyInputConfig) { - generatedTypeMap[propertyInputName] = buildInputObjectType({ - name: buildName({ name: propertyInputName }), - fields: [ - buildInputValue({ - name: buildName({ name: primaryKeyName }), - type: buildNamedType({ - name: primaryKeyInputConfig.type.name, - wrappers: { - [TypeWrappers.NON_NULL_NAMED_TYPE]: true - } - }) - }) - ] - }); - } - } - } - return generatedTypeMap; -}; diff --git a/src/augment/types/node/selection.js b/src/augment/types/node/selection.js index 5f8a1c8a..c7734524 100644 --- a/src/augment/types/node/selection.js +++ b/src/augment/types/node/selection.js @@ -4,7 +4,8 @@ import { isNeo4jTypeField, isNeo4jIDField, unwrapNamedType, - TypeWrappers + TypeWrappers, + getTypeFields } from '../../fields'; import { isCypherField, @@ -15,6 +16,18 @@ import { isIndexedField, validateFieldDirectives } from '../../directives'; +import { + buildName, + buildNamedType, + buildInputValue, + buildInputObjectType +} from '../../ast'; +import { shouldAugmentType } from '../../augment'; +import { OperationType } from '../../types/types'; +import { + buildPropertyFilters, + buildLogicalFilterInputValues +} from '../../input-values'; /** * Gets a single field for use as a primary key @@ -112,3 +125,207 @@ const inferPrimaryKey = (fields = []) => { } return pk; }; + +/** + * Builds the AST definition of the node input object type used + * by relationship mutations for selecting the nodes of the + * relationship + */ +export const buildNodeSelectionInputType = ({ + definition, + typeName, + propertyInputValues, + generatedTypeMap, + typeExtensionDefinitionMap, + config +}) => { + const mutationTypeName = OperationType.MUTATION; + const mutationTypeNameLower = mutationTypeName.toLowerCase(); + if (shouldAugmentType(config, mutationTypeNameLower, typeName)) { + const fields = getTypeFields({ + typeName, + definition, + typeExtensionDefinitionMap + }); + const primaryKey = getPrimaryKey({ fields }); + const propertyInputName = `_${typeName}Input`; + if (primaryKey) { + const primaryKeyName = primaryKey.name.value; + const primaryKeyInputConfig = propertyInputValues.find( + field => field.name === primaryKeyName + ); + if (primaryKeyInputConfig) { + generatedTypeMap[propertyInputName] = buildInputObjectType({ + name: buildName({ name: propertyInputName }), + fields: [ + buildInputValue({ + name: buildName({ name: primaryKeyName }), + type: buildNamedType({ + name: primaryKeyInputConfig.type.name, + wrappers: { + [TypeWrappers.NON_NULL_NAMED_TYPE]: true + } + }) + }) + ] + }); + } + } + } + return generatedTypeMap; +}; + +export const buildNodeSelectionInputTypes = ({ + definition, + typeName, + propertyInputValues, + generatedTypeMap, + typeDefinitionMap, + typeExtensionDefinitionMap, + config +}) => { + const mutationTypeName = OperationType.MUTATION; + const mutationTypeNameLower = mutationTypeName.toLowerCase(); + if (shouldAugmentType(config, mutationTypeNameLower, typeName)) { + const fields = getTypeFields({ + typeName, + definition, + typeExtensionDefinitionMap + }); + // Used by Create, Update, Merge + generatedTypeMap = buildNodeDataInputObject({ + typeName, + propertyInputValues, + generatedTypeMap + }); + // Used by Update, Delete + generatedTypeMap = buildNodeSelectionInputObject({ + typeName, + generatedTypeMap, + typeDefinitionMap, + fields + }); + // Used by Merge + generatedTypeMap = buildNodeKeySelectionInputObject({ + typeName, + generatedTypeMap, + fields + }); + } + return generatedTypeMap; +}; + +const buildNodeSelectionInputObject = ({ + typeName, + generatedTypeMap, + typeDefinitionMap, + fields +}) => { + let keyFields = getKeyFields({ fields }); + keyFields = keyFields.filter(field => { + const directives = field.directives; + return ( + isPrimaryKeyField({ directives }) || + isUniqueField({ directives }) || + isIndexedField({ directives }) + ); + }); + if (!keyFields.length) { + const primaryKey = getPrimaryKey({ fields }); + if (primaryKey) keyFields.push(primaryKey); + } + const propertyInputName = `_${typeName}Where`; + if (keyFields.length) { + const selectionArguments = buildLogicalFilterInputValues({ + typeName: propertyInputName + }); + keyFields.forEach(field => { + const fieldName = field.name.value; + const fieldType = field.type; + const unwrappedType = unwrapNamedType({ type: fieldType }); + const outputType = unwrappedType.name; + const outputDefinition = typeDefinitionMap[outputType]; + const outputKind = outputDefinition ? outputDefinition.kind : ''; + selectionArguments.push( + ...buildPropertyFilters({ + field, + fieldName, + outputType, + outputKind + }) + ); + }); + if (selectionArguments.length) { + generatedTypeMap[propertyInputName] = buildInputObjectType({ + name: buildName({ name: propertyInputName }), + fields: selectionArguments + }); + } + } + return generatedTypeMap; +}; +const buildNodeKeySelectionInputObject = ({ + typeName, + generatedTypeMap, + fields +}) => { + let keyFields = getKeyFields({ fields }); + keyFields = keyFields.filter(field => { + const directives = field.directives; + return ( + isPrimaryKeyField({ directives }) || + isUniqueField({ directives }) || + isIndexedField({ directives }) + ); + }); + if (!keyFields.length) { + const primaryKey = getPrimaryKey({ fields }); + if (primaryKey) keyFields.push(primaryKey); + } + if (keyFields.length) { + const propertyInputName = `_${typeName}Keys`; + const selectionArguments = keyFields.map(field => { + const fieldName = field.name.value; + const fieldType = field.type; + const unwrappedType = unwrapNamedType({ type: fieldType }); + const outputType = unwrappedType.name; + return buildInputValue({ + name: buildName({ name: fieldName }), + type: buildNamedType({ + name: outputType + }) + }); + }); + if (selectionArguments.length) { + generatedTypeMap[propertyInputName] = buildInputObjectType({ + name: buildName({ name: propertyInputName }), + fields: selectionArguments + }); + } + } + return generatedTypeMap; +}; + +const buildNodeDataInputObject = ({ + typeName, + propertyInputValues = [], + generatedTypeMap +}) => { + const propertyInputName = `_${typeName}Data`; + const inputValues = propertyInputValues.map(field => { + const { name, type } = field; + return buildInputValue({ + name: buildName({ name }), + type: buildNamedType({ + name: type.name + }) + }); + }); + if (inputValues.length) { + generatedTypeMap[propertyInputName] = buildInputObjectType({ + name: buildName({ name: propertyInputName }), + fields: inputValues + }); + } + return generatedTypeMap; +}; diff --git a/src/augment/types/relationship/mutation.js b/src/augment/types/relationship/mutation.js index e86f1667..25eefa85 100644 --- a/src/augment/types/relationship/mutation.js +++ b/src/augment/types/relationship/mutation.js @@ -179,7 +179,8 @@ const getRelatedNodeTypeDefinition = ({ const buildNodeSelectionArguments = ({ fromType, toType, - relationshipDirective + relationshipDirective, + config }) => { let fromFieldName = getDirectiveArgument({ directive: relationshipDirective, @@ -191,13 +192,19 @@ const buildNodeSelectionArguments = ({ }); if (!fromFieldName) fromFieldName = RelationshipDirectionField.FROM; if (!toFieldName) toFieldName = RelationshipDirectionField.TO; + let fromTypeName = `_${fromType}Input`; + let toTypeName = `_${toType}Input`; + if (config.experimental === true) { + fromTypeName = `_${fromType}Where`; + toTypeName = `_${toType}Where`; + } return [ buildInputValue({ name: buildName({ name: fromFieldName }), type: buildNamedType({ - name: `_${fromType}Input`, + name: fromTypeName, wrappers: { [TypeWrappers.NON_NULL_NAMED_TYPE]: true } @@ -208,7 +215,7 @@ const buildNodeSelectionArguments = ({ name: toFieldName }), type: buildNamedType({ - name: `_${toType}Input`, + name: toTypeName, wrappers: { [TypeWrappers.NON_NULL_NAMED_TYPE]: true } @@ -336,7 +343,8 @@ const buildRelationshipMutationField = ({ fromType, toType, propertyOutputFields, - outputType + outputType, + config }), directives: buildRelationshipMutationDirectives({ mutationAction, @@ -421,12 +429,14 @@ const buildRelationshipMutationArguments = ({ fromType, toType, propertyOutputFields, - outputType + outputType, + config }) => { const fieldArguments = buildNodeSelectionArguments({ relationshipDirective, fromType, - toType + toType, + config }); if ( (mutationAction === RelationshipMutation.CREATE || diff --git a/src/selections.js b/src/selections.js index 2ffde42d..1901187e 100644 --- a/src/selections.js +++ b/src/selections.js @@ -431,7 +431,6 @@ export function buildCypherSelection({ selectionFilters, neo4jTypeArgs, fieldsForTranslation, - schemaType, subSelection, skipLimit, commaIfTail, diff --git a/src/translate.js b/src/translate.js index c9e4c944..51de0d2f 100644 --- a/src/translate.js +++ b/src/translate.js @@ -205,7 +205,6 @@ export const relationFieldOnNodeType = ({ selectionFilters, neo4jTypeArgs, fieldsForTranslation, - schemaType, subSelection, skipLimit, commaIfTail, @@ -228,7 +227,6 @@ export const relationFieldOnNodeType = ({ derivedTypeMap, resolveInfo }); - const allParams = innerFilterParams(filterParams, neo4jTypeArgs); const queryParams = paramsToString( _.filter(allParams, param => { @@ -270,6 +268,13 @@ export const relationFieldOnNodeType = ({ resolveInfo }); + const [lhsOrdering, rhsOrdering] = translateNestedOrderingArgument({ + schemaType: innerSchemaType, + selections: fieldsForTranslation, + fieldSelectionSet, + filterParams + }); + let whereClauses = [ labelPredicate, ...neo4jTypeClauses, @@ -277,32 +282,9 @@ export const relationFieldOnNodeType = ({ ...filterPredicates ].filter(predicate => !!predicate); - const orderByParam = filterParams['orderBy']; - const usesTemporalOrdering = temporalOrderingFieldExists( - schemaType, - filterParams - ); - - const selectedFieldNames = fieldSelectionSet.reduce((fieldNames, field) => { - if (field.name) fieldNames.push(field.name.value); - return fieldNames; - }, []); - const neo4jTypeFieldSelections = buildOrderedNeo4jTypeSelections({ - schemaType: innerSchemaType, - selections: fieldsForTranslation, - usesTemporalOrdering, - selectedFieldNames - }); - tailParams.initial = `${initial}${fieldName}: ${ !isArrayType(fieldType) ? 'head(' : '' - }${ - orderByParam - ? usesTemporalOrdering - ? `[sortedElement IN apoc.coll.sortMulti(` - : `apoc.coll.sortMulti(` - : '' - }[(${safeVar(variableName)})${ + }${lhsOrdering}[(${safeVar(variableName)})${ isUnionTypeField ? `--` : `${ @@ -318,17 +300,9 @@ export const relationFieldOnNodeType = ({ ) ])}`}${queryParams})${ whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '' - } | ${mapProjection}]${ - orderByParam - ? `, [${buildSortMultiArgs(orderByParam)}])${ - usesTemporalOrdering - ? ` | sortedElement { .* ${ - neo4jTypeFieldSelections ? `, ${neo4jTypeFieldSelections}` : '' - }}]` - : `` - }` - : '' - }${!isArrayType(fieldType) ? ')' : ''}${skipLimit} ${commaIfTail}`; + } | ${mapProjection}]${rhsOrdering}${ + !isArrayType(fieldType) ? ')' : '' + }${skipLimit} ${commaIfTail}`; return [tailParams, subSelection]; }; @@ -385,43 +359,6 @@ export const relationTypeFieldOnNodeType = ({ ) { subSelection[1][filterParamKey] = serializedFilterParam[filterParamKey]; } - const orderByParam = filterParams['orderBy']; - const usesTemporalOrdering = temporalOrderingFieldExists( - innerSchemaType, - filterParams - ); - const selectedFieldNames = fieldSelectionSet.reduce((fieldNames, field) => { - if (field.name) fieldNames.push(field.name.value); - return fieldNames; - }, []); - const neo4jTypeFieldSelections = buildOrderedNeo4jTypeSelections({ - schemaType: innerSchemaType, - selections: fieldsForTranslation, - usesTemporalOrdering, - selectedFieldNames - }); - - const lhsOrdering = `${ - orderByParam - ? usesTemporalOrdering - ? `[sortedElement IN apoc.coll.sortMulti(` - : `apoc.coll.sortMulti(` - : '' - }`; - - const rhsOrdering = `${ - orderByParam - ? `, [${buildSortMultiArgs(orderByParam)}])${ - usesTemporalOrdering - ? ` | sortedElement { .* ${ - neo4jTypeFieldSelections - ? `, ${neo4jTypeFieldSelections}` - : '' - }}]` - : `` - }` - : '' - }`; const allParams = innerFilterParams(filterParams, neo4jTypeArgs); const queryParams = paramsToString( @@ -440,6 +377,13 @@ export const relationTypeFieldOnNodeType = ({ resolveInfo }); + const [lhsOrdering, rhsOrdering] = translateNestedOrderingArgument({ + schemaType: innerSchemaType, + selections: fieldsForTranslation, + fieldSelectionSet, + filterParams + }); + const fromTypeName = innerSchemaTypeRelation.from; const toTypeName = innerSchemaTypeRelation.to; const schemaTypeName = schemaType.name; @@ -687,47 +631,6 @@ const directedNodeTypeFieldOnRelationType = ({ if (isReflexiveRelationshipOutputType({ schemaType })) { isFromField = schemaType.astNode.fields[0].name.value === fieldName; isToField = schemaType.astNode.fields[1].name.value === fieldName; - const orderByParam = filterParams['orderBy']; - const usesTemporalOrdering = temporalOrderingFieldExists( - innerSchemaType, - filterParams - ); - const selectedFieldNames = fieldSelectionSet.reduce( - (fieldNames, field) => { - if (field.name) fieldNames.push(field.name.value); - return fieldNames; - }, - [] - ); - const neo4jTypeFieldSelections = buildOrderedNeo4jTypeSelections({ - schemaType: innerSchemaType, - selections: fieldsForTranslation, - usesTemporalOrdering, - selectedFieldNames - }); - - const lhsOrdering = `${ - orderByParam - ? usesTemporalOrdering - ? `[sortedElement IN apoc.coll.sortMulti(` - : `apoc.coll.sortMulti(` - : '' - }`; - - const rhsOrdering = `${ - orderByParam - ? `, [${buildSortMultiArgs(orderByParam)}])${ - usesTemporalOrdering - ? ` | sortedElement { .* ${ - neo4jTypeFieldSelections - ? `, ${neo4jTypeFieldSelections}` - : '' - }}]` - : `` - }` - : '' - }`; - const temporalFieldRelationshipVariableName = `${nestedVariable}_relation`; const neo4jTypeClauses = neo4jTypePredicateClauses( filterParams, @@ -762,6 +665,13 @@ const directedNodeTypeFieldOnRelationType = ({ resolveInfo }); + const [lhsOrdering, rhsOrdering] = translateNestedOrderingArgument({ + schemaType: innerSchemaType, + selections: fieldsForTranslation, + fieldSelectionSet, + filterParams + }); + const whereClauses = [ ...neo4jTypeClauses, ...filterPredicates, @@ -1555,7 +1465,8 @@ export const translateMutation = ({ context, variableName, typeName, - additionalLabels + additionalLabels, + typeMap }); } else if (isDeleteMutation(resolveInfo)) { [translation, translationParams] = nodeDelete({ @@ -1598,7 +1509,8 @@ export const translateMutation = ({ schemaType, additionalLabels, params, - context + context, + typeMap }); } } else if (isRemoveMutation(resolveInfo)) { @@ -1720,36 +1632,63 @@ const customMutation = ({ // Generated API // Node Create - Update - Delete const nodeCreate = ({ + resolveInfo, + schemaType, + selections, + params, + context, variableName, typeName, - selections, - schemaType, - resolveInfo, additionalLabels, - params, - context + typeMap }) => { const safeVariableName = safeVar(variableName); const safeLabelName = safeLabel([typeName, ...additionalLabels]); let statements = []; - const args = getMutationArguments(resolveInfo); + 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]; + if (dataArgument) { + // config.experimental + const unwrappedType = unwrapNamedType({ type: dataArgument.type }); + const name = unwrappedType.name; + 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; + // remove .params entry + delete params.params; + } else { + dataParams = params.params; + } + // use apoc.create.uuid() to set a default value for @id field, + // if no value for it is provided in dataParams statements = setPrimaryKeyValue({ args, statements, - params: params.params, + params: dataParams, primaryKey }); const paramStatements = buildCypherParameters({ args, statements, params, - paramKey: 'params', + paramKey, resolveInfo }); - + createStatement = `CREATE (${safeVariableName}:${safeLabelName} {${paramStatements.join( + ',' + )}})`; const [subQuery, subParams] = buildCypherSelection({ selections, variableName, @@ -1759,12 +1698,118 @@ const nodeCreate = ({ }); params = { ...params, ...subParams }; const query = ` - CREATE (${safeVariableName}:${safeLabelName} {${paramStatements.join(',')}}) + ${createStatement} RETURN ${safeVariableName} {${subQuery}} AS ${safeVariableName} `; return [query, params]; }; +const nodeMergeOrUpdate = ({ + resolveInfo, + variableName, + typeName, + selections, + schemaType, + additionalLabels, + params, + context, + typeMap +}) => { + const safeVariableName = safeVar(variableName); + const args = getMutationArguments(resolveInfo); + + const selectionArgument = args.find(arg => arg.name.value === 'where'); + const dataArgument = args.find(arg => arg.name.value === 'data'); + + const fieldMap = schemaType.getFields(); + const fields = Object.values(fieldMap).map(field => field.astNode); + const primaryKey = getPrimaryKey({ fields }); + const primaryKeyArgName = primaryKey.name.value; + + let cypherOperation = ''; + let safeLabelName = safeLabel(typeName); + if (isMergeMutation(resolveInfo)) { + safeLabelName = safeLabel([typeName, ...additionalLabels]); + cypherOperation = 'MERGE'; + } else if (isUpdateMutation(resolveInfo)) { + cypherOperation = 'MATCH'; + } + let query = ``; + let paramUpdateStatements = []; + if (selectionArgument && dataArgument) { + // config.experimental + // no need to use .params key in this argument design + params = params.params; + const inputTranslation = translateNodeInputArgument({ + dataArgument, + variableName, + params, + typeMap, + resolveInfo, + context + }); + if (isMergeMutation(resolveInfo)) { + const unwrappedType = unwrapNamedType({ type: selectionArgument.type }); + const name = unwrappedType.name; + const inputType = typeMap[name]; + const inputValues = inputType.getFields(); + const selectionArgs = Object.values(inputValues).map(arg => arg.astNode); + const selectionExpression = buildCypherParameters({ + args: selectionArgs, + params, + paramKey: 'where', + resolveInfo, + cypherParams: getCypherParams(context) + }); + query = `${cypherOperation} (${safeVariableName}:${safeLabelName}{${selectionExpression.join( + ',' + )}})${inputTranslation}\n`; + } else { + const [predicate, serializedFilter] = translateNodeSelectionArgument({ + variableName, + args, + params, + schemaType, + resolveInfo + }); + query = `${cypherOperation} (${safeVariableName}:${safeLabelName})${predicate}${inputTranslation}\n`; + params = { ...params, ...serializedFilter }; + } + } else { + const [primaryKeyParam, updateParams] = splitSelectionParameters( + params, + primaryKeyArgName, + 'params' + ); + paramUpdateStatements = buildCypherParameters({ + args, + params: updateParams, + paramKey: 'params', + resolveInfo, + cypherParams: getCypherParams(context) + }); + query = `${cypherOperation} (${safeVariableName}:${safeLabelName}{${primaryKeyArgName}: $params.${primaryKeyArgName}}) + `; + if (paramUpdateStatements.length > 0) { + query += `SET ${safeVariableName} += {${paramUpdateStatements.join( + ',' + )}} `; + } + if (!params.params) params.params = {}; + params.params[primaryKeyArgName] = primaryKeyParam[primaryKeyArgName]; + } + const [subQuery, subParams] = buildCypherSelection({ + selections, + variableName, + schemaType, + resolveInfo, + cypherParams: getCypherParams(context) + }); + params = { ...params, ...subParams }; + query += `RETURN ${safeVariableName} {${subQuery}} AS ${safeVariableName}`; + return [query, params]; +}; + const nodeDelete = ({ resolveInfo, selections, @@ -1780,18 +1825,21 @@ const nodeDelete = ({ const fields = Object.values(fieldMap).map(field => field.astNode); const primaryKey = getPrimaryKey({ fields }); const primaryKeyArgName = primaryKey.name.value; - const neo4jTypeArgs = getNeo4jTypeArguments(args); - const [primaryKeyParam] = splitSelectionParameters(params, primaryKeyArgName); - const neo4jTypeClauses = neo4jTypePredicateClauses( - primaryKeyParam, - safeVariableName, - neo4jTypeArgs - ); - let query = `MATCH (${safeVariableName}:${safeLabelName}${ - neo4jTypeClauses.length > 0 - ? `) WHERE ${neo4jTypeClauses.join(' AND ')}` - : ` {${primaryKeyArgName}: $${primaryKeyArgName}})` - }`; + let matchStatement = ``; + const selectionArgument = args.find(arg => arg.name.value === 'where'); + if (selectionArgument) { + const [predicate, serializedFilter] = translateNodeSelectionArgument({ + variableName, + args, + params, + schemaType, + resolveInfo + }); + matchStatement = `MATCH (${safeVariableName}:${safeLabelName})${predicate}`; + params = { ...params, ...serializedFilter }; + } else { + matchStatement = `MATCH (${safeVariableName}:${safeLabelName} {${primaryKeyArgName}: $${primaryKeyArgName}})`; + } const [subQuery, subParams] = buildCypherSelection({ selections, variableName, @@ -1802,13 +1850,69 @@ const nodeDelete = ({ 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 - query += ` + const query = `${matchStatement} WITH ${safeVariableName} AS ${deletionVariableName}, ${safeVariableName} {${subQuery}} AS ${safeVariableName} DETACH DELETE ${deletionVariableName} RETURN ${safeVariableName}`; return [query, params]; }; +const translateNodeInputArgument = ({ + dataArgument = {}, + variableName, + params, + typeMap, + resolveInfo, + context +}) => { + const unwrappedType = unwrapNamedType({ type: dataArgument.type }); + const name = unwrappedType.name; + const inputType = typeMap[name]; + const inputValues = inputType.getFields(); + const updateArgs = Object.values(inputValues).map(arg => arg.astNode); + let translation = ''; + const paramUpdateStatements = buildCypherParameters({ + args: updateArgs, + params, + paramKey: 'data', + resolveInfo, + cypherParams: getCypherParams(context) + }); + if (paramUpdateStatements.length > 0) { + translation = `\nSET ${safeVar( + variableName + )} += {${paramUpdateStatements.join(',')}} `; + } + return translation; +}; + +const translateNodeSelectionArgument = ({ + variableName, + args, + params, + schemaType, + resolveInfo +}) => { + const [filterPredicates, serializedFilter] = processFilterArgument({ + argumentName: 'where', + fieldArgs: args, + schemaType, + variableName, + resolveInfo, + params + }); + const predicateClauses = [...filterPredicates] + .filter(predicate => !!predicate) + .join(' AND '); + let predicate = ``; + if (isMergeMutation(resolveInfo)) { + predicate = predicateClauses; + } else { + predicate = predicateClauses ? ` WHERE ${predicateClauses} ` : ''; + } + return [predicate, serializedFilter]; +}; + // Relation Add / Remove const relationshipCreate = ({ resolveInfo, @@ -1853,8 +1957,9 @@ const relationshipCreate = ({ const typeMap = resolveInfo.schema.getTypeMap(); const fromType = fromTypeArg.value.value; + const fromSchemaType = resolveInfo.schema.getType(fromType); const fromAdditionalLabels = getAdditionalLabels( - resolveInfo.schema.getType(fromType), + fromSchemaType, cypherParams ); const fromLabel = safeLabel([fromType, ...fromAdditionalLabels]); @@ -1863,27 +1968,24 @@ const relationshipCreate = ({ const fromVar = `${lowFirstLetter(fromType)}_${fromArgName}`; const fromVariable = safeVar(fromVar); const fromInputArg = firstArg.type; - const fromInputAst = - typeMap[getNamedType(fromInputArg).type.name.value].astNode; + const fromInputArgType = getNamedType(fromInputArg).type.name.value; + const fromInputAst = typeMap[fromInputArgType].astNode; const fromFields = fromInputAst.fields; const fromCypherParam = fromFields[0].name.value; - const fromNodeNeo4jTypeArgs = getNeo4jTypeArguments(fromFields); const toType = toTypeArg.value.value; - const toAdditionalLabels = getAdditionalLabels( - resolveInfo.schema.getType(toType), - cypherParams - ); + const toSchemaType = resolveInfo.schema.getType(toType); + const toAdditionalLabels = getAdditionalLabels(toSchemaType, cypherParams); const toLabel = safeLabel([toType, ...toAdditionalLabels]); const secondArg = args[1]; const toArgName = secondArg.name.value; const toVar = `${lowFirstLetter(toType)}_${toArgName}`; const toVariable = safeVar(toVar); const toInputArg = secondArg.type; - const toInputAst = typeMap[getNamedType(toInputArg).type.name.value].astNode; + const toInputArgType = getNamedType(toInputArg).type.name.value; + const toInputAst = typeMap[toInputArgType].astNode; const toFields = toInputAst.fields; const toCypherParam = toFields[0].name.value; - const toNodeNeo4jTypeArgs = getNeo4jTypeArguments(toFields); const relationshipName = relationshipNameArg.value.value; const lowercased = relationshipName.toLowerCase(); @@ -1895,20 +1997,6 @@ const relationshipCreate = ({ ? typeMap[getNamedType(dataInputArg.type).type.name.value].astNode : undefined; const dataFields = dataInputAst ? dataInputAst.fields : []; - - const fromNodeNeo4jTypeClauses = neo4jTypePredicateClauses( - params.from, - fromVariable, - fromNodeNeo4jTypeArgs, - fromArgName - ); - const toNodeNeo4jTypeClauses = neo4jTypePredicateClauses( - params.to, - toVariable, - toNodeNeo4jTypeArgs, - toArgName - ); - const [subQuery, subParams] = buildCypherSelection({ selections, schemaType, @@ -1922,7 +2010,48 @@ const relationshipCreate = ({ }, cypherParams: getCypherParams(context) }); - + let nodeSelectionStatements = ``; + const fromUsesWhereInput = + fromInputArgType.startsWith('_') && fromInputArgType.endsWith('Where'); + const toUsesWhereInput = + toInputArgType.startsWith('_') && toInputArgType.endsWith('Where'); + if (fromUsesWhereInput && toUsesWhereInput) { + const [fromPredicate, serializedFromFilter] = processFilterArgument({ + argumentName: fromArgName, + variableName: fromVar, + schemaType: fromSchemaType, + fieldArgs: args, + resolveInfo, + params + }); + const fromClauses = [...fromPredicate] + .filter(predicate => !!predicate) + .join(' AND '); + const [toPredicate, serializedToFilter] = processFilterArgument({ + argumentName: toArgName, + variableName: toVar, + schemaType: toSchemaType, + fieldArgs: args, + resolveInfo, + params + }); + const toClauses = [...toPredicate] + .filter(predicate => !!predicate) + .join(' AND '); + const sourceNodeSelectionPredicate = fromClauses + ? ` WHERE ${fromClauses} ` + : ''; + const targetNodeSelectionPredicate = toClauses + ? ` WHERE ${toClauses} ` + : ''; + params = { ...params, ...serializedFromFilter }; + params = { ...params, ...serializedToFilter }; + nodeSelectionStatements = `MATCH (${fromVariable}:${fromLabel})${sourceNodeSelectionPredicate} + MATCH (${toVariable}:${toLabel})${targetNodeSelectionPredicate}`; + } else { + nodeSelectionStatements = `MATCH (${fromVariable}:${fromLabel} {${fromCypherParam}: $${fromArgName}.${fromCypherParam}}) + MATCH (${toVariable}:${toLabel} {${toCypherParam}: $${toArgName}.${toCypherParam}})`; + } const paramStatements = buildCypherParameters({ args: dataFields, params, @@ -1931,19 +2060,7 @@ const relationshipCreate = ({ }); params = { ...params, ...subParams }; let query = ` - MATCH (${fromVariable}:${fromLabel}${ - fromNodeNeo4jTypeClauses && fromNodeNeo4jTypeClauses.length > 0 - ? // uses either a WHERE clause for managed type primary keys (temporal, etc.) - `) WHERE ${fromNodeNeo4jTypeClauses.join(' AND ')} ` - : // or a an internal matching clause for normal, scalar property primary keys - // NOTE this will need to change if we at some point allow for multi field node selection - ` {${fromCypherParam}: $${fromArgName}.${fromCypherParam}})` - } - MATCH (${toVariable}:${toLabel}${ - toNodeNeo4jTypeClauses && toNodeNeo4jTypeClauses.length > 0 - ? `) WHERE ${toNodeNeo4jTypeClauses.join(' AND ')} ` - : ` {${toCypherParam}: $${toArgName}.${toCypherParam}})` - } + ${nodeSelectionStatements} CREATE (${fromVariable})-[${relationshipVariable}:${relationshipLabel}${ paramStatements.length > 0 ? ` {${paramStatements.join(',')}}` : '' }]->(${toVariable}) @@ -1996,6 +2113,7 @@ const relationshipDelete = ({ const typeMap = resolveInfo.schema.getTypeMap(); const fromType = fromTypeArg.value.value; + const fromSchemaType = resolveInfo.schema.getType(fromType); const fromAdditionalLabels = getAdditionalLabels( resolveInfo.schema.getType(fromType), cypherParams @@ -2006,13 +2124,13 @@ const relationshipDelete = ({ const fromVar = `${lowFirstLetter(fromType)}_${fromArgName}`; const fromVariable = safeVar(fromVar); const fromInputArg = firstArg.type; - const fromInputAst = - typeMap[getNamedType(fromInputArg).type.name.value].astNode; + const fromInputArgType = getNamedType(fromInputArg).type.name.value; + const fromInputAst = typeMap[fromInputArgType].astNode; const fromFields = fromInputAst.fields; const fromCypherParam = fromFields[0].name.value; - const fromNodeNeo4jTypeArgs = getNeo4jTypeArguments(fromFields); const toType = toTypeArg.value.value; + const toSchemaType = resolveInfo.schema.getType(toType); const toAdditionalLabels = getAdditionalLabels( resolveInfo.schema.getType(toType), cypherParams @@ -2024,27 +2142,56 @@ const relationshipDelete = ({ const toVariable = safeVar(toVar); const toInputArg = secondArg.type; - const toInputAst = typeMap[getNamedType(toInputArg).type.name.value].astNode; + const toInputArgType = getNamedType(toInputArg).type.name.value; + const toInputAst = typeMap[toInputArgType].astNode; const toFields = toInputAst.fields; const toCypherParam = toFields[0].name.value; - const toNodeNeo4jTypeArgs = getNeo4jTypeArguments(toFields); const relationshipName = relationshipNameArg.value.value; const relationshipVariable = safeVar(fromVar + toVar); const relationshipLabel = safeLabel(relationshipName); - - const fromNodeNeo4jTypeClauses = neo4jTypePredicateClauses( - params.from, - fromVariable, - fromNodeNeo4jTypeArgs, - fromArgName - ); - const toNodeNeo4jTypeClauses = neo4jTypePredicateClauses( - params.to, - toVariable, - toNodeNeo4jTypeArgs, - toArgName - ); + let nodeSelectionStatements = ``; + const fromUsesWhereInput = + fromInputArgType.startsWith('_') && fromInputArgType.endsWith('Where'); + const toUsesWhereInput = + toInputArgType.startsWith('_') && toInputArgType.endsWith('Where'); + if (fromUsesWhereInput && toUsesWhereInput) { + const [fromPredicate, serializedFromFilter] = processFilterArgument({ + argumentName: fromArgName, + variableName: fromVar, + schemaType: fromSchemaType, + fieldArgs: args, + resolveInfo, + params + }); + const fromClauses = [...fromPredicate] + .filter(predicate => !!predicate) + .join(' AND '); + const [toPredicate, serializedToFilter] = processFilterArgument({ + argumentName: toArgName, + variableName: toVar, + schemaType: toSchemaType, + fieldArgs: args, + resolveInfo, + params + }); + const toClauses = [...toPredicate] + .filter(predicate => !!predicate) + .join(' AND '); + const sourceNodeSelectionPredicate = fromClauses + ? ` WHERE ${fromClauses} ` + : ''; + const targetNodeSelectionPredicate = toClauses + ? ` WHERE ${toClauses} ` + : ''; + params = { ...params, ...serializedFromFilter }; + params = { ...params, ...serializedToFilter }; + nodeSelectionStatements = `MATCH (${fromVariable}:${fromLabel})${sourceNodeSelectionPredicate} + MATCH (${toVariable}:${toLabel})${targetNodeSelectionPredicate}`; + } else { + nodeSelectionStatements = `MATCH (${fromVariable}:${fromLabel} {${fromCypherParam}: $${fromArgName}.${fromCypherParam}}) + MATCH (${toVariable}:${toLabel} {${toCypherParam}: $${toArgName}.${toCypherParam}})`; + } const [subQuery, subParams] = buildCypherSelection({ selections, @@ -2058,22 +2205,8 @@ const relationshipDelete = ({ }, cypherParams: getCypherParams(context) }); - - params = { ...params, ...subParams }; - let query = ` - MATCH (${fromVariable}:${fromLabel}${ - fromNodeNeo4jTypeClauses && fromNodeNeo4jTypeClauses.length > 0 - ? // uses either a WHERE clause for managed type primary keys (temporal, etc.) - `) WHERE ${fromNodeNeo4jTypeClauses.join(' AND ')} ` - : // or a an internal matching clause for normal, scalar property primary keys - // NOTE this will need to change if we at some point allow for multi field node selection - ` {${fromCypherParam}: $${fromArgName}.${fromCypherParam}})` - } - MATCH (${toVariable}:${toLabel}${ - toNodeNeo4jTypeClauses && toNodeNeo4jTypeClauses.length > 0 - ? `) WHERE ${toNodeNeo4jTypeClauses.join(' AND ')} ` - : ` {${toCypherParam}: $${toArgName}.${toCypherParam}})` - } + const query = ` + ${nodeSelectionStatements} OPTIONAL MATCH (${fromVariable})-[${relationshipVariable}:${relationshipLabel}]->(${toVariable}) DELETE ${relationshipVariable} WITH COUNT(*) AS scope, ${fromVariable} AS ${safeVar( @@ -2081,6 +2214,7 @@ const relationshipDelete = ({ )}, ${toVariable} AS ${safeVar(`_${toVar}`)} RETURN {${subQuery}} AS ${schemaTypeName}; `; + params = { ...params, ...subParams }; return [query, params]; }; @@ -2119,6 +2253,7 @@ const relationshipMergeOrUpdate = ({ const typeMap = resolveInfo.schema.getTypeMap(); const fromType = fromTypeArg.value.value; + const fromSchemaType = resolveInfo.schema.getType(fromType); const fromAdditionalLabels = getAdditionalLabels( resolveInfo.schema.getType(fromType), cypherParams @@ -2129,13 +2264,13 @@ const relationshipMergeOrUpdate = ({ const fromVar = `${lowFirstLetter(fromType)}_${fromArgName}`; const fromVariable = safeVar(fromVar); const fromInputArg = firstArg.type; - const fromInputAst = - typeMap[getNamedType(fromInputArg).type.name.value].astNode; + const fromInputArgType = getNamedType(fromInputArg).type.name.value; + const fromInputAst = typeMap[fromInputArgType].astNode; const fromFields = fromInputAst.fields; const fromCypherParam = fromFields[0].name.value; - const fromNodeNeo4jTypeArgs = getNeo4jTypeArguments(fromFields); const toType = toTypeArg.value.value; + const toSchemaType = resolveInfo.schema.getType(toType); const toAdditionalLabels = getAdditionalLabels( resolveInfo.schema.getType(toType), cypherParams @@ -2146,11 +2281,10 @@ const relationshipMergeOrUpdate = ({ const toVar = `${lowFirstLetter(toType)}_${toArgName}`; const toVariable = safeVar(toVar); const toInputArg = secondArg.type; - const toInputAst = - typeMap[getNamedType(toInputArg).type.name.value].astNode; + const toInputArgType = getNamedType(toInputArg).type.name.value; + const toInputAst = typeMap[toInputArgType].astNode; const toFields = toInputAst.fields; const toCypherParam = toFields[0].name.value; - const toNodeNeo4jTypeArgs = getNeo4jTypeArguments(toFields); const relationshipName = relationshipNameArg.value.value; const lowercased = relationshipName.toLowerCase(); @@ -2163,18 +2297,48 @@ const relationshipMergeOrUpdate = ({ : undefined; const dataFields = dataInputAst ? dataInputAst.fields : []; - const fromNodeNeo4jTypeClauses = neo4jTypePredicateClauses( - params.from, - fromVariable, - fromNodeNeo4jTypeArgs, - fromArgName - ); - const toNodeNeo4jTypeClauses = neo4jTypePredicateClauses( - params.to, - toVariable, - toNodeNeo4jTypeArgs, - toArgName - ); + let nodeSelectionStatements = ``; + const fromUsesWhereInput = + fromInputArgType.startsWith('_') && fromInputArgType.endsWith('Where'); + const toUsesWhereInput = + toInputArgType.startsWith('_') && toInputArgType.endsWith('Where'); + if (fromUsesWhereInput && toUsesWhereInput) { + const [fromPredicate, serializedFromFilter] = processFilterArgument({ + argumentName: fromArgName, + variableName: fromVar, + schemaType: fromSchemaType, + fieldArgs: args, + resolveInfo, + params + }); + const fromClauses = [...fromPredicate] + .filter(predicate => !!predicate) + .join(' AND '); + const [toPredicate, serializedToFilter] = processFilterArgument({ + argumentName: toArgName, + variableName: toVar, + schemaType: toSchemaType, + fieldArgs: args, + resolveInfo, + params + }); + const toClauses = [...toPredicate] + .filter(predicate => !!predicate) + .join(' AND '); + const sourceNodeSelectionPredicate = fromClauses + ? ` WHERE ${fromClauses} ` + : ''; + const targetNodeSelectionPredicate = toClauses + ? ` WHERE ${toClauses} ` + : ''; + params = { ...params, ...serializedFromFilter }; + params = { ...params, ...serializedToFilter }; + nodeSelectionStatements = ` MATCH (${fromVariable}:${fromLabel})${sourceNodeSelectionPredicate} + MATCH (${toVariable}:${toLabel})${targetNodeSelectionPredicate}`; + } else { + nodeSelectionStatements = ` MATCH (${fromVariable}:${fromLabel} {${fromCypherParam}: $${fromArgName}.${fromCypherParam}}) + MATCH (${toVariable}:${toLabel} {${toCypherParam}: $${toArgName}.${toCypherParam}})`; + } const [subQuery, subParams] = buildCypherSelection({ selections, @@ -2204,21 +2368,8 @@ const relationshipMergeOrUpdate = ({ cypherOperation = 'MATCH'; } - params = { ...params, ...subParams }; query = ` - MATCH (${fromVariable}:${fromLabel}${ - fromNodeNeo4jTypeClauses && fromNodeNeo4jTypeClauses.length > 0 - ? // uses either a WHERE clause for managed type primary keys (temporal, etc.) - `) WHERE ${fromNodeNeo4jTypeClauses.join(' AND ')} ` - : // or a an internal matching clause for normal, scalar property primary keys - // NOTE this will need to change if we at some point allow for multi field node selection - ` {${fromCypherParam}: $${fromArgName}.${fromCypherParam}})` - } - MATCH (${toVariable}:${toLabel}${ - toNodeNeo4jTypeClauses && toNodeNeo4jTypeClauses.length > 0 - ? `) WHERE ${toNodeNeo4jTypeClauses.join(' AND ')} ` - : ` {${toCypherParam}: $${toArgName}.${toCypherParam}})` - } + ${nodeSelectionStatements} ${cypherOperation} (${fromVariable})-[${relationshipVariable}:${relationshipLabel}]->(${toVariable})${ paramStatements.length > 0 ? ` @@ -2227,89 +2378,29 @@ const relationshipMergeOrUpdate = ({ } RETURN ${relationshipVariable} { ${subQuery} } AS ${schemaTypeName}; `; + params = { ...params, ...subParams }; } return [query, params]; }; -const nodeMergeOrUpdate = ({ - resolveInfo, - variableName, - typeName, - selections, - schemaType, - additionalLabels, - params, - context -}) => { - const safeVariableName = safeVar(variableName); - const args = getMutationArguments(resolveInfo); - const fieldMap = schemaType.getFields(); - const fields = Object.values(fieldMap).map(field => field.astNode); - const primaryKey = getPrimaryKey({ fields }); - const primaryKeyArgName = primaryKey.name.value; - const neo4jTypeArgs = getNeo4jTypeArguments(args); - const [primaryKeyParam, updateParams] = splitSelectionParameters( - params, - primaryKeyArgName, - 'params' - ); - const neo4jTypeClauses = neo4jTypePredicateClauses( - primaryKeyParam, - safeVariableName, - neo4jTypeArgs, - 'params' - ); - const predicateClauses = [...neo4jTypeClauses] - .filter(predicate => !!predicate) - .join(' AND '); - const predicate = predicateClauses ? `WHERE ${predicateClauses} ` : ''; - let paramUpdateStatements = buildCypherParameters({ - args, - params: updateParams, - paramKey: 'params', - resolveInfo, - cypherParams: getCypherParams(context) - }); - let cypherOperation = ''; - let safeLabelName = safeLabel(typeName); - if (isMergeMutation(resolveInfo)) { - safeLabelName = safeLabel([typeName, ...additionalLabels]); - cypherOperation = 'MERGE'; - } else if (isUpdateMutation(resolveInfo)) { - cypherOperation = 'MATCH'; - } - let query = `${cypherOperation} (${safeVariableName}:${safeLabelName}${ - predicate !== '' - ? `) ${predicate} ` - : `{${primaryKeyArgName}: $params.${primaryKeyArgName}})` - } - `; - if (paramUpdateStatements.length > 0) { - query += `SET ${safeVariableName} += {${paramUpdateStatements.join(',')}} `; - } - const [subQuery, subParams] = buildCypherSelection({ - selections, - variableName, - schemaType, - resolveInfo, - cypherParams: getCypherParams(context) - }); - if (!params.params) params.params = {}; - params.params[primaryKeyArgName] = primaryKeyParam[primaryKeyArgName]; - params = { ...params, ...subParams }; - query += `RETURN ${safeVariableName} {${subQuery}} AS ${safeVariableName}`; - return [query, params]; -}; - -const buildOrderedNeo4jTypeSelections = ({ +const translateNestedOrderingArgument = ({ schemaType, selections, - usesTemporalOrdering, - selectedFieldNames + fieldSelectionSet, + filterParams }) => { - let neo4jTypeSelections = ''; + const orderByParam = filterParams['orderBy']; + const usesTemporalOrdering = temporalOrderingFieldExists( + schemaType, + filterParams + ); + const selectedFieldNames = fieldSelectionSet.reduce((fieldNames, field) => { + if (field.name) fieldNames.push(field.name.value); + return fieldNames; + }, []); + let neo4jTypeFieldSelections = ''; if (usesTemporalOrdering) { - neo4jTypeSelections = selections + neo4jTypeFieldSelections = selections .reduce((temporalTypeFields, innerSelection) => { // name of temporal type field const fieldName = innerSelection.name.value; @@ -2322,7 +2413,6 @@ const buildOrderedNeo4jTypeSelections = ({ const innerSelectedTypes = innerSelection.selectionSet ? innerSelection.selectionSet.selections : []; - temporalTypeFields.push( `${fieldName}: {${innerSelectedTypes .reduce((temporalSubFields, t) => { @@ -2346,7 +2436,21 @@ const buildOrderedNeo4jTypeSelections = ({ }, []) .join(','); } - return neo4jTypeSelections; + const lhsOrdering = orderByParam + ? usesTemporalOrdering + ? `[sortedElement IN apoc.coll.sortMulti(` + : `apoc.coll.sortMulti(` + : ''; + const rhsOrdering = orderByParam + ? `, [${buildSortMultiArgs(orderByParam)}])${ + usesTemporalOrdering + ? ` | sortedElement { .* ${ + neo4jTypeFieldSelections ? `, ${neo4jTypeFieldSelections}` : '' + }}]` + : `` + }` + : ''; + return [lhsOrdering, rhsOrdering]; }; const getFieldTypeName = (schemaType, fieldName) => { @@ -2382,6 +2486,7 @@ const buildSortMultiArgs = param => { }; const processFilterArgument = ({ + argumentName = 'filter', fieldArgs, isFederatedOperation, schemaType, @@ -2392,9 +2497,12 @@ const processFilterArgument = ({ parentSchemaType, rootIsRelationType = false }) => { - const filterArg = fieldArgs.find(e => e.name.value === 'filter'); - const filterValue = Object.keys(params).length ? params['filter'] : undefined; - const filterParamKey = paramIndex > 1 ? `${paramIndex - 1}_filter` : `filter`; + const filterArg = fieldArgs.find(e => e.name.value === argumentName); + const filterValue = Object.keys(params).length + ? params[argumentName] + : undefined; + const filterParamKey = + paramIndex > 1 ? `${paramIndex - 1}_${argumentName}` : argumentName; const filterCypherParam = `$${filterParamKey}`; let translations = []; // allows an exception for the existence of the filter argument AST @@ -3735,6 +3843,7 @@ const translateNeo4jTypeFilter = ({ nullFieldPredicate, rootPredicateFunction, cypherTypeConstructor, + parentIsListArgument: isListFilterArgument, isTemporalFilter, isSpatialFilter }); @@ -3745,6 +3854,7 @@ const buildNeo4jTypeTranslation = ({ listVariable, isTemporalFilter, isSpatialFilter, + parentIsListArgument, isListFilterArgument, filterValue, nullFieldPredicate, @@ -3766,7 +3876,7 @@ const buildNeo4jTypeTranslation = ({ !filterOperationType || filterOperationType === 'not'; if ( (isTemporalFilter || isSpatialFilter) && - (isIdentityFilter || isListFilterArgument) + (isIdentityFilter || isListFilterArgument || parentIsListArgument) ) { const generalizedComparisonPredicates = Object.keys(filterValue).map( filterName => { @@ -3810,6 +3920,7 @@ const buildNeo4jTypePredicate = ({ nullFieldPredicate, rootPredicateFunction, cypherTypeConstructor, + parentIsListArgument, isTemporalFilter, isSpatialFilter }) => { @@ -3832,6 +3943,7 @@ const buildNeo4jTypePredicate = ({ listVariable, isTemporalFilter, isSpatialFilter, + parentIsListArgument, isListFilterArgument, filterValue, nullFieldPredicate, diff --git a/src/utils.js b/src/utils.js index f14880cf..23d26613 100644 --- a/src/utils.js +++ b/src/utils.js @@ -470,7 +470,7 @@ const buildNeo4jTypeCypherParameters = ({ const neo4jTypeConstructor = decideNeo4jTypeConstructor(fieldTypeName); if (neo4jTypeConstructor) { // Prefer only using formatted, if provided - if (formatted) { + if (formatted !== undefined) { if (paramKey) params[paramKey][paramName] = formatted; else params[paramName] = formatted; paramStatements.push( diff --git a/test/helpers/augmentSchemaTestHelpers.js b/test/helpers/augmentSchemaTestHelpers.js new file mode 100644 index 00000000..9231f81e --- /dev/null +++ b/test/helpers/augmentSchemaTestHelpers.js @@ -0,0 +1,63 @@ +import { parse, print, Kind } from 'graphql'; +import { printSchemaDocument } from '../../src/augment/augment'; + +export const compareSchema = ({ + test, + sourceSchema = {}, + expectedSchema = {} +}) => { + const expectedDefinitions = parse(expectedSchema, { noLocation: true }) + .definitions; + const printedSourceSchema = printSchemaDocument({ schema: sourceSchema }); + const augmentedDefinitions = parse(printedSourceSchema, { noLocation: true }) + .definitions; + expectedDefinitions.forEach(expected => { + const matchingAugmented = findMatchingType({ + definitions: augmentedDefinitions, + definition: expected + }); + if (matchingAugmented) { + test.is(print(expected), print(matchingAugmented)); + } else { + test.fail( + `\nAugmented schema is missing definition:\n${print(expected)}` + ); + } + }); + augmentedDefinitions.forEach(augmented => { + const matchingExpected = findMatchingType({ + definitions: expectedDefinitions, + definition: augmented + }); + if (matchingExpected) { + test.is(print(augmented), print(matchingExpected)); + } else { + test.fail( + `\nExpected augmented schema is missing definition:\n${print( + augmented + )}` + ); + } + }); +}; + +const findMatchingType = ({ definitions = [], definition }) => { + const expectedKind = definition.kind; + const expectedName = definition.name; + return definitions.find(augmented => { + const augmentedName = augmented.name; + const matchesKind = augmented.kind == expectedKind; + let matchesName = false; + let isSchemaDefinition = false; + if (matchesKind) { + if (expectedName && augmentedName) { + if (expectedName.value === augmentedName.value) { + matchesName = true; + } + } else if (augmented.kind === Kind.SCHEMA_DEFINITION) { + isSchemaDefinition = true; + } + } + return matchesKind && (matchesName || isSchemaDefinition); + }); +}; diff --git a/test/helpers/experimental/augmentSchemaTest.js b/test/helpers/experimental/augmentSchemaTest.js new file mode 100644 index 00000000..3b12cba7 --- /dev/null +++ b/test/helpers/experimental/augmentSchemaTest.js @@ -0,0 +1,200 @@ +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 '../../helpers/experimental/testSchema'; + +// Optimization to prevent schema augmentation from running for every test +const cypherTestTypeDefs = printSchemaDocument({ + schema: makeAugmentedSchema({ + typeDefs: testSchema, + resolvers: {}, + config: { + auth: true, + experimental: true + } + }) +}); + +export function cypherTestRunner( + t, + graphqlQuery, + graphqlParams, + expectedCypherQuery, + expectedCypherParams +) { + const testMovieSchema = + testSchema + + ` + type Mutation { + CreateUser(data: _UserData!): User @hasScope(scopes: ["User: Create", "create:user"]) + UpdateUser(where: _UserWhere!, data: _UserData!): User @hasScope(scopes: ["User: Update", "update:user"]) + DeleteUser(where: _UserWhere!): User @hasScope(scopes: ["User: Delete", "delete:user"]) + MergeUser(where: _UserWhere!, data: _UserData!): User @hasScope(scopes: ["User: Merge", "merge:user"]) + } + + type Query { + User: [User] @hasScope(scopes: ["User: Read", "read:user"]) + } + + input _UserWhere { + AND: [_UserWhere!] + OR: [_UserWhere!] + idField: ID + idField_not: ID + idField_in: [ID!] + idField_not_in: [ID!] + idField_contains: ID + idField_not_contains: ID + idField_starts_with: ID + idField_not_starts_with: ID + idField_ends_with: ID + idField_not_ends_with: ID + uniqueString: String + uniqueString_not: String + uniqueString_in: [String!] + uniqueString_not_in: [String!] + uniqueString_contains: String + uniqueString_not_contains: String + uniqueString_starts_with: String + uniqueString_not_starts_with: String + uniqueString_ends_with: String + uniqueString_not_ends_with: String + indexedInt: Int + indexedInt_not: Int + indexedInt_in: [Int!] + indexedInt_not_in: [Int!] + indexedInt_lt: Int + indexedInt_lte: Int + indexedInt_gt: Int + indexedInt_gte: Int + } + + input _UserKeys { + idField: ID + uniqueString: String + indexedInt: Int + } + + input _UserData { + idField: ID + name: String + birthday: _Neo4jDateTimeInput + uniqueString: String + indexedInt: Int + extensionString: String + } + + `; + + 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, + UpdateUser: checkCypherMutation, + DeleteUser: checkCypherMutation, + MergeUser: checkCypherMutation + } + }; + let augmentedTypeDefs = augmentTypeDefs(testMovieSchema, { + 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, + UpdateUser: checkCypherMutation, + DeleteUser: checkCypherMutation, + MergeUser: checkCypherMutation, + AddUserRated: checkCypherMutation, + UpdateUserRated: checkCypherMutation, + RemoveUserRated: checkCypherMutation, + MergeUserRated: checkCypherMutation + } + }; + + const augmentedSchema = makeExecutableSchema({ + typeDefs: cypherTestTypeDefs, + resolvers, + resolverValidationOptions: { + requireResolversForResolveType: false + }, + config: { + auth: true, + experimental: true + } + }); + + return graphql( + augmentedSchema, + graphqlQuery, + null, + { + cypherParams: { + userId: 'user-id' + } + }, + graphqlParams + ); +} diff --git a/test/helpers/experimental/testSchema.js b/test/helpers/experimental/testSchema.js new file mode 100644 index 00000000..baa44a90 --- /dev/null +++ b/test/helpers/experimental/testSchema.js @@ -0,0 +1,49 @@ +import { gql } from 'apollo-server'; + +export const testSchema = ` + type User { + idField: ID! @id + name: String + birthday: DateTime + uniqueString: String! @unique + indexedInt: Int @index + liked: [Movie!]! @relation( + name: "RATING", + direction: OUT + ) + rated: [Rating] + } + + extend type User { + extensionString: String! + } + + type Rating @relation(from: "user", to: "movie") { + user: User + rating: Int! + movie: Movie + } + + type Movie { + id: ID! @id + title: String! @unique + genre: MovieGenre @index + likedBy: [User!]! @relation( + name: "RATING", + direction: IN + ) + ratedBy: [Rating] + } + + enum MovieGenre { + Action + Mystery + Scary + } + + enum Role { + reader + user + admin + } +`; diff --git a/test/unit/augmentSchemaTest.test.js b/test/unit/augmentSchemaTest.test.js index 33238875..06ed5577 100644 --- a/test/unit/augmentSchemaTest.test.js +++ b/test/unit/augmentSchemaTest.test.js @@ -1,9 +1,8 @@ import test from 'ava'; -import { parse, print, Kind } from 'graphql'; -import { printSchemaDocument } from '../../src/augment/augment'; import { makeAugmentedSchema } from '../../src/index'; import { testSchema } from '../helpers/testSchema'; import { gql } from 'apollo-server'; +import { compareSchema } from '../helpers/augmentSchemaTestHelpers'; test.cb('Test augmented schema', t => { const parseTypeDefs = gql` @@ -9161,60 +9160,3 @@ test.cb('Test augmented schema', t => { }); t.end(); }); - -const compareSchema = ({ test, sourceSchema = {}, expectedSchema = {} }) => { - const expectedDefinitions = parse(expectedSchema, { noLocation: true }) - .definitions; - const printedSourceSchema = printSchemaDocument({ schema: sourceSchema }); - const augmentedDefinitions = parse(printedSourceSchema, { noLocation: true }) - .definitions; - expectedDefinitions.forEach(expected => { - const matchingAugmented = findMatchingType({ - definitions: augmentedDefinitions, - definition: expected - }); - if (matchingAugmented) { - test.is(print(expected), print(matchingAugmented)); - } else { - test.fail( - `\nAugmented schema is missing definition:\n${print(expected)}` - ); - } - }); - augmentedDefinitions.forEach(augmented => { - const matchingExpected = findMatchingType({ - definitions: expectedDefinitions, - definition: augmented - }); - if (matchingExpected) { - test.is(print(augmented), print(matchingExpected)); - } else { - test.fail( - `\nExpected augmented schema is missing definition:\n${print( - augmented - )}` - ); - } - }); -}; - -const findMatchingType = ({ definitions = [], definition }) => { - const expectedKind = definition.kind; - const expectedName = definition.name; - return definitions.find(augmented => { - const augmentedName = augmented.name; - const matchesKind = augmented.kind == expectedKind; - let matchesName = false; - let isSchemaDefinition = false; - if (matchesKind) { - if (expectedName && augmentedName) { - if (expectedName.value === augmentedName.value) { - matchesName = true; - } - } else if (augmented.kind === Kind.SCHEMA_DEFINITION) { - isSchemaDefinition = true; - } - } - return matchesKind && (matchesName || isSchemaDefinition); - }); -}; diff --git a/test/unit/cypherTest.test.js b/test/unit/cypherTest.test.js index 27d0fd76..94b510b3 100644 --- a/test/unit/cypherTest.test.js +++ b/test/unit/cypherTest.test.js @@ -4062,6 +4062,51 @@ test('Create node with temporal properties', t => { ); }); +test('Create node with temporal property only using formatted argument value', t => { + const graphQLQuery = `mutation { + CreateTemporalNode( + datetime: { + formatted: "2020-11-01T13:23:24.284-08:00[America/Los_Angeles]" + } + ) { + _id + datetime { + year + month + day + hour + minute + second + millisecond + microsecond + nanosecond + timezone + formatted + } + } + }`, + expectedCypherQuery = ` + CREATE (\`temporalNode\`:\`TemporalNode\` {datetime: datetime($params.datetime)}) + RETURN \`temporalNode\` {_id: ID(\`temporalNode\`),datetime: { year: \`temporalNode\`.datetime.year , month: \`temporalNode\`.datetime.month , day: \`temporalNode\`.datetime.day , hour: \`temporalNode\`.datetime.hour , minute: \`temporalNode\`.datetime.minute , second: \`temporalNode\`.datetime.second , millisecond: \`temporalNode\`.datetime.millisecond , microsecond: \`temporalNode\`.datetime.microsecond , nanosecond: \`temporalNode\`.datetime.nanosecond , timezone: \`temporalNode\`.datetime.timezone , formatted: toString(\`temporalNode\`.datetime) }} AS \`temporalNode\` + `, + expectedParams = { + params: { + datetime: '2020-11-01T13:23:24.284-08:00[America/Los_Angeles]' + }, + first: -1, + offset: 0 + }; + + t.plan(2); + return augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ); +}); + test('Create node with spatial properties', t => { const graphQLQuery = `mutation { CreateSpatialNode( @@ -7592,6 +7637,64 @@ test('Query relationship type using list field filters', t => { ); }); +test('Query node using only formatted temporal filter value nested in logical OR filter', t => { + const graphQLQuery = `query { + TemporalNode( + filter: { + OR: [ + { + datetime_gte: { + formatted: "2018-11-23T10:30:01.002003004-08:00[America/Los_Angeles]" + } + } + ] + } + ) { + _id + datetime { + year + month + day + hour + minute + second + millisecond + microsecond + nanosecond + timezone + formatted + } + } + } + `, + expectedCypherQuery = `MATCH (\`temporalNode\`:\`TemporalNode\`) WHERE (ANY(_OR IN $filter.OR WHERE (((_OR.datetime_gte.formatted IS NULL OR \`temporalNode\`.datetime = datetime(_OR.datetime_gte.formatted)))))) RETURN \`temporalNode\` {_id: ID(\`temporalNode\`),datetime: { year: \`temporalNode\`.datetime.year , month: \`temporalNode\`.datetime.month , day: \`temporalNode\`.datetime.day , hour: \`temporalNode\`.datetime.hour , minute: \`temporalNode\`.datetime.minute , second: \`temporalNode\`.datetime.second , millisecond: \`temporalNode\`.datetime.millisecond , microsecond: \`temporalNode\`.datetime.microsecond , nanosecond: \`temporalNode\`.datetime.nanosecond , timezone: \`temporalNode\`.datetime.timezone , formatted: toString(\`temporalNode\`.datetime) }} AS \`temporalNode\``, + expectedParams = { + offset: 0, + first: -1, + filter: { + OR: [ + { + datetime_gte: { + formatted: + '2018-11-23T10:30:01.002003004-08:00[America/Los_Angeles]' + } + } + ] + } + }; + + t.plan(2); + return Promise.all([ + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + test('Create node with non-null field', t => { const graphQLQuery = `mutation { CreateState( @@ -7929,7 +8032,7 @@ test('query nested relationship with differences between selected and ordered fi ]); }); -test('Deeply nested query using temporal orderBy without temporal field selection', t => { +test('Order reflexive relationship field using temporal argument without corresponding field selection', t => { const graphQLQuery = `query { TemporalNode(orderBy: [datetime_desc]) { _id @@ -7960,6 +8063,38 @@ test('Deeply nested query using temporal orderBy without temporal field selectio ]); }); +test('Order relationship field using temporal argument without corresponding field selection', t => { + const graphQLQuery = `query { + Actor { + userId + movies(orderBy: released_asc) { + movieId + released { + formatted + } + } + } + }`, + expectedCypherQuery = `MATCH (\`actor\`:\`Actor\`) RETURN \`actor\` { .userId ,movies: [sortedElement IN apoc.coll.sortMulti([(\`actor\`)-[:\`ACTED_IN\`]->(\`actor_movies\`:\`Movie\`${ADDITIONAL_MOVIE_LABELS}) | \`actor_movies\` { .movieId ,released: \`actor_movies\`.released}], ['^released']) | sortedElement { .* , released: {formatted: toString(sortedElement.released)}}] } AS \`actor\``, + expectedParams = { + offset: 0, + first: -1, + '1_orderBy': 'released_asc', + cypherParams: CYPHER_PARAMS + }; + + t.plan(2); + return Promise.all([ + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + test('Handle @cypher field with String payload using cypherParams', t => { const graphQLQuery = `query { User { diff --git a/test/unit/experimental/augmentSchemaTest.test.js b/test/unit/experimental/augmentSchemaTest.test.js new file mode 100644 index 00000000..1bdf89dc --- /dev/null +++ b/test/unit/experimental/augmentSchemaTest.test.js @@ -0,0 +1,949 @@ +import test from 'ava'; +import { gql } from 'apollo-server'; +import { makeAugmentedSchema } from '../../../src/index'; +import { testSchema } from '../../helpers/experimental/testSchema'; +import { compareSchema } from '../../helpers/augmentSchemaTestHelpers'; + +test.cb('Test augmented schema', t => { + const parseTypeDefs = gql` + ${testSchema} + `; + const sourceSchema = makeAugmentedSchema({ + typeDefs: parseTypeDefs, + config: { + auth: true, + experimental: true + } + }); + + const expectedSchema = /* GraphQL */ ` + type _AddUserLikedPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + from: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + to: Movie + } + + type _RemoveUserLikedPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + from: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + to: Movie + } + + type _MergeUserLikedPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + from: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + to: Movie + } + + type _UserRated @relation(name: "RATING", from: "User", to: "Movie") { + rating: Int! + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this relationship." + _id: String + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + } + + input _UserRatingFilter { + AND: [_UserRatingFilter!] + OR: [_UserRatingFilter!] + rating: Int + rating_not: Int + rating_in: [Int!] + rating_not_in: [Int!] + rating_lt: Int + rating_lte: Int + rating_gt: Int + rating_gte: Int + movie: _MovieFilter + } + + enum _RatingOrdering { + rating_asc + rating_desc + _id_asc + _id_desc + } + + input _RatingInput { + rating: Int! + } + + type _AddUserRatedPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + rating: Int! + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this relationship." + _id: String + } + + type _RemoveUserRatedPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + } + + type _UpdateUserRatedPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + rating: Int! + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this relationship." + _id: String + } + + type _MergeUserRatedPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + rating: Int! + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this relationship." + _id: String + } + + input _UserData { + idField: ID + name: String + birthday: _Neo4jDateTimeInput + uniqueString: String + indexedInt: Int + extensionString: String + } + + input _UserWhere { + AND: [_UserWhere!] + OR: [_UserWhere!] + idField: ID + idField_not: ID + idField_in: [ID!] + idField_not_in: [ID!] + idField_contains: ID + idField_not_contains: ID + idField_starts_with: ID + idField_not_starts_with: ID + idField_ends_with: ID + idField_not_ends_with: ID + uniqueString: String + uniqueString_not: String + uniqueString_in: [String!] + uniqueString_not_in: [String!] + uniqueString_contains: String + uniqueString_not_contains: String + uniqueString_starts_with: String + uniqueString_not_starts_with: String + uniqueString_ends_with: String + uniqueString_not_ends_with: String + indexedInt: Int + indexedInt_not: Int + indexedInt_in: [Int!] + indexedInt_not_in: [Int!] + indexedInt_lt: Int + indexedInt_lte: Int + indexedInt_gt: Int + indexedInt_gte: Int + } + + input _UserKeys { + idField: ID + uniqueString: String + indexedInt: Int + } + + enum _UserOrdering { + idField_asc + idField_desc + name_asc + name_desc + birthday_asc + birthday_desc + uniqueString_asc + uniqueString_desc + indexedInt_asc + indexedInt_desc + extensionString_asc + extensionString_desc + _id_asc + _id_desc + } + + input _UserFilter { + AND: [_UserFilter!] + OR: [_UserFilter!] + idField: ID + idField_not: ID + idField_in: [ID!] + idField_not_in: [ID!] + idField_contains: ID + idField_not_contains: ID + idField_starts_with: ID + idField_not_starts_with: ID + idField_ends_with: ID + idField_not_ends_with: ID + name: String + name_not: String + name_in: [String!] + name_not_in: [String!] + name_contains: String + name_not_contains: String + name_starts_with: String + name_not_starts_with: String + name_ends_with: String + name_not_ends_with: String + birthday: _Neo4jDateTimeInput + birthday_not: _Neo4jDateTimeInput + birthday_in: [_Neo4jDateTimeInput!] + birthday_not_in: [_Neo4jDateTimeInput!] + birthday_lt: _Neo4jDateTimeInput + birthday_lte: _Neo4jDateTimeInput + birthday_gt: _Neo4jDateTimeInput + birthday_gte: _Neo4jDateTimeInput + uniqueString: String + uniqueString_not: String + uniqueString_in: [String!] + uniqueString_not_in: [String!] + uniqueString_contains: String + uniqueString_not_contains: String + uniqueString_starts_with: String + uniqueString_not_starts_with: String + uniqueString_ends_with: String + uniqueString_not_ends_with: String + indexedInt: Int + indexedInt_not: Int + indexedInt_in: [Int!] + indexedInt_not_in: [Int!] + indexedInt_lt: Int + indexedInt_lte: Int + indexedInt_gt: Int + indexedInt_gte: Int + liked: _MovieFilter + liked_not: _MovieFilter + liked_in: [_MovieFilter!] + liked_not_in: [_MovieFilter!] + liked_some: _MovieFilter + liked_none: _MovieFilter + liked_single: _MovieFilter + liked_every: _MovieFilter + rated: _UserRatingFilter + rated_not: _UserRatingFilter + rated_in: [_UserRatingFilter!] + rated_not_in: [_UserRatingFilter!] + rated_some: _UserRatingFilter + rated_none: _UserRatingFilter + rated_single: _UserRatingFilter + rated_every: _UserRatingFilter + extensionString: String + extensionString_not: String + extensionString_in: [String!] + extensionString_not_in: [String!] + extensionString_contains: String + extensionString_not_contains: String + extensionString_starts_with: String + extensionString_not_starts_with: String + extensionString_ends_with: String + extensionString_not_ends_with: String + } + + type User { + idField: ID! @id + name: String + birthday: _Neo4jDateTime + uniqueString: String! @unique + indexedInt: Int @index + liked( + first: Int + offset: Int + orderBy: [_MovieOrdering] + filter: _MovieFilter + ): [Movie!]! @relation(name: "RATING", direction: OUT) + rated( + first: Int + offset: Int + orderBy: [_RatingOrdering] + filter: _UserRatingFilter + ): [_UserRated] + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this node." + _id: String + } + + type Rating @relation(from: "user", to: "movie") { + user: User + rating: Int! + movie: Movie + } + + type _AddMovieLikedByPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + from: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + to: Movie + } + + type _RemoveMovieLikedByPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + from: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + to: Movie + } + + type _MergeMovieLikedByPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + from: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + to: Movie + } + + type _MovieRatedBy @relation(name: "RATING", from: "User", to: "Movie") { + rating: Int! + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this relationship." + _id: String + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + } + + input _MovieRatingFilter { + AND: [_MovieRatingFilter!] + OR: [_MovieRatingFilter!] + rating: Int + rating_not: Int + rating_in: [Int!] + rating_not_in: [Int!] + rating_lt: Int + rating_lte: Int + rating_gt: Int + rating_gte: Int + user: _UserFilter + } + + type _AddMovieRatedByPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + rating: Int! + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this relationship." + _id: String + } + + type _RemoveMovieRatedByPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + } + + type _UpdateMovieRatedByPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + rating: Int! + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this relationship." + _id: String + } + + type _MergeMovieRatedByPayload + @relation(name: "RATING", from: "User", to: "Movie") { + "Field for the User node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is coming from." + user: User + "Field for the Movie node this RATING [relationship](https://grandstack.io/docs/graphql-relationship-types) is going to." + movie: Movie + rating: Int! + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this relationship." + _id: String + } + + input _MovieData { + id: ID + title: String + genre: MovieGenre + } + + input _MovieWhere { + AND: [_MovieWhere!] + OR: [_MovieWhere!] + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] + id_contains: ID + id_not_contains: ID + id_starts_with: ID + id_not_starts_with: ID + id_ends_with: ID + id_not_ends_with: ID + title: String + title_not: String + title_in: [String!] + title_not_in: [String!] + title_contains: String + title_not_contains: String + title_starts_with: String + title_not_starts_with: String + title_ends_with: String + title_not_ends_with: String + genre: MovieGenre + genre_not: MovieGenre + genre_in: [MovieGenre!] + genre_not_in: [MovieGenre!] + } + + input _MovieKeys { + id: ID + title: String + genre: MovieGenre + } + + enum _MovieOrdering { + id_asc + id_desc + title_asc + title_desc + genre_asc + genre_desc + _id_asc + _id_desc + } + + input _MovieFilter { + AND: [_MovieFilter!] + OR: [_MovieFilter!] + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] + id_contains: ID + id_not_contains: ID + id_starts_with: ID + id_not_starts_with: ID + id_ends_with: ID + id_not_ends_with: ID + title: String + title_not: String + title_in: [String!] + title_not_in: [String!] + title_contains: String + title_not_contains: String + title_starts_with: String + title_not_starts_with: String + title_ends_with: String + title_not_ends_with: String + genre: MovieGenre + genre_not: MovieGenre + genre_in: [MovieGenre!] + genre_not_in: [MovieGenre!] + likedBy: _UserFilter + likedBy_not: _UserFilter + likedBy_in: [_UserFilter!] + likedBy_not_in: [_UserFilter!] + likedBy_some: _UserFilter + likedBy_none: _UserFilter + likedBy_single: _UserFilter + likedBy_every: _UserFilter + ratedBy: _MovieRatingFilter + ratedBy_not: _MovieRatingFilter + ratedBy_in: [_MovieRatingFilter!] + ratedBy_not_in: [_MovieRatingFilter!] + ratedBy_some: _MovieRatingFilter + ratedBy_none: _MovieRatingFilter + ratedBy_single: _MovieRatingFilter + ratedBy_every: _MovieRatingFilter + } + + type Movie { + id: ID! @id + title: String! @unique + genre: MovieGenre @index + likedBy( + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter + ): [User!]! @relation(name: "RATING", direction: IN) + ratedBy( + first: Int + offset: Int + orderBy: [_RatingOrdering] + filter: _MovieRatingFilter + ): [_MovieRatedBy] + "Generated field for querying the Neo4j [system id](https://neo4j.com/docs/cypher-manual/current/functions/scalar/#functions-id) of this node." + _id: String + } + + enum MovieGenre { + Action + Mystery + Scary + } + + enum Role { + reader + user + admin + } + + "Generated Time input object for Neo4j [Temporal field arguments](https://grandstack.io/docs/graphql-temporal-types-datetime/#temporal-query-arguments)." + input _Neo4jTimeInput { + hour: Int + minute: Int + second: Int + millisecond: Int + microsecond: Int + nanosecond: Int + timezone: String + "Creates a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime/#using-temporal-fields-in-mutations) Time value using a [String format](https://neo4j.com/docs/cypher-manual/current/functions/temporal/time/#functions-time-create-string)." + formatted: String + } + + "Generated Time object type for Neo4j [Temporal fields](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries)." + type _Neo4jTime { + hour: Int + minute: Int + second: Int + millisecond: Int + microsecond: Int + nanosecond: Int + timezone: String + "Outputs a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries) Time value as a String type by using the [toString](https://neo4j.com/docs/cypher-manual/current/functions/string/#functions-tostring) Cypher function." + formatted: String + } + + "Generated Date input object for Neo4j [Temporal field arguments](https://grandstack.io/docs/graphql-temporal-types-datetime/#temporal-query-arguments)." + input _Neo4jDateInput { + year: Int + month: Int + day: Int + "Creates a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime/#using-temporal-fields-in-mutations) Date value using a [String format](https://neo4j.com/docs/cypher-manual/current/functions/temporal/date/#functions-date-create-string)." + formatted: String + } + + "Generated Date object type for Neo4j [Temporal fields](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries)." + type _Neo4jDate { + year: Int + month: Int + day: Int + "Outputs a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries) Date value as a String type by using the [toString](https://neo4j.com/docs/cypher-manual/current/functions/string/#functions-tostring) Cypher function." + formatted: String + } + + "Generated DateTime input object for Neo4j [Temporal field arguments](https://grandstack.io/docs/graphql-temporal-types-datetime/#temporal-query-arguments)." + input _Neo4jDateTimeInput { + year: Int + month: Int + day: Int + hour: Int + minute: Int + second: Int + millisecond: Int + microsecond: Int + nanosecond: Int + timezone: String + "Creates a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime/#using-temporal-fields-in-mutations) DateTime value using a [String format](https://neo4j.com/docs/cypher-manual/current/functions/temporal/datetime/#functions-datetime-create-string)." + formatted: String + } + + "Generated DateTime object type for Neo4j [Temporal fields](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries)." + type _Neo4jDateTime { + year: Int + month: Int + day: Int + hour: Int + minute: Int + second: Int + millisecond: Int + microsecond: Int + nanosecond: Int + timezone: String + "Outputs a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries) DateTime value as a String type by using the [toString](https://neo4j.com/docs/cypher-manual/current/functions/string/#functions-tostring) Cypher function." + formatted: String + } + + "Generated LocalTime input object for Neo4j [Temporal field arguments](https://grandstack.io/docs/graphql-temporal-types-datetime/#temporal-query-arguments)." + input _Neo4jLocalTimeInput { + hour: Int + minute: Int + second: Int + millisecond: Int + microsecond: Int + nanosecond: Int + "Creates a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime/#using-temporal-fields-in-mutations) LocalTime value using a [String format](https://neo4j.com/docs/cypher-manual/current/functions/temporal/localtime/#functions-localtime-create-string)." + formatted: String + } + + "Generated LocalTime object type for Neo4j [Temporal fields](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries)." + type _Neo4jLocalTime { + hour: Int + minute: Int + second: Int + millisecond: Int + microsecond: Int + nanosecond: Int + "Outputs a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries) LocalTime value as a String type by using the [toString](https://neo4j.com/docs/cypher-manual/current/functions/string/#functions-tostring) Cypher function." + formatted: String + } + + "Generated LocalDateTime input object for Neo4j [Temporal field arguments](https://grandstack.io/docs/graphql-temporal-types-datetime/#temporal-query-arguments)." + input _Neo4jLocalDateTimeInput { + year: Int + month: Int + day: Int + hour: Int + minute: Int + second: Int + millisecond: Int + microsecond: Int + nanosecond: Int + "Creates a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime/#using-temporal-fields-in-mutations) LocalDateTime value using a [String format](https://neo4j.com/docs/cypher-manual/current/functions/temporal/localdatetime/#functions-localdatetime-create-string)." + formatted: String + } + + "Generated LocalDateTime object type for Neo4j [Temporal fields](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries)." + type _Neo4jLocalDateTime { + year: Int + month: Int + day: Int + hour: Int + minute: Int + second: Int + millisecond: Int + microsecond: Int + nanosecond: Int + "Outputs a Neo4j [Temporal](https://grandstack.io/docs/graphql-temporal-types-datetime#using-temporal-fields-in-queries) LocalDateTime value as a String type by using the [toString](https://neo4j.com/docs/cypher-manual/current/functions/string/#functions-tostring) Cypher function." + formatted: String + } + + input _Neo4jPointDistanceFilter { + point: _Neo4jPointInput! + distance: Float! + } + + "Generated Point input object for Neo4j [Spatial field arguments](https://grandstack.io/docs/graphql-spatial-types/#point-query-arguments)." + input _Neo4jPointInput { + x: Float + y: Float + z: Float + longitude: Float + latitude: Float + height: Float + crs: String + srid: Int + } + + "Generated Point object type for Neo4j [Spatial fields](https://grandstack.io/docs/graphql-spatial-types#using-point-in-queries)." + type _Neo4jPoint { + x: Float + y: Float + z: Float + longitude: Float + latitude: Float + height: Float + crs: String + srid: Int + } + + enum _RelationDirections { + IN + OUT + } + + directive @cypher(statement: String) on FIELD_DEFINITION + + directive @relation( + name: String + direction: _RelationDirections + from: String + to: String + ) on FIELD_DEFINITION | OBJECT + + directive @additionalLabels(labels: [String]) on OBJECT + + directive @MutationMeta( + relationship: String + from: String + to: String + ) on FIELD_DEFINITION + + directive @neo4j_ignore on FIELD_DEFINITION + + directive @id on FIELD_DEFINITION + + directive @unique on FIELD_DEFINITION + + directive @index on FIELD_DEFINITION + + directive @isAuthenticated on OBJECT | FIELD_DEFINITION + + directive @hasRole(roles: [Role]) on OBJECT | FIELD_DEFINITION + + directive @hasScope(scopes: [String]) on OBJECT | FIELD_DEFINITION + + extend type User { + extensionString: String! + } + + type Query { + "[Generated query](https://grandstack.io/docs/graphql-schema-generation-augmentation#generated-queries) for User type nodes." + User( + idField: ID + name: String + birthday: _Neo4jDateTimeInput + uniqueString: String + indexedInt: Int + extensionString: String + _id: String + first: Int + offset: Int + orderBy: [_UserOrdering] + filter: _UserFilter + ): [User] @hasScope(scopes: ["User: Read", "read:user"]) + "[Generated query](https://grandstack.io/docs/graphql-schema-generation-augmentation#generated-queries) for Movie type nodes." + Movie( + id: ID + title: String + genre: MovieGenre + _id: String + first: Int + offset: Int + orderBy: [_MovieOrdering] + filter: _MovieFilter + ): [Movie] @hasScope(scopes: ["Movie: Read", "read:movie"]) + } + + type Mutation { + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##add--remove-relationship) for [creating](https://neo4j.com/docs/cypher-manual/4.1/clauses/create/#create-relationships) the RATING relationship." + AddUserLiked(from: _UserWhere!, to: _MovieWhere!): _AddUserLikedPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Create" + "create:user" + "Movie: Create" + "create:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##add--remove-relationship) for [deleting](https://neo4j.com/docs/cypher-manual/4.1/clauses/delete/#delete-delete-relationships-only) the RATING relationship." + RemoveUserLiked( + from: _UserWhere! + to: _MovieWhere! + ): _RemoveUserLikedPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Delete" + "delete:user" + "Movie: Delete" + "delete:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##merge-relationship) for [merging](https://neo4j.com/docs/cypher-manual/4.1/clauses/merge/#query-merge-relationships) the RATING relationship." + MergeUserLiked( + from: _UserWhere! + to: _MovieWhere! + ): _MergeUserLikedPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: ["User: Merge", "merge:user", "Movie: Merge", "merge:movie"] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##add--remove-relationship) for [creating](https://neo4j.com/docs/cypher-manual/4.1/clauses/create/#create-relationships) the RATING relationship." + AddUserRated( + user: _UserWhere! + movie: _MovieWhere! + data: _RatingInput! + ): _AddUserRatedPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Create" + "create:user" + "Movie: Create" + "create:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##add--remove-relationship) for [deleting](https://neo4j.com/docs/cypher-manual/4.1/clauses/delete/#delete-delete-relationships-only) the RATING relationship." + RemoveUserRated( + user: _UserWhere! + movie: _MovieWhere! + ): _RemoveUserRatedPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Delete" + "delete:user" + "Movie: Delete" + "delete:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##update-relationship) for [updating](https://neo4j.com/docs/cypher-manual/4.1/clauses/set/#set-update-a-property) the RATING relationship." + UpdateUserRated( + user: _UserWhere! + movie: _MovieWhere! + data: _RatingInput! + ): _UpdateUserRatedPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Update" + "update:user" + "Movie: Update" + "update:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##merge-relationship) for [merging](https://neo4j.com/docs/cypher-manual/4.1/clauses/merge/#query-merge-relationships) the RATING relationship." + MergeUserRated( + user: _UserWhere! + movie: _MovieWhere! + data: _RatingInput! + ): _MergeUserRatedPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: ["User: Merge", "merge:user", "Movie: Merge", "merge:movie"] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/#create) for [creating](https://neo4j.com/docs/cypher-manual/4.1/clauses/create/#create-nodes) a User node." + CreateUser(data: _UserData!): User + @hasScope(scopes: ["User: Create", "create:user"]) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/#update) for [updating](https://neo4j.com/docs/cypher-manual/4.1/clauses/set/#set-update-a-property) a User node." + UpdateUser(where: _UserWhere!, data: _UserData!): User + @hasScope(scopes: ["User: Update", "update:user"]) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/#delete) for [deleting](https://neo4j.com/docs/cypher-manual/4.1/clauses/delete/#delete-delete-single-node) a User node." + DeleteUser(where: _UserWhere!): User + @hasScope(scopes: ["User: Delete", "delete:user"]) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/#merge) for [merging](https://neo4j.com/docs/cypher-manual/4.1/clauses/merge/#query-merge-node-derived) a User node." + MergeUser(where: _UserKeys!, data: _UserData!): User + @hasScope(scopes: ["User: Merge", "merge:user"]) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##add--remove-relationship) for [creating](https://neo4j.com/docs/cypher-manual/4.1/clauses/create/#create-relationships) the RATING relationship." + AddMovieLikedBy( + from: _UserWhere! + to: _MovieWhere! + ): _AddMovieLikedByPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Create" + "create:user" + "Movie: Create" + "create:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##add--remove-relationship) for [deleting](https://neo4j.com/docs/cypher-manual/4.1/clauses/delete/#delete-delete-relationships-only) the RATING relationship." + RemoveMovieLikedBy( + from: _UserWhere! + to: _MovieWhere! + ): _RemoveMovieLikedByPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Delete" + "delete:user" + "Movie: Delete" + "delete:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##merge-relationship) for [merging](https://neo4j.com/docs/cypher-manual/4.1/clauses/merge/#query-merge-relationships) the RATING relationship." + MergeMovieLikedBy( + from: _UserWhere! + to: _MovieWhere! + ): _MergeMovieLikedByPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: ["User: Merge", "merge:user", "Movie: Merge", "merge:movie"] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##add--remove-relationship) for [creating](https://neo4j.com/docs/cypher-manual/4.1/clauses/create/#create-relationships) the RATING relationship." + AddMovieRatedBy( + user: _UserWhere! + movie: _MovieWhere! + data: _RatingInput! + ): _AddMovieRatedByPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Create" + "create:user" + "Movie: Create" + "create:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##add--remove-relationship) for [deleting](https://neo4j.com/docs/cypher-manual/4.1/clauses/delete/#delete-delete-relationships-only) the RATING relationship." + RemoveMovieRatedBy( + user: _UserWhere! + movie: _MovieWhere! + ): _RemoveMovieRatedByPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Delete" + "delete:user" + "Movie: Delete" + "delete:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##update-relationship) for [updating](https://neo4j.com/docs/cypher-manual/4.1/clauses/set/#set-update-a-property) the RATING relationship." + UpdateMovieRatedBy( + user: _UserWhere! + movie: _MovieWhere! + data: _RatingInput! + ): _UpdateMovieRatedByPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: [ + "User: Update" + "update:user" + "Movie: Update" + "update:movie" + ] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/##merge-relationship) for [merging](https://neo4j.com/docs/cypher-manual/4.1/clauses/merge/#query-merge-relationships) the RATING relationship." + MergeMovieRatedBy( + user: _UserWhere! + movie: _MovieWhere! + data: _RatingInput! + ): _MergeMovieRatedByPayload + @MutationMeta(relationship: "RATING", from: "User", to: "Movie") + @hasScope( + scopes: ["User: Merge", "merge:user", "Movie: Merge", "merge:movie"] + ) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/#create) for [creating](https://neo4j.com/docs/cypher-manual/4.1/clauses/create/#create-nodes) a Movie node." + CreateMovie(data: _MovieData!): Movie + @hasScope(scopes: ["Movie: Create", "create:movie"]) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/#update) for [updating](https://neo4j.com/docs/cypher-manual/4.1/clauses/set/#set-update-a-property) a Movie node." + UpdateMovie(where: _MovieWhere!, data: _MovieData!): Movie + @hasScope(scopes: ["Movie: Update", "update:movie"]) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/#delete) for [deleting](https://neo4j.com/docs/cypher-manual/4.1/clauses/delete/#delete-delete-single-node) a Movie node." + DeleteMovie(where: _MovieWhere!): Movie + @hasScope(scopes: ["Movie: Delete", "delete:movie"]) + "[Generated mutation](https://grandstack.io/docs/graphql-schema-generation-augmentation/#merge) for [merging](https://neo4j.com/docs/cypher-manual/4.1/clauses/merge/#query-merge-node-derived) a Movie node." + MergeMovie(where: _MovieKeys!, data: _MovieData!): Movie + @hasScope(scopes: ["Movie: Merge", "merge:movie"]) + } + + schema { + query: Query + mutation: Mutation + } + `; + + compareSchema({ + test: t, + sourceSchema, + expectedSchema + }); + t.end(); +}); diff --git a/test/unit/experimental/cypherTest.test.js b/test/unit/experimental/cypherTest.test.js new file mode 100644 index 00000000..8f22b863 --- /dev/null +++ b/test/unit/experimental/cypherTest.test.js @@ -0,0 +1,548 @@ +import test from 'ava'; +import { + cypherTestRunner, + augmentedSchemaCypherTestRunner +} from '../../helpers/experimental/augmentSchemaTest'; + +test('Create node mutation using data input object argument', t => { + const graphQLQuery = `mutation { + CreateUser( + data: { + name: "Michael" + indexedInt: 33 + birthday: { year: 1987, month: 9, day: 3, hour: 1 } + } + ) { + idField + indexedInt + name + birthday { + year + month + day + } + } + } + `, + expectedCypherQuery = ` + CREATE (\`user\`:\`User\` {idField: apoc.create.uuid(),name:$data.name,birthday: datetime($data.birthday),indexedInt:$data.indexedInt}) + RETURN \`user\` { .idField , .indexedInt , .name ,birthday: { year: \`user\`.birthday.year , month: \`user\`.birthday.month , day: \`user\`.birthday.day }} AS \`user\` + `, + expectedParams = { + first: -1, + offset: 0, + data: { + name: 'Michael', + birthday: { + year: { + low: 1987, + high: 0 + }, + month: { + low: 9, + high: 0 + }, + day: { + low: 3, + high: 0 + }, + hour: { + low: 1, + high: 0 + } + }, + indexedInt: { + low: 33, + high: 0 + } + } + }; + + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Update node mutation using data input object argument', t => { + const graphQLQuery = `mutation { + UpdateUser( + where: { + idField_not: null + indexedInt_in: [11, 33, 1] + } + data: { + name: "Michael", + indexedInt: 34 + birthday: { + year: 2020 + month: 10 + day: 30 + hour: 2 + } + } + ) { + idField + name + indexedInt + birthday { + year + month + day + } + } + } + `, + expectedCypherQuery = `MATCH (\`user\`:\`User\`) WHERE ($where._idField_not_null = TRUE AND EXISTS(\`user\`.idField)) AND (\`user\`.indexedInt IN $where.indexedInt_in) +SET \`user\` += {name:$data.name,birthday: datetime($data.birthday),indexedInt:$data.indexedInt} +RETURN \`user\` { .idField , .name , .indexedInt ,birthday: { year: \`user\`.birthday.year , month: \`user\`.birthday.month , day: \`user\`.birthday.day }} AS \`user\``, + expectedParams = { + where: { + _idField_not_null: true, + indexedInt_in: [ + { + low: 11, + high: 0 + }, + { + low: 33, + high: 0 + }, + { + low: 1, + high: 0 + } + ] + }, + data: { + name: 'Michael', + birthday: { + year: { + low: 2020, + high: 0 + }, + month: { + low: 10, + high: 0 + }, + day: { + low: 30, + high: 0 + }, + hour: { + low: 2, + high: 0 + } + }, + indexedInt: { + low: 34, + high: 0 + } + } + }; + + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Delete node mutation using data input object argument', t => { + const graphQLQuery = `mutation { + DeleteUser( + where: { + idField_not: null + indexedInt_in: [11, 34, 33] + } + ) { + idField + name + indexedInt + birthday { + year + month + day + } + } + } + `, + expectedCypherQuery = `MATCH (\`user\`:\`User\`) WHERE ($where._idField_not_null = TRUE AND EXISTS(\`user\`.idField)) AND (\`user\`.indexedInt IN $where.indexedInt_in) +WITH \`user\` AS \`user_toDelete\`, \`user\` { .idField , .name , .indexedInt ,birthday: { year: \`user\`.birthday.year , month: \`user\`.birthday.month , day: \`user\`.birthday.day }} AS \`user\` +DETACH DELETE \`user_toDelete\` +RETURN \`user\``, + expectedParams = { + where: { + _idField_not_null: true, + indexedInt_in: [ + { + low: 11, + high: 0 + }, + { + low: 34, + high: 0 + }, + { + low: 33, + 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 using data input object argument', t => { + const graphQLQuery = `mutation { + MergeUser( + where: { + idField: "A" + indexedInt: 33 + } + data: { + idField: "A" + indexedInt: 33 + name: "Michael" + birthday: { year: 1987, month: 9, day: 3, hour: 1 } + } + ) { + idField + indexedInt + name + birthday { + year + month + day + } + } + } + `, + expectedCypherQuery = `MERGE (\`user\`:\`User\`{idField:$where.idField,indexedInt:$where.indexedInt}) +SET \`user\` += {idField:$data.idField,name:$data.name,birthday: datetime($data.birthday),indexedInt:$data.indexedInt} +RETURN \`user\` { .idField , .indexedInt , .name ,birthday: { year: \`user\`.birthday.year , month: \`user\`.birthday.month , day: \`user\`.birthday.day }} AS \`user\``, + expectedParams = { + where: { + idField: 'A', + indexedInt: { + low: 33, + high: 0 + } + }, + data: { + idField: 'A', + name: 'Michael', + birthday: { + year: { + low: 1987, + high: 0 + }, + month: { + low: 9, + high: 0 + }, + day: { + low: 3, + high: 0 + }, + hour: { + low: 1, + high: 0 + } + }, + indexedInt: { + low: 33, + high: 0 + } + } + }; + + t.plan(4); + return Promise.all([ + cypherTestRunner(t, graphQLQuery, {}, expectedCypherQuery, expectedParams), + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Add relationship mutation using complex node selection arguments', t => { + const graphQLQuery = `mutation { + AddUserRated( + user: { + indexedInt_in: [33] + } + movie: { + title_not: "B" + } + data: { + rating: 10 + } + ) { + user { + idField + indexedInt + name + } + movie { + id + title + } + } + } + `, + expectedCypherQuery = ` + MATCH (\`user_user\`:\`User\`) WHERE (\`user_user\`.indexedInt IN $user.indexedInt_in) + MATCH (\`movie_movie\`:\`Movie\`) WHERE (NOT \`movie_movie\`.title = $movie.title_not) + CREATE (\`user_user\`)-[\`rating_relation\`:\`RATING\` {rating:$data.rating}]->(\`movie_movie\`) + RETURN \`rating_relation\` { user: \`user_user\` { .idField , .indexedInt , .name } ,movie: \`movie_movie\` { .id , .title } } AS \`_AddUserRatedPayload\`; + `, + expectedParams = { + user: { + indexedInt_in: [ + { + low: 33, + high: 0 + } + ] + }, + movie: { + title_not: 'B' + }, + data: { + rating: { + low: 10, + high: 0 + } + }, + first: -1, + offset: 0 + }; + t.plan(2); + return Promise.all([ + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Update relationship mutation using complex node selection arguments', t => { + const graphQLQuery = `mutation { + UpdateUserRated( + user: { + indexedInt_in: [33] + } + movie: { + title: "A" + } + data: { + rating: 5 + } + ) { + user { + idField + indexedInt + name + } + movie { + id + title + } + } + } + `, + expectedCypherQuery = ` + MATCH (\`user_user\`:\`User\`) WHERE (\`user_user\`.indexedInt IN $user.indexedInt_in) + MATCH (\`movie_movie\`:\`Movie\`) WHERE (\`movie_movie\`.title = $movie.title) + MATCH (\`user_user\`)-[\`rating_relation\`:\`RATING\`]->(\`movie_movie\`) + SET \`rating_relation\` += {rating:$data.rating} + RETURN \`rating_relation\` { user: \`user_user\` { .idField , .indexedInt , .name } ,movie: \`movie_movie\` { .id , .title } } AS \`_UpdateUserRatedPayload\`; + `, + expectedParams = { + user: { + indexedInt_in: [ + { + low: 33, + high: 0 + } + ] + }, + movie: { + title: 'A' + }, + data: { + rating: { + low: 5, + high: 0 + } + }, + first: -1, + offset: 0 + }; + t.plan(2); + return Promise.all([ + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Remove relationship mutation using complex node selection arguments', t => { + const graphQLQuery = `mutation { + RemoveUserRated( + user: { + indexedInt_in: [33] + } + movie: { + title_not: "B" + } + ) { + user { + idField + indexedInt + name + } + movie { + id + title + } + } + } + `, + expectedCypherQuery = ` + MATCH (\`user_user\`:\`User\`) WHERE (\`user_user\`.indexedInt IN $user.indexedInt_in) + MATCH (\`movie_movie\`:\`Movie\`) WHERE (NOT \`movie_movie\`.title = $movie.title_not) + OPTIONAL MATCH (\`user_user\`)-[\`user_usermovie_movie\`:\`RATING\`]->(\`movie_movie\`) + DELETE \`user_usermovie_movie\` + WITH COUNT(*) AS scope, \`user_user\` AS \`_user_user\`, \`movie_movie\` AS \`_movie_movie\` + RETURN {user: \`_user_user\` { .idField , .indexedInt , .name } ,movie: \`_movie_movie\` { .id , .title } } AS \`_RemoveUserRatedPayload\`; + `, + expectedParams = { + user: { + indexedInt_in: [ + { + low: 33, + high: 0 + } + ] + }, + movie: { + title_not: 'B' + }, + first: -1, + offset: 0 + }; + t.plan(2); + return Promise.all([ + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +}); + +test('Merge relationship mutation using complex node selection arguments', t => { + const graphQLQuery = `mutation { + MergeUserRated( + user: { + indexedInt_in: [33] + } + movie: { + title: "A" + } + data: { + rating: 5 + } + ) { + user { + idField + indexedInt + name + } + movie { + id + title + } + } + } + `, + expectedCypherQuery = ` + MATCH (\`user_user\`:\`User\`) WHERE (\`user_user\`.indexedInt IN $user.indexedInt_in) + MATCH (\`movie_movie\`:\`Movie\`) WHERE (\`movie_movie\`.title = $movie.title) + MERGE (\`user_user\`)-[\`rating_relation\`:\`RATING\`]->(\`movie_movie\`) + SET \`rating_relation\` += {rating:$data.rating} + RETURN \`rating_relation\` { user: \`user_user\` { .idField , .indexedInt , .name } ,movie: \`movie_movie\` { .id , .title } } AS \`_MergeUserRatedPayload\`; + `, + expectedParams = { + user: { + indexedInt_in: [ + { + low: 33, + high: 0 + } + ] + }, + movie: { + title: 'A' + }, + data: { + rating: { + low: 5, + high: 0 + } + }, + first: -1, + offset: 0 + }; + t.plan(2); + return Promise.all([ + augmentedSchemaCypherTestRunner( + t, + graphQLQuery, + {}, + expectedCypherQuery, + expectedParams + ) + ]); +});