diff --git a/docs/asciidoc/guides/2.0.0-migration/mutations.adoc b/docs/asciidoc/guides/2.0.0-migration/mutations.adoc index 0ef477c8c3..c02316bd66 100644 --- a/docs/asciidoc/guides/2.0.0-migration/mutations.adoc +++ b/docs/asciidoc/guides/2.0.0-migration/mutations.adoc @@ -3,7 +3,7 @@ The most broadly affected area of functionality by the 2.0.0 upgrade are the nested operations of Mutations, to faciliate the mutation of and filtering on relationship properties. -The examples in this section will be based off the following type definitions (which have been migrated over to `@neo4j/graphql` syntax): +The examples in this section will be based off the following type definitions: [source, graphql] ---- diff --git a/docs/asciidoc/guides/2.0.0-migration/unions.adoc b/docs/asciidoc/guides/2.0.0-migration/unions.adoc new file mode 100644 index 0000000000..54047b859f --- /dev/null +++ b/docs/asciidoc/guides/2.0.0-migration/unions.adoc @@ -0,0 +1,94 @@ +[[rel-migration-unions]] += Unions + +The structure of input types for union queries and mutations have been changed for user friendliness, and a more consistent API. + +The examples in this section will be based off the following type definitions: + +[source, graphql] +---- +type Actor { + name: String! + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) +} + +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +type Series { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +union Production = Movie | Series +---- + +Essentially, field names which were previously of template `${unionFieldName}_${concreteType}` (for example, "actedIn_Movie") are now an object, with the field name at the top, and the member types under it. + +For example, a Mutation which would have previously been: + +[source, graphql] +---- +mutation { + createActors( + input: [ + { + name: "Tom Hiddleston" + actedIn_Movie: { + create: [ + { + title: "The Avengers" + } + ] + } + actedIn_Series: { + create: [ + { + title: "Loki" + } + ] + } + } + ] + ) +} +---- + +Will now be: + +[source, graphql] +---- +mutation { + createActors( + input: [ + { + name: "Tom Hiddleston" + actedIn: { + Movie: { + create: [ + { + node: { + title: "The Avengers" + } + } + ] + } + Series: { + create: [ + { + node: { + title: "Loki" + } + } + ] + } + } + } + ] + ) +} +---- + +Note the change in structure for union input, but also the additional `node` level which enables the use of relationship properties. These changes are consistent across all operations, including `where`. diff --git a/docs/asciidoc/type-definitions/unions-and-interfaces.adoc b/docs/asciidoc/type-definitions/unions-and-interfaces.adoc index 8ba0f15a34..2da85b70b4 100644 --- a/docs/asciidoc/type-definitions/unions-and-interfaces.adoc +++ b/docs/asciidoc/type-definitions/unions-and-interfaces.adoc @@ -5,88 +5,113 @@ Unions and interfaces are abstract GraphQL types that enable a schema field to r [[type-definitions-unions-and-interfaces-union-types]] == Union Types -Neo4j GraphQL supports the use of Unions on a `@relationship` field. -=== Example -The following schema defines a `User` type, that has a relationship(`HAS_CONTENT`), of type `[Content]`. `Content` is of type `union` representing either `Blog` or `Post`. +The Neo4j GraphQL Library supports the use of unions on relationship fields. For example, the following schema defines a `User` type, that has a relationship `HAS_CONTENT`, of type `[Content]`. `Content` is of type `union` representing either a `Blog` or a `Post`. [source, graphql] ---- union Content = Blog | Post type Blog { - title: String - posts: [Post] @relationship(type: "HAS_POST", direction: OUT) + title: String + posts: [Post] @relationship(type: "HAS_POST", direction: OUT) } type Post { - content: String + content: String } type User { - name: String - content: [Content] @relationship(type: "HAS_CONTENT", direction: OUT) + name: String + content: [Content] @relationship(type: "HAS_CONTENT", direction: OUT) } ---- +Below you can find some examples of how queries and mutations work with this example. === Querying a union -The below query gets the user and their content; + +The Neo4j GraphQL Library only returns union members which have inline fragments in your selection set. + +For example, the following will return users and only their blogs: [source, graphql] ---- -query GetUsersWithContent { - users { - name - content { - ... on Blog { - title - posts { - content +query GetUsersWithBlogs { + users { + name + content { + ... on Blog { + title + posts { + content + } } } - ... on Post { - content + } +} +---- + +Whilst the query below will return both the blogs and posts of users: + +[source, graphql] +---- +query GetUsersWithAllContent { + users { + name + content { + ... on Blog { + title + posts { + content + } + } + ... on Post { + content + } } } - } } ---- === Creating a union -The below mutation creates the user and their content; + +The below mutation creates the user and their content: [source, graphql] ---- mutation CreateUserAndContent { - createUsers(input: [ - { - name: "Dan" - content_Blog: { - create: [ - { - node: { - title: "My Cool Blog" - posts: { - create: [ - { - node: { - content: "My Cool Post" - } + createUsers( + input: [ + { + name: "Dan" + content: { + Blog: { + create: [ + { + node: { + title: "My Cool Blog" + posts: { + create: [ + { + node: { + content: "My Cool Post" + } + } + ] } - ] + } } - } + ] } - ] + } } + ] + ) { + users { + name } - ] - ) { - users { - name } - } } ---- @@ -94,4 +119,4 @@ mutation CreateUserAndContent { == Interface Types -Using interface types will give you no real database support therefore no; query, update, delete, filter support. But instead used as a language feature to safeguard your schema. Great for when dealing with repetitive or large schemas you can essentially put "The side railings up". +Using interface types will give you no database support, therefore there is no support for querying, updating, deleting, filtering. But instead used as a language feature to safeguard your schema. Great for when dealing with repetitive or large schemas you can essentially put "The side railings up". diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 87d81d8aee..9094f22e30 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -619,16 +619,39 @@ function makeAugmentedSchema( }, }); - refNodes.forEach((n) => { - const concatFieldName = `${rel.fieldName}_${n.name}`; - const unionPrefix = `${node.name}${upperFirst(rel.fieldName)}${n.name}`; + const upperFieldName = upperFirst(rel.fieldName); + const upperNodeName = upperFirst(node.name); + const typePrefix = `${upperNodeName}${upperFieldName}`; + + const [ + unionConnectInput, + unionCreateInput, + unionDeleteInput, + unionDisconnectInput, + unionUpdateInput, + ] = ["Connect", "Create", "Delete", "Disconnect", "Update"].map((operation) => + composer.createInputTC({ + name: `${typePrefix}${operation}Input`, + fields: {}, + }) + ); - const updateField = `${n.name}UpdateInput`; + const unionCreateFieldInput = composer.createInputTC({ + name: `${typePrefix}CreateFieldInput`, + fields: {}, + }); + refNodes.forEach((n) => { + const unionPrefix = `${node.name}${upperFieldName}${n.name}`; + const updateField = `${n.name}UpdateInput`; const nodeFieldInputName = `${unionPrefix}FieldInput`; - const nodeFieldUpdateInputName = `${unionPrefix}UpdateFieldInput`; - const nodeFieldDeleteInputName = `${unionPrefix}DeleteFieldInput`; - const nodeFieldDisconnectInputName = `${unionPrefix}DisconnectFieldInput`; + const whereName = `${unionPrefix}ConnectionWhere`; + + const deleteName = `${unionPrefix}DeleteFieldInput`; + const _delete = rel.typeMeta.array ? `[${deleteName}!]` : `${deleteName}`; + + const disconnectName = `${unionPrefix}DisconnectFieldInput`; + const disconnect = rel.typeMeta.array ? `[${disconnectName}!]` : `${disconnectName}`; const connectionUpdateInputName = `${unionPrefix}UpdateConnectionInput`; @@ -642,6 +665,14 @@ function makeAugmentedSchema( ...(rel.properties ? { properties: `${rel.properties}CreateInput!` } : {}), }, }); + + unionCreateInput.addFields({ + [n.name]: nodeFieldInputName, + }); + + unionCreateFieldInput.addFields({ + [n.name]: `[${createName}!]`, + }); } const connectWhereName = `${n.name}ConnectWhere`; @@ -654,7 +685,7 @@ function makeAugmentedSchema( }); } - const connectName = `${node.name}${upperFirst(rel.fieldName)}ConnectFieldInput`; + const connectName = `${unionPrefix}ConnectFieldInput`; const connect = rel.typeMeta.array ? `[${connectName}!]` : `${connectName}`; if (!composer.has(connectName)) { composer.createInputTC({ @@ -671,6 +702,30 @@ function makeAugmentedSchema( ...(rel.properties ? { properties: `${rel.properties}CreateInput!` } : {}), }, }); + + unionConnectInput.addFields({ + [n.name]: connect, + }); + } + + const updateName = `${unionPrefix}UpdateFieldInput`; + const update = rel.typeMeta.array ? `[${updateName}!]` : updateName; + if (!composer.has(updateName)) { + composer.createInputTC({ + name: updateName, + fields: { + where: whereName, + update: connectionUpdateInputName, + connect, + disconnect: rel.typeMeta.array ? `[${disconnectName}!]` : disconnectName, + create, + delete: rel.typeMeta.array ? `[${deleteName}!]` : deleteName, + }, + }); + + unionUpdateInput.addFields({ + [n.name]: update, + }); } composer.createInputTC({ @@ -681,20 +736,6 @@ function makeAugmentedSchema( }, }); - composer.createInputTC({ - name: nodeFieldUpdateInputName, - fields: { - where: `${node.name}${upperFirst(rel.fieldName)}ConnectionWhere`, - update: connectionUpdateInputName, - connect, - disconnect: rel.typeMeta.array - ? `[${nodeFieldDisconnectInputName}!]` - : nodeFieldDisconnectInputName, - create, - delete: rel.typeMeta.array ? `[${nodeFieldDeleteInputName}!]` : nodeFieldDeleteInputName, - }, - }); - composer.createInputTC({ name: nodeFieldInputName, fields: { @@ -703,14 +744,13 @@ function makeAugmentedSchema( }, }); - const connectionWhereName = `${unionPrefix}ConnectionWhere`; composer.createInputTC({ - name: connectionWhereName, + name: whereName, fields: { node: `${n.name}Where`, node_NOT: `${n.name}Where`, - AND: `[${connectionWhereName}!]`, - OR: `[${connectionWhereName}!]`, + AND: `[${whereName}!]`, + OR: `[${whereName}!]`, ...(rel.properties ? { relationship: `${rel.properties}Where`, @@ -720,59 +760,65 @@ function makeAugmentedSchema( }, }); - composer.createInputTC({ - name: nodeFieldDeleteInputName, - fields: { - where: connectionWhereName, - ...(n.relationFields.length - ? { - delete: `${n.name}DeleteInput`, - } - : {}), - }, - }); + if (!composer.has(deleteName)) { + composer.createInputTC({ + name: deleteName, + fields: { + where: whereName, + ...(n.relationFields.length + ? { + delete: `${n.name}DeleteInput`, + } + : {}), + }, + }); - composer.createInputTC({ - name: nodeFieldDisconnectInputName, - fields: { - where: connectionWhereName, - ...(n.relationFields.length - ? { - disconnect: `${n.name}DisconnectInput`, - } - : {}), - }, - }); + unionDeleteInput.addFields({ + [n.name]: _delete, + }); + } - nodeRelationInput.addFields({ - [concatFieldName]: create, - }); + if (!composer.has(disconnectName)) { + composer.createInputTC({ + name: disconnectName, + fields: { + where: whereName, + ...(n.relationFields.length + ? { + disconnect: `${n.name}DisconnectInput`, + } + : {}), + }, + }); - nodeInput.addFields({ - [concatFieldName]: nodeFieldInputName, - }); + unionDisconnectInput.addFields({ + [n.name]: disconnect, + }); + } + }); - nodeUpdateInput.addFields({ - [concatFieldName]: rel.typeMeta.array - ? `[${nodeFieldUpdateInputName}!]` - : nodeFieldUpdateInputName, - }); + nodeInput.addFields({ + [rel.fieldName]: unionCreateInput, + }); - nodeDeleteInput.addFields({ - [concatFieldName]: rel.typeMeta.array - ? `[${nodeFieldDeleteInputName}!]` - : nodeFieldDeleteInputName, - }); + nodeRelationInput.addFields({ + [rel.fieldName]: unionCreateFieldInput, + }); - nodeConnectInput.addFields({ - [concatFieldName]: connect, - }); + nodeUpdateInput.addFields({ + [rel.fieldName]: unionUpdateInput, + }); - nodeDisconnectInput.addFields({ - [concatFieldName]: rel.typeMeta.array - ? `[${nodeFieldDisconnectInputName}!]` - : nodeFieldDisconnectInputName, - }); + nodeConnectInput.addFields({ + [rel.fieldName]: unionConnectInput, + }); + + nodeDeleteInput.addFields({ + [rel.fieldName]: unionDeleteInput, + }); + + nodeDisconnectInput.addFields({ + [rel.fieldName]: unionDisconnectInput, }); return; diff --git a/packages/graphql/src/translate/create-connect-and-params.ts b/packages/graphql/src/translate/create-connect-and-params.ts index 294f67a8d7..6f7e7f8a30 100644 --- a/packages/graphql/src/translate/create-connect-and-params.ts +++ b/packages/graphql/src/translate/create-connect-and-params.ts @@ -176,31 +176,35 @@ function createConnectAndParams({ const connects = (Array.isArray(connect.connect) ? connect.connect : [connect.connect]) as any[]; connects.forEach((c) => { const reduced = Object.entries(c).reduce( - (r: Res, [k, v]) => { - const relField = refNode.relationFields.find((x) => k.startsWith(x.fieldName)); - let newRefNode: Node; - - if (relationField.union) { - const [modelName] = k.split(`${relationField.fieldName}_`).join("").split("_"); - newRefNode = context.neoSchema.nodes.find((x) => x.name === modelName) as Node; + (r: Res, [k, v]: [string, any]) => { + const relField = refNode.relationFields.find((x) => k === x.fieldName) as RelationField; + const newRefNodes: Node[] = []; + + if (relField.union) { + Object.keys(v).forEach((modelName) => { + newRefNodes.push(context.neoSchema.nodes.find((x) => x.name === modelName) as Node); + }); } else { - newRefNode = context.neoSchema.nodes.find( - (x) => x.name === (relField as RelationField).typeMeta.name - ) as Node; + newRefNodes.push( + context.neoSchema.nodes.find((x) => x.name === relField.typeMeta.name) as Node + ); } - const recurse = createConnectAndParams({ - withVars: [...withVars, nodeName], - value: v, - varName: `${nodeName}_${k}`, - relationField: relField as RelationField, - parentVar: nodeName, - context, - refNode: newRefNode, - parentNode: refNode, + newRefNodes.forEach((newRefNode) => { + const recurse = createConnectAndParams({ + withVars: [...withVars, nodeName], + value: relField.union ? v[newRefNode.name] : v, + varName: `${nodeName}_${k}${relField.union ? `_${newRefNode.name}` : ""}`, + relationField: relField, + parentVar: nodeName, + context, + refNode: newRefNode, + parentNode: refNode, + labelOverride: relField.union ? newRefNode.name : "", + }); + r.connects.push(recurse[0]); + r.params = { ...r.params, ...recurse[1] }; }); - r.connects.push(recurse[0]); - r.params = { ...r.params, ...recurse[1] }; return r; }, diff --git a/packages/graphql/src/translate/create-create-and-params.ts b/packages/graphql/src/translate/create-create-and-params.ts index 25c52c769c..b6fd47d059 100644 --- a/packages/graphql/src/translate/create-create-and-params.ts +++ b/packages/graphql/src/translate/create-create-and-params.ts @@ -51,78 +51,88 @@ function createCreateAndParams({ }): [string, any] { function reducer(res: Res, [key, value]: [string, any]): Res { const _varName = `${varName}_${key}`; - const relationField = node.relationFields.find((x) => key.startsWith(x.fieldName)); + const relationField = node.relationFields.find((x) => key === x.fieldName); const primitiveField = node.primitiveFields.find((x) => key === x.fieldName); - const pointField = node.pointFields.find((x) => key.startsWith(x.fieldName)); + const pointField = node.pointFields.find((x) => key === x.fieldName); if (relationField) { - let refNode: Node; - let unionTypeName = ""; + const refNodes: Node[] = []; + // let unionTypeName = ""; if (relationField.union) { - [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); - refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; + // [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); + + Object.keys(value).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); + + // refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; } else { - refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); } - if (value.create) { - const creates = relationField.typeMeta.array ? value.create : [value.create]; - creates.forEach((create, index) => { - res.creates.push(`\nWITH ${withVars.join(", ")}`); - - const baseName = `${_varName}${index}`; - const nodeName = `${baseName}_node`; - const propertiesName = `${baseName}_relationship`; + refNodes.forEach((refNode) => { + const v = relationField.union ? value[refNode.name] : value; + const unionTypeName = relationField.union ? refNode.name : ""; + + if (v.create) { + const creates = relationField.typeMeta.array ? v.create : [v.create]; + creates.forEach((create, index) => { + res.creates.push(`\nWITH ${withVars.join(", ")}`); + + const baseName = `${_varName}${relationField.union ? "_" : ""}${unionTypeName}${index}`; + const nodeName = `${baseName}_node`; + const propertiesName = `${baseName}_relationship`; + + const recurse = createCreateAndParams({ + input: create.node, + context, + node: refNode, + varName: nodeName, + withVars: [...withVars, nodeName], + }); + res.creates.push(recurse[0]); + res.params = { ...res.params, ...recurse[1] }; + + const inStr = relationField.direction === "IN" ? "<-" : "-"; + const outStr = relationField.direction === "OUT" ? "->" : "-"; + const relTypeStr = `[${create.properties ? propertiesName : ""}:${relationField.type}]`; + res.creates.push(`MERGE (${varName})${inStr}${relTypeStr}${outStr}(${nodeName})`); + + if (create.properties) { + const relationship = (context.neoSchema.relationships.find( + (x) => x.properties === relationField.properties + ) as unknown) as Relationship; + + const setA = createSetRelationshipPropertiesAndParams({ + properties: create.properties, + varName: propertiesName, + relationship, + operation: "CREATE", + }); + res.creates.push(setA[0]); + res.params = { ...res.params, ...setA[1] }; + } + }); + } - const recurse = createCreateAndParams({ - input: create.node, + if (v.connect) { + const connectAndParams = createConnectAndParams({ + withVars, + value: v.connect, + varName: `${_varName}${relationField.union ? "_" : ""}${unionTypeName}_connect`, + parentVar: varName, + relationField, context, - node: refNode, - varName: nodeName, - withVars: [...withVars, nodeName], + refNode, + labelOverride: unionTypeName, + parentNode: node, + fromCreate: true, }); - res.creates.push(recurse[0]); - res.params = { ...res.params, ...recurse[1] }; - - const inStr = relationField.direction === "IN" ? "<-" : "-"; - const outStr = relationField.direction === "OUT" ? "->" : "-"; - const relTypeStr = `[${create.properties ? propertiesName : ""}:${relationField.type}]`; - res.creates.push(`MERGE (${varName})${inStr}${relTypeStr}${outStr}(${nodeName})`); - - if (create.properties) { - const relationship = (context.neoSchema.relationships.find( - (x) => x.properties === relationField.properties - ) as unknown) as Relationship; - - const setA = createSetRelationshipPropertiesAndParams({ - properties: create.properties, - varName: propertiesName, - relationship, - operation: "CREATE", - }); - res.creates.push(setA[0]); - res.params = { ...res.params, ...setA[1] }; - } - }); - } - - if (value.connect) { - const connectAndParams = createConnectAndParams({ - withVars, - value: value.connect, - varName: `${_varName}_connect`, - parentVar: varName, - relationField, - context, - refNode, - labelOverride: unionTypeName, - parentNode: node, - fromCreate: true, - }); - res.creates.push(connectAndParams[0]); - res.params = { ...res.params, ...connectAndParams[1] }; - } + res.creates.push(connectAndParams[0]); + res.params = { ...res.params, ...connectAndParams[1] }; + } + }); return res; } diff --git a/packages/graphql/src/translate/create-delete-and-params.ts b/packages/graphql/src/translate/create-delete-and-params.ts index 1e5f817d67..70c0109d53 100644 --- a/packages/graphql/src/translate/create-delete-and-params.ts +++ b/packages/graphql/src/translate/create-delete-and-params.ts @@ -52,108 +52,113 @@ function createDeleteAndParams({ recursing?: boolean; }): [string, any] { function reducer(res: Res, [key, value]: [string, any]) { - const relationField = node.relationFields.find((x) => key.startsWith(x.fieldName)); - let unionTypeName = ""; + const relationField = node.relationFields.find((x) => key === x.fieldName); if (relationField) { - let refNode: Node; + const refNodes: Node[] = []; const relationship = (context.neoSchema.relationships.find( (x) => x.properties === relationField.properties ) as unknown) as Relationship; if (relationField.union) { - [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); - refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; + Object.keys(value).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); } else { - refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); } const inStr = relationField.direction === "IN" ? "<-" : "-"; const outStr = relationField.direction === "OUT" ? "->" : "-"; - const deletes = relationField.typeMeta.array ? value : [value]; - deletes.forEach((d, index) => { - const _varName = chainStr ? `${varName}${index}` : `${varName}_${key}${index}`; - const relationshipVariable = `${_varName}_relationship`; - const relTypeStr = `[${relationshipVariable}:${relationField.type}]`; - - if (withVars) { - res.strs.push(`WITH ${withVars.join(", ")}`); - } - - res.strs.push( - `OPTIONAL MATCH (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName}:${refNode.name})` - ); - - const whereStrs: string[] = []; - if (d.where) { - const whereAndParams = createConnectionWhereAndParams({ - nodeVariable: _varName, - whereInput: d.where, - node: refNode, - context, - relationshipVariable, - relationship, - parameterPrefix: `${parameterPrefix}${!recursing ? `.${key}` : ""}${ - relationField.typeMeta.array ? `[${index}]` : "" - }.where`, - }); - if (whereAndParams[0]) { - whereStrs.push(whereAndParams[0]); + refNodes.forEach((refNode) => { + const v = relationField.union ? value[refNode.name] : value; + const deletes = relationField.typeMeta.array ? v : [v]; + deletes.forEach((d, index) => { + const _varName = chainStr + ? `${varName}${index}` + : `${varName}_${key}${relationField.union ? `_${refNode.name}` : ""}${index}`; + const relationshipVariable = `${_varName}_relationship`; + const relTypeStr = `[${relationshipVariable}:${relationField.type}]`; + + if (withVars) { + res.strs.push(`WITH ${withVars.join(", ")}`); } - } - const whereAuth = createAuthAndParams({ - operation: "DELETE", - entity: refNode, - context, - where: { varName: _varName, node: refNode }, - }); - if (whereAuth[0]) { - whereStrs.push(whereAuth[0]); - res.params = { ...res.params, ...whereAuth[1] }; - } - if (whereStrs.length) { - res.strs.push(`WHERE ${whereStrs.join(" AND ")}`); - } - - const allowAuth = createAuthAndParams({ - entity: refNode, - operation: "DELETE", - context, - escapeQuotes: Boolean(insideDoWhen), - allow: { parentNode: refNode, varName: _varName }, - }); - if (allowAuth[0]) { - const quote = insideDoWhen ? `\\"` : `"`; - res.strs.push(`WITH ${[...withVars, _varName].join(", ")}`); + res.strs.push( - `CALL apoc.util.validate(NOT(${allowAuth[0]}), ${quote}${AUTH_FORBIDDEN_ERROR}${quote}, [0])` + `OPTIONAL MATCH (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName}:${refNode.name})` ); - res.params = { ...res.params, ...allowAuth[1] }; - } - if (d.delete) { - const deleteAndParams = createDeleteAndParams({ + const whereStrs: string[] = []; + if (d.where) { + const whereAndParams = createConnectionWhereAndParams({ + nodeVariable: _varName, + whereInput: d.where, + node: refNode, + context, + relationshipVariable, + relationship, + parameterPrefix: `${parameterPrefix}${!recursing ? `.${key}` : ""}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ""}.where`, + }); + if (whereAndParams[0]) { + whereStrs.push(whereAndParams[0]); + } + } + const whereAuth = createAuthAndParams({ + operation: "DELETE", + entity: refNode, + context, + where: { varName: _varName, node: refNode }, + }); + if (whereAuth[0]) { + whereStrs.push(whereAuth[0]); + res.params = { ...res.params, ...whereAuth[1] }; + } + if (whereStrs.length) { + res.strs.push(`WHERE ${whereStrs.join(" AND ")}`); + } + + const allowAuth = createAuthAndParams({ + entity: refNode, + operation: "DELETE", context, - node: refNode, - deleteInput: d.delete, - varName: _varName, - withVars: [...withVars, _varName], - parentVar: _varName, - parameterPrefix: `${parameterPrefix}${!recursing ? `.${key}` : ""}${ - relationField.typeMeta.array ? `[${index}]` : "" - }.delete`, - recursing: false, + escapeQuotes: Boolean(insideDoWhen), + allow: { parentNode: refNode, varName: _varName }, }); - res.strs.push(deleteAndParams[0]); - res.params = { ...res.params, ...deleteAndParams[1] }; - } + if (allowAuth[0]) { + const quote = insideDoWhen ? `\\"` : `"`; + res.strs.push(`WITH ${[...withVars, _varName].join(", ")}`); + res.strs.push( + `CALL apoc.util.validate(NOT(${allowAuth[0]}), ${quote}${AUTH_FORBIDDEN_ERROR}${quote}, [0])` + ); + res.params = { ...res.params, ...allowAuth[1] }; + } + + if (d.delete) { + const deleteAndParams = createDeleteAndParams({ + context, + node: refNode, + deleteInput: d.delete, + varName: _varName, + withVars: [...withVars, _varName], + parentVar: _varName, + parameterPrefix: `${parameterPrefix}${!recursing ? `.${key}` : ""}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ""}.delete`, + recursing: false, + }); + res.strs.push(deleteAndParams[0]); + res.params = { ...res.params, ...deleteAndParams[1] }; + } - res.strs.push(` + res.strs.push(` FOREACH(_ IN CASE ${_varName} WHEN NULL THEN [] ELSE [1] END | DETACH DELETE ${_varName} )`); + }); }); return res; diff --git a/packages/graphql/src/translate/create-disconnect-and-params.test.ts b/packages/graphql/src/translate/create-disconnect-and-params.test.ts index 00ae735ea0..63d39b6f10 100644 --- a/packages/graphql/src/translate/create-disconnect-and-params.test.ts +++ b/packages/graphql/src/translate/create-disconnect-and-params.test.ts @@ -105,7 +105,7 @@ describe("createDisconnectAndParams", () => { ) WITH this, this0 OPTIONAL MATCH (this0)-[this0_similarMovies0_rel:SIMILAR]->(this0_similarMovies0:Movie) - WHERE this0_similarMovies0.title = $this.similarMovies[0].disconnect[0].where.node.title + WHERE this0_similarMovies0.title = $this[0].disconnect.similarMovies[0].where.node.title FOREACH(_ IN CASE this0_similarMovies0 WHEN NULL THEN [] ELSE [1] END | DELETE this0_similarMovies0_rel ) diff --git a/packages/graphql/src/translate/create-disconnect-and-params.ts b/packages/graphql/src/translate/create-disconnect-and-params.ts index af05c33c72..1b21810da2 100644 --- a/packages/graphql/src/translate/create-disconnect-and-params.ts +++ b/packages/graphql/src/translate/create-disconnect-and-params.ts @@ -169,32 +169,38 @@ function createDisconnectAndParams({ disconnects.forEach((c, i) => { const reduced = Object.entries(c).reduce( - (r: Res, [k, v]) => { - const relField = refNode.relationFields.find((x) => k.startsWith(x.fieldName)); - let newRefNode: Node; - - if (relationField.union) { - const [modelName] = k.split(`${relationField.fieldName}_`).join("").split("_"); - newRefNode = context.neoSchema.nodes.find((x) => x.name === modelName) as Node; + (r: Res, [k, v]: [string, any]) => { + const relField = refNode.relationFields.find((x) => k.startsWith(x.fieldName)) as RelationField; + const newRefNodes: Node[] = []; + + if (relField.union) { + Object.keys(v).forEach((modelName) => { + newRefNodes.push(context.neoSchema.nodes.find((x) => x.name === modelName) as Node); + }); } else { - newRefNode = context.neoSchema.nodes.find( - (x) => x.name === (relField as RelationField).typeMeta.name - ) as Node; + newRefNodes.push( + context.neoSchema.nodes.find((x) => x.name === relField.typeMeta.name) as Node + ); } - const recurse = createDisconnectAndParams({ - withVars: [...withVars, _varName], - value: v, - varName: `${_varName}_${k}`, - relationField: relField as RelationField, - parentVar: _varName, - context, - refNode: newRefNode, - parentNode: refNode, - parameterPrefix: `${parameterPrefix}.${k}[${i}].disconnect`, + newRefNodes.forEach((newRefNode) => { + const recurse = createDisconnectAndParams({ + withVars: [...withVars, _varName], + value: relField.union ? v[newRefNode.name] : v, + varName: `${_varName}_${k}${relField.union ? `_${newRefNode.name}` : ""}`, + relationField: relField, + parentVar: _varName, + context, + refNode: newRefNode, + parentNode: refNode, + parameterPrefix: `${parameterPrefix}${ + relField.typeMeta.array ? `[${i}]` : "" + }.disconnect.${k}${relField.union ? `.${newRefNode.name}` : ""}`, + labelOverride: relField.union ? newRefNode.name : "", + }); + r.disconnects.push(recurse[0]); + r.params = { ...r.params, ...recurse[1] }; }); - r.disconnects.push(recurse[0]); - r.params = { ...r.params, ...recurse[1] }; return r; }, diff --git a/packages/graphql/src/translate/create-update-and-params.ts b/packages/graphql/src/translate/create-update-and-params.ts index 267eba566a..ba0a32d496 100644 --- a/packages/graphql/src/translate/create-update-and-params.ts +++ b/packages/graphql/src/translate/create-update-and-params.ts @@ -73,243 +73,252 @@ function createUpdateAndParams({ param = `${parentVar}_update_${key}`; } - const relationField = node.relationFields.find((x) => key.startsWith(x.fieldName)); - const pointField = node.pointFields.find((x) => key.startsWith(x.fieldName)); - let unionTypeName = ""; + const relationField = node.relationFields.find((x) => key === x.fieldName); + const pointField = node.pointFields.find((x) => key === x.fieldName); if (relationField) { - let refNode: Node; + const refNodes: Node[] = []; const relationship = (context.neoSchema.relationships.find( (x) => x.properties === relationField.properties ) as unknown) as Relationship; if (relationField.union) { - [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); - refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; + // [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); + + Object.keys(value).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); + + // refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; } else { - refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); } const inStr = relationField.direction === "IN" ? "<-" : "-"; const outStr = relationField.direction === "OUT" ? "->" : "-"; - const updates = relationField.typeMeta.array ? value : [value]; - updates.forEach((update, index) => { - const relationshipVariable = `${varName}_${relationField.type.toLowerCase()}${index}`; - const relTypeStr = `[${relationshipVariable}:${relationField.type}]`; - const _varName = `${varName}_${key}${index}`; + refNodes.forEach((refNode) => { + const v = relationField.union ? value[refNode.name] : value; + const updates = relationField.typeMeta.array ? v : [v]; + updates.forEach((update, index) => { + const relationshipVariable = `${varName}_${relationField.type.toLowerCase()}${index}`; + const relTypeStr = `[${relationshipVariable}:${relationField.type}]`; + const _varName = `${varName}_${key}${relationField.union ? `_${refNode.name}` : ""}${index}`; + + if (update.update) { + if (withVars) { + res.strs.push(`WITH ${withVars.join(", ")}`); + } - if (update.update) { - if (withVars) { - res.strs.push(`WITH ${withVars.join(", ")}`); - } + res.strs.push( + `OPTIONAL MATCH (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName}:${refNode.name})` + ); - res.strs.push( - `OPTIONAL MATCH (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName}:${refNode.name})` - ); + const whereStrs: string[] = []; - const whereStrs: string[] = []; + if (update.where) { + const where = createConnectionWhereAndParams({ + whereInput: update.where, + node: refNode, + nodeVariable: _varName, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ``}.where`, + }); + const [whereClause] = where; + whereStrs.push(whereClause); + } - if (update.where) { - const where = createConnectionWhereAndParams({ - whereInput: update.where, - node: refNode, - nodeVariable: _varName, - relationship, - relationshipVariable, - context, - parameterPrefix: `${parameterPrefix}.${key}${ - relationField.typeMeta.array ? `[${index}]` : `` - }.where`, - }); - const [whereClause] = where; - whereStrs.push(whereClause); - } + if (node.auth) { + const whereAuth = createAuthAndParams({ + operation: "UPDATE", + entity: refNode, + context, + where: { varName: _varName, node: refNode }, + }); + if (whereAuth[0]) { + whereStrs.push(whereAuth[0]); + res.params = { ...res.params, ...whereAuth[1] }; + } + } + if (whereStrs.length) { + res.strs.push(`WHERE ${whereStrs.join(" AND ")}`); + } - if (node.auth) { - const whereAuth = createAuthAndParams({ - operation: "UPDATE", - entity: refNode, - context, - where: { varName: _varName, node: refNode }, - }); - if (whereAuth[0]) { - whereStrs.push(whereAuth[0]); - res.params = { ...res.params, ...whereAuth[1] }; + if (update.update.node) { + res.strs.push(`CALL apoc.do.when(${_varName} IS NOT NULL, ${insideDoWhen ? '\\"' : '"'}`); + + const auth = createAuthParam({ context }); + let innerApocParams = { auth }; + + const updateAndParams = createUpdateAndParams({ + context, + node: refNode, + updateInput: update.update.node, + varName: _varName, + withVars: [...withVars, _varName], + parentVar: _varName, + chainStr: `${param}${relationField.union ? `_${refNode.name}` : ""}${index}`, + insideDoWhen: true, + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ``}.update.node`, + }); + res.params = { ...res.params, ...updateAndParams[1], auth }; + innerApocParams = { ...innerApocParams, ...updateAndParams[1] }; + + const updateStrs = [updateAndParams[0], "RETURN count(*)"]; + const apocArgs = `{${parentVar}:${parentVar}, ${parameterPrefix?.split(".")[0]}: $${ + parameterPrefix?.split(".")[0] + }, ${_varName}:${_varName}REPLACE_ME}`; + + if (insideDoWhen) { + updateStrs.push(`\\", \\"\\", ${apocArgs})`); + } else { + updateStrs.push(`", "", ${apocArgs})`); + } + updateStrs.push("YIELD value as _"); + + const paramsString = Object.keys(innerApocParams) + .reduce((r: string[], k) => [...r, `${k}:$${k}`], []) + .join(","); + + const updateStr = updateStrs.join("\n").replace(/REPLACE_ME/g, `, ${paramsString}`); + res.strs.push(updateStr); } - } - if (whereStrs.length) { - res.strs.push(`WHERE ${whereStrs.join(" AND ")}`); - } - if (update.update.node) { - res.strs.push(`CALL apoc.do.when(${_varName} IS NOT NULL, ${insideDoWhen ? '\\"' : '"'}`); + if (update.update.relationship) { + res.strs.push( + `CALL apoc.do.when(${relationshipVariable} IS NOT NULL, ${insideDoWhen ? '\\"' : '"'}` + ); - const auth = createAuthParam({ context }); - let innerApocParams = { auth }; + const setProperties = createSetRelationshipProperties({ + properties: update.update.relationship, + varName: relationshipVariable, + relationship, + operation: "UPDATE", + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.union ? `.${refNode.name}` : "" + }[${index}].update.relationship`, + }); - const updateAndParams = createUpdateAndParams({ - context, - node: refNode, - updateInput: update.update.node, - varName: _varName, - withVars: [...withVars, _varName], - parentVar: _varName, - chainStr: `${param}${index}`, - insideDoWhen: true, - parameterPrefix: `${parameterPrefix}.${key}${ - relationField.typeMeta.array ? `[${index}]` : `` - }.update.node`, - }); - res.params = { ...res.params, ...updateAndParams[1], auth }; - innerApocParams = { ...innerApocParams, ...updateAndParams[1] }; - - const updateStrs = [updateAndParams[0], "RETURN count(*)"]; - const apocArgs = `{${parentVar}:${parentVar}, ${parameterPrefix?.split(".")[0]}: $${ - parameterPrefix?.split(".")[0] - }, ${_varName}:${_varName}REPLACE_ME}`; - - if (insideDoWhen) { - updateStrs.push(`\\", \\"\\", ${apocArgs})`); - } else { - updateStrs.push(`", "", ${apocArgs})`); + const updateStrs = [setProperties, "RETURN count(*)"]; + const apocArgs = `{${relationshipVariable}:${relationshipVariable}, ${ + parameterPrefix?.split(".")[0] + }: $${parameterPrefix?.split(".")[0]}}`; + + if (insideDoWhen) { + updateStrs.push(`\\", \\"\\", ${apocArgs})`); + } else { + updateStrs.push(`", "", ${apocArgs})`); + } + updateStrs.push(`YIELD value as ${relationshipVariable}_${key}${index}_properties`); + res.strs.push(updateStrs.join("\n")); } - updateStrs.push("YIELD value as _"); - - const paramsString = Object.keys(innerApocParams) - .reduce((r: string[], k) => [...r, `${k}:$${k}`], []) - .join(","); - - const updateStr = updateStrs.join("\n").replace(/REPLACE_ME/g, `, ${paramsString}`); - res.strs.push(updateStr); } - if (update.update.relationship) { - res.strs.push( - `CALL apoc.do.when(${relationshipVariable} IS NOT NULL, ${insideDoWhen ? '\\"' : '"'}` - ); - - const setProperties = createSetRelationshipProperties({ - properties: update.update.relationship, - varName: relationshipVariable, - relationship, - operation: "UPDATE", - parameterPrefix: `${parameterPrefix}.${key}[${index}].update.relationship`, + if (update.disconnect) { + const disconnectAndParams = createDisconnectAndParams({ + context, + refNode, + value: update.disconnect, + varName: `${_varName}_disconnect`, + withVars, + parentVar, + relationField, + labelOverride: relationField.union ? refNode.name : "", + parentNode: node, + insideDoWhen, + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ""}.disconnect`, }); - - const updateStrs = [setProperties, "RETURN count(*)"]; - const apocArgs = `{${relationshipVariable}:${relationshipVariable}, ${ - parameterPrefix?.split(".")[0] - }: $${parameterPrefix?.split(".")[0]}}`; - - if (insideDoWhen) { - updateStrs.push(`\\", \\"\\", ${apocArgs})`); - } else { - updateStrs.push(`", "", ${apocArgs})`); - } - updateStrs.push(`YIELD value as ${relationshipVariable}_${key}${index}_properties`); - res.strs.push(updateStrs.join("\n")); + res.strs.push(disconnectAndParams[0]); + res.params = { ...res.params, ...disconnectAndParams[1] }; } - } - - if (update.disconnect) { - const disconnectAndParams = createDisconnectAndParams({ - context, - refNode, - value: update.disconnect, - varName: `${_varName}_disconnect`, - withVars, - parentVar, - relationField, - labelOverride: unionTypeName, - parentNode: node, - insideDoWhen, - parameterPrefix: `${parameterPrefix}.${key}${ - relationField.typeMeta.array ? `[${index}]` : "" - }.disconnect`, - }); - res.strs.push(disconnectAndParams[0]); - res.params = { ...res.params, ...disconnectAndParams[1] }; - } - - if (update.connect) { - const connectAndParams = createConnectAndParams({ - context, - refNode, - value: update.connect, - varName: `${_varName}_connect`, - withVars, - parentVar, - relationField, - labelOverride: unionTypeName, - parentNode: node, - insideDoWhen, - }); - res.strs.push(connectAndParams[0]); - res.params = { ...res.params, ...connectAndParams[1] }; - } - - if (update.delete) { - const innerVarName = `${_varName}_delete`; - - const deleteAndParams = createDeleteAndParams({ - context, - node, - deleteInput: { [key]: update.delete }, // OBJECT ENTIERS key reused twice - varName: innerVarName, - chainStr: innerVarName, - parentVar, - withVars, - insideDoWhen, - parameterPrefix: `${parameterPrefix}.${key}${ - relationField.typeMeta.array ? `[${index}]` : `` - }.delete`, // its use here - recursing: true, - }); - res.strs.push(deleteAndParams[0]); - res.params = { ...res.params, ...deleteAndParams[1] }; - } - if (update.create) { - if (withVars) { - res.strs.push(`WITH ${withVars.join(", ")}`); + if (update.connect) { + const connectAndParams = createConnectAndParams({ + context, + refNode, + value: update.connect, + varName: `${_varName}_connect`, + withVars, + parentVar, + relationField, + labelOverride: relationField.union ? refNode.name : "", + parentNode: node, + insideDoWhen, + }); + res.strs.push(connectAndParams[0]); + res.params = { ...res.params, ...connectAndParams[1] }; } - const creates = relationField.typeMeta.array ? update.create : [update.create]; - creates.forEach((create, i) => { - const baseName = `${_varName}_create${i}`; - const nodeName = `${baseName}_node`; - const propertiesName = `${baseName}_relationship`; + if (update.delete) { + const innerVarName = `${_varName}_delete`; - const createAndParams = createCreateAndParams({ + const deleteAndParams = createDeleteAndParams({ context, - node: refNode, - input: create.node, - varName: nodeName, - withVars: [...withVars, nodeName], + node, + deleteInput: { [key]: update.delete }, // OBJECT ENTIERS key reused twice + varName: innerVarName, + chainStr: innerVarName, + parentVar, + withVars, insideDoWhen, + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.typeMeta.array ? `[${index}]` : `` + }.delete`, // its use here + recursing: true, }); - res.strs.push(createAndParams[0]); - res.params = { ...res.params, ...createAndParams[1] }; - res.strs.push( - `MERGE (${parentVar})${inStr}[${create.properties ? propertiesName : ""}:${ - relationField.type - }]${outStr}(${nodeName})` - ); + res.strs.push(deleteAndParams[0]); + res.params = { ...res.params, ...deleteAndParams[1] }; + } - if (create.properties) { - const setA = createSetRelationshipPropertiesAndParams({ - properties: create.properties, - varName: propertiesName, - relationship, - operation: "CREATE", - }); - res.strs.push(setA[0]); - res.params = { ...res.params, ...setA[1] }; + if (update.create) { + if (withVars) { + res.strs.push(`WITH ${withVars.join(", ")}`); } - }); - } + + const creates = relationField.typeMeta.array ? update.create : [update.create]; + creates.forEach((create, i) => { + const baseName = `${_varName}_create${i}`; + const nodeName = `${baseName}_node`; + const propertiesName = `${baseName}_relationship`; + + const createAndParams = createCreateAndParams({ + context, + node: refNode, + input: create.node, + varName: nodeName, + withVars: [...withVars, nodeName], + insideDoWhen, + }); + res.strs.push(createAndParams[0]); + res.params = { ...res.params, ...createAndParams[1] }; + res.strs.push( + `MERGE (${parentVar})${inStr}[${create.properties ? propertiesName : ""}:${ + relationField.type + }]${outStr}(${nodeName})` + ); + + if (create.properties) { + const setA = createSetRelationshipPropertiesAndParams({ + properties: create.properties, + varName: propertiesName, + relationship, + operation: "CREATE", + }); + res.strs.push(setA[0]); + res.params = { ...res.params, ...setA[1] }; + } + }); + } + }); }); return res; diff --git a/packages/graphql/src/translate/translate-update.ts b/packages/graphql/src/translate/translate-update.ts index 48f3a3c0b2..d2988cb081 100644 --- a/packages/graphql/src/translate/translate-update.ts +++ b/packages/graphql/src/translate/translate-update.ts @@ -113,21 +113,33 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ if (disconnectInput) { Object.entries(disconnectInput).forEach((entry) => { const relationField = node.relationFields.find((x) => x.fieldName === entry[0]) as RelationField; - const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + const refNodes: Node[] = []; - const disconnectAndParams = createDisconnectAndParams({ - context, - parentVar: varName, - refNode, - relationField, - value: entry[1], - varName: `${varName}_disconnect_${entry[0]}`, - withVars: [varName], - parentNode: node, - parameterPrefix: `${resolveTree.name}.args.disconnect.${entry[0]}`, + if (relationField.union) { + Object.keys(entry[1]).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); + } else { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); + } + refNodes.forEach((refNode) => { + const disconnectAndParams = createDisconnectAndParams({ + context, + parentVar: varName, + refNode, + relationField, + value: relationField.union ? entry[1][refNode.name] : entry[1], + varName: `${varName}_disconnect_${entry[0]}${relationField.union ? `_${refNode.name}` : ""}`, + withVars: [varName], + parentNode: node, + parameterPrefix: `${resolveTree.name}.args.disconnect.${entry[0]}${ + relationField.union ? `.${refNode.name}` : "" + }`, + labelOverride: relationField.union ? refNode.name : "", + }); + disconnectStrs.push(disconnectAndParams[0]); + cypherParams = { ...cypherParams, ...disconnectAndParams[1] }; }); - disconnectStrs.push(disconnectAndParams[0]); - cypherParams = { ...cypherParams, ...disconnectAndParams[1] }; }); updateArgs = { @@ -138,83 +150,89 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ if (connectInput) { Object.entries(connectInput).forEach((entry) => { - const relationField = node.relationFields.find((x) => entry[0].startsWith(x.fieldName)) as RelationField; + const relationField = node.relationFields.find((x) => entry[0] === x.fieldName) as RelationField; - let refNode: Node; - let unionTypeName = ""; + const refNodes: Node[] = []; if (relationField.union) { - [unionTypeName] = entry[0].split(`${relationField.fieldName}_`).join("").split("_"); - refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; + Object.keys(entry[1]).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); } else { - refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); } - - const connectAndParams = createConnectAndParams({ - context, - parentVar: varName, - refNode, - relationField, - value: entry[1], - varName: `${varName}_connect_${entry[0]}`, - withVars: [varName], - parentNode: node, - labelOverride: unionTypeName, + refNodes.forEach((refNode) => { + const connectAndParams = createConnectAndParams({ + context, + parentVar: varName, + refNode, + relationField, + value: relationField.union ? entry[1][refNode.name] : entry[1], + varName: `${varName}_connect_${entry[0]}${relationField.union ? `_${refNode.name}` : ""}`, + withVars: [varName], + parentNode: node, + labelOverride: relationField.union ? refNode.name : "", + }); + connectStrs.push(connectAndParams[0]); + cypherParams = { ...cypherParams, ...connectAndParams[1] }; }); - connectStrs.push(connectAndParams[0]); - cypherParams = { ...cypherParams, ...connectAndParams[1] }; }); } if (createInput) { Object.entries(createInput).forEach((entry) => { - const relationField = node.relationFields.find((x) => entry[0].startsWith(x.fieldName)) as RelationField; + const relationField = node.relationFields.find((x) => entry[0] === x.fieldName) as RelationField; - let refNode: Node; - let unionTypeName = ""; + const refNodes: Node[] = []; if (relationField.union) { - [unionTypeName] = entry[0].split(`${relationField.fieldName}_`).join("").split("_"); - refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; + Object.keys(entry[1]).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); } else { - refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); } const inStr = relationField.direction === "IN" ? "<-" : "-"; const outStr = relationField.direction === "OUT" ? "->" : "-"; - const creates = relationField.typeMeta.array ? entry[1] : [entry[1]]; - creates.forEach((create, index) => { - const baseName = `${varName}_create_${entry[0]}${index}`; - const nodeName = `${baseName}_node`; - const propertiesName = `${baseName}_relationship`; - const relTypeStr = `[${create.properties ? propertiesName : ""}:${relationField.type}]`; - - const createAndParams = createCreateAndParams({ - context, - node: refNode, - input: create.node, - varName: nodeName, - withVars: [varName, nodeName], - }); - createStrs.push(createAndParams[0]); - cypherParams = { ...cypherParams, ...createAndParams[1] }; - createStrs.push(`MERGE (${varName})${inStr}${relTypeStr}${outStr}(${nodeName})`); - - if (create.properties) { - const relationship = (context.neoSchema.relationships.find( - (x) => x.properties === relationField.properties - ) as unknown) as Relationship; + refNodes.forEach((refNode) => { + const v = relationField.union ? entry[1][refNode.name] : entry[1]; + const creates = relationField.typeMeta.array ? v : [v]; + creates.forEach((create, index) => { + const baseName = `${varName}_create_${entry[0]}${ + relationField.union ? `_${refNode.name}` : "" + }${index}`; + const nodeName = `${baseName}_node`; + const propertiesName = `${baseName}_relationship`; + const relTypeStr = `[${create.properties ? propertiesName : ""}:${relationField.type}]`; - const setA = createSetRelationshipPropertiesAndParams({ - properties: create.properties, - varName: propertiesName, - relationship, - operation: "CREATE", + const createAndParams = createCreateAndParams({ + context, + node: refNode, + input: create.node, + varName: nodeName, + withVars: [varName, nodeName], }); - createStrs.push(setA[0]); + createStrs.push(createAndParams[0]); cypherParams = { ...cypherParams, ...createAndParams[1] }; - } + createStrs.push(`MERGE (${varName})${inStr}${relTypeStr}${outStr}(${nodeName})`); + + if (create.properties) { + const relationship = (context.neoSchema.relationships.find( + (x) => x.properties === relationField.properties + ) as unknown) as Relationship; + + const setA = createSetRelationshipPropertiesAndParams({ + properties: create.properties, + varName: propertiesName, + relationship, + operation: "CREATE", + }); + createStrs.push(setA[0]); + cypherParams = { ...cypherParams, ...createAndParams[1] }; + } + }); }); }); } diff --git a/packages/graphql/tests/integration/nested-unions.int.test.ts b/packages/graphql/tests/integration/nested-unions.int.test.ts new file mode 100644 index 0000000000..d1292e7683 --- /dev/null +++ b/packages/graphql/tests/integration/nested-unions.int.test.ts @@ -0,0 +1,550 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "./neo4j"; +import { Neo4jGraphQL } from "../../src/classes"; + +describe("Nested unions", () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Series { + name: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + union Production = Movie | Series + + type LeadActor { + name: String! + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) + } + + type Extra { + name: String + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) + } + + union Actor = LeadActor | Extra + `; + + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("chain multiple connects, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + connect: { + actors: { + LeadActor: { + where: { node: { name: "${actorName}" } } + connect: { actedIn: { Series: { where: { node: { name: "${seriesName}" } } } } } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle}) + CREATE (:LeadActor {name:$actorName}) + CREATE (:Series {name:$seriesName}) + `, + { movieTitle, seriesName, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + }); + expect(gqlResult.errors).toBeFalsy(); + // expect(gqlResult.data?.updateMovies.movies).toEqual([ + // { + // title: movieTitle, + // actors: [ + // { + // name: actorName, + // actedIn: [ + // {}, + // { + // name: seriesName, + // }, + // ], + // }, + // ], + // }, + // ]); + expect(gqlResult.data?.updateMovies.movies[0].title).toEqual(movieTitle); + expect(gqlResult.data?.updateMovies.movies[0].actors[0].name).toEqual(actorName); + expect(gqlResult.data?.updateMovies.movies[0].actors[0].actedIn).toContainEqual({ + name: seriesName, + }); + } finally { + await session.close(); + } + }); + + test("chain multiple disconnects, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + disconnect: { + actors: { + LeadActor: { + where: { node: { name: "${actorName}" } } + disconnect: { actedIn: { Series: { where: { node: { name: "${seriesName}" } } } } } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN]-(:LeadActor {name:$actorName})-[:ACTED_IN]->(:Series {name:$seriesName}) + `, + { movieTitle, seriesName, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actors: [], + }, + ]); + + const cypherMovie = ` + MATCH (:Movie {title: $movieTitle}) + <-[actedIn:ACTED_IN]- + (:LeadActor {name: $actorName}) + RETURN actedIn + `; + + const neo4jResultMovie = await session.run(cypherMovie, { movieTitle, actorName }); + expect(neo4jResultMovie.records).toHaveLength(0); + + const cypherSeries = ` + MATCH (:Series {name: $seriesName}) + <-[actedIn:ACTED_IN]- + (:LeadActor {name: $actorName}) + RETURN actedIn + `; + + const neo4jResultSeries = await session.run(cypherSeries, { seriesName, actorName }); + expect(neo4jResultSeries.records).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("chain multiple deletes, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + delete: { + actors: { + LeadActor: { + where: { node: { name: "${actorName}" } } + delete: { actedIn: { Series: { where: { node: { name: "${seriesName}" } } } } } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN]-(:LeadActor {name:$actorName})-[:ACTED_IN]->(:Series {name:$seriesName}) + `, + { movieTitle, seriesName, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actors: [], + }, + ]); + + const cypherMovie = ` + MATCH (m:Movie {title: $movieTitle}) + RETURN m + `; + + const neo4jResultMovie = await session.run(cypherMovie, { movieTitle }); + expect(neo4jResultMovie.records).toHaveLength(1); + + const cypherActor = ` + MATCH (a:LeadActor {name: $actorName}) + RETURN a + `; + + const neo4jResultActor = await session.run(cypherActor, { actorName }); + expect(neo4jResultActor.records).toHaveLength(0); + + const cypherSeries = ` + MATCH (s:Series {name: $seriesName}) + RETURN s + `; + + const neo4jResultSeries = await session.run(cypherSeries, { seriesName }); + expect(neo4jResultSeries.records).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("chain multiple creates under update, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + create: { + actors: { + LeadActor: { + node: { + name: "${actorName}" + actedIn: { + Series: { + create: [ + { + node: { + name: "${seriesName}" + } + } + ] + } + } + } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle}) + `, + { movieTitle, seriesName, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + }); + expect(gqlResult.errors).toBeFalsy(); + // expect(gqlResult.data?.updateMovies.movies).toEqual([ + // { + // title: movieTitle, + // actors: [ + // { + // name: actorName, + // actedIn: [ + // {}, + // { + // name: seriesName, + // }, + // ], + // }, + // ], + // }, + // ]); + expect(gqlResult.data?.updateMovies.movies[0].title).toEqual(movieTitle); + expect(gqlResult.data?.updateMovies.movies[0].actors[0].name).toEqual(actorName); + expect(gqlResult.data?.updateMovies.movies[0].actors[0].actedIn).toContainEqual({ + name: seriesName, + }); + + const cypherMovie = ` + MATCH (m:Movie {title: $movieTitle}) + RETURN m + `; + + const neo4jResultMovie = await session.run(cypherMovie, { movieTitle }); + expect(neo4jResultMovie.records).toHaveLength(1); + + const cypherActor = ` + MATCH (a:LeadActor {name: $actorName}) + RETURN a + `; + + const neo4jResultActor = await session.run(cypherActor, { actorName }); + expect(neo4jResultActor.records).toHaveLength(1); + + const cypherSeries = ` + MATCH (s:Series {name: $seriesName}) + RETURN s + `; + + const neo4jResultSeries = await session.run(cypherSeries, { seriesName }); + expect(neo4jResultSeries.records).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("chain multiple creates under create, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + createMovies( + input: [ + { + title: "${movieTitle}" + actors: { + LeadActor: { + create: [ + { + node: { + name: "${actorName}" + actedIn: { + Series: { + create: [ + { + node: { + name: "${seriesName}" + } + } + ] + } + } + } + } + ] + } + } + } + ] + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + }); + expect(gqlResult.errors).toBeFalsy(); + // expect(gqlResult.data?.createMovies.movies).toEqual([ + // { + // title: movieTitle, + // actors: [ + // { + // name: actorName, + // actedIn: [ + // {}, + // { + // name: seriesName, + // }, + // ], + // }, + // ], + // }, + // ]); + expect(gqlResult.data?.createMovies.movies[0].title).toEqual(movieTitle); + expect(gqlResult.data?.createMovies.movies[0].actors[0].name).toEqual(actorName); + expect(gqlResult.data?.createMovies.movies[0].actors[0].actedIn).toContainEqual({ + name: seriesName, + }); + + const cypherMovie = ` + MATCH (m:Movie {title: $movieTitle}) + RETURN m + `; + + const neo4jResultMovie = await session.run(cypherMovie, { movieTitle }); + expect(neo4jResultMovie.records).toHaveLength(1); + + const cypherActor = ` + MATCH (a:LeadActor {name: $actorName}) + RETURN a + `; + + const neo4jResultActor = await session.run(cypherActor, { actorName }); + expect(neo4jResultActor.records).toHaveLength(1); + + const cypherSeries = ` + MATCH (s:Series {name: $seriesName}) + RETURN s + `; + + const neo4jResultSeries = await session.run(cypherSeries, { seriesName }); + expect(neo4jResultSeries.records).toHaveLength(1); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/relationship-properties/connect.int.test.ts b/packages/graphql/tests/integration/relationship-properties/connect.int.test.ts index aad10b96ec..ef4808e152 100644 --- a/packages/graphql/tests/integration/relationship-properties/connect.int.test.ts +++ b/packages/graphql/tests/integration/relationship-properties/connect.int.test.ts @@ -163,13 +163,15 @@ describe("Relationship properties - connect", () => { mutation($movieTitle: String!, $screenTime: Int!, $actorName: String!) { createActors(input: [{ name: $actorName, - actedIn_Movie: { - connect: { - where: { - node: { title: $movieTitle } - }, - properties: { - screenTime: $screenTime + actedIn: { + Movie: { + connect: { + where: { + node: { title: $movieTitle } + }, + properties: { + screenTime: $screenTime + } } } } @@ -310,19 +312,15 @@ describe("Relationship properties - connect", () => { type Movie { title: String! } - type Show { name: String! } - type Actor { name: String! actedIn: [ActedInUnion!]! @relationship(type: "ACTED_IN", properties: "ActedInInterface", direction: OUT) } - union ActedInUnion = Movie | Show - interface ActedInInterface { screenTime: Int! } @@ -342,9 +340,11 @@ describe("Relationship properties - connect", () => { updateActors( where: { name: $actorName } connect: { - actedIn_Movie: { - where: { node: { title: $movieTitle } }, - properties: { screenTime: $screenTime } + actedIn: { + Movie: { + where: { node: { title: $movieTitle } } + properties: { screenTime: $screenTime } + } } } ) { diff --git a/packages/graphql/tests/integration/relationship-properties/create.int.test.ts b/packages/graphql/tests/integration/relationship-properties/create.int.test.ts index a289766da1..aec52fffc9 100644 --- a/packages/graphql/tests/integration/relationship-properties/create.int.test.ts +++ b/packages/graphql/tests/integration/relationship-properties/create.int.test.ts @@ -153,11 +153,13 @@ describe("Relationship properties - create", () => { input: [ { name: $actorName - publications_Movie: { - create: [{ - properties: { words: $words }, - node: { title: $movieTitle } - }] + publications: { + Movie: { + create: [{ + properties: { words: $words }, + node: { title: $movieTitle } + }] + } } } ] diff --git a/packages/graphql/tests/integration/relationship-properties/delete.int.test.ts b/packages/graphql/tests/integration/relationship-properties/delete.int.test.ts new file mode 100644 index 0000000000..c29d5f3a20 --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/delete.int.test.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Relationship properties - delete", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should delete a related node for a relationship that has properties", async () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $actorName: String!) { + updateMovies( + where: { title: $movieTitle } + delete: { actors: { where: { node: { name: $actorName } } } } + ) { + movies { + title + actors { + name + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieTitle, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + variableValues: { movieTitle, actorName }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actors: [], + }, + ]); + + const cypher = ` + MATCH (a:Actor {name: $actorName}) + RETURN a + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("should delete a related node for a relationship that has properties (with Union)", async () => { + const typeDefs = gql` + type Movie { + title: String! + } + + type Show { + name: String! + } + + type Actor { + name: String! + actedIn: [ActedInUnion!]! + @relationship(type: "ACTED_IN", properties: "ActedInInterface", direction: OUT) + } + + union ActedInUnion = Movie | Show + + interface ActedInInterface { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($screenTime: Int!, $actorName: String!) { + updateActors( + where: { name: $actorName } + delete: { + actedIn: { + Movie: { + where: { relationship: { screenTime: $screenTime } } + } + } + } + ) { + actors { + name + actedIn { + __typename + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieTitle, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + variableValues: { actorName, screenTime }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateActors.actors).toEqual([ + { + name: actorName, + actedIn: [], + }, + ]); + + const cypher = ` + MATCH (m:Movie {title: $movieTitle}) + RETURN m + `; + + const neo4jResult = await session.run(cypher, { movieTitle }); + expect(neo4jResult.records).toHaveLength(0); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/relationship-properties/disconnect.int.test.ts b/packages/graphql/tests/integration/relationship-properties/disconnect.int.test.ts new file mode 100644 index 0000000000..5a2a655583 --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/disconnect.int.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Relationship properties - disconnect", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should disconnect a relationship that has properties", async () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $actorName: String!) { + updateMovies( + where: { title: $movieTitle } + disconnect: { actors: { where: { node: { name: $actorName } } } } + ) { + movies { + title + actors { + name + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieTitle, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + variableValues: { movieTitle, actorName }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actors: [], + }, + ]); + + const cypher = ` + MATCH (:Movie {title: $movieTitle}) + <-[actedIn:ACTED_IN {screenTime: $screenTime}]- + (:Actor {name: $actorName}) + RETURN actedIn + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("should disconnect a relationship that has properties (with Union)", async () => { + const typeDefs = gql` + type Movie { + title: String! + } + + type Show { + name: String! + } + + type Actor { + name: String! + actedIn: [ActedInUnion!]! + @relationship(type: "ACTED_IN", properties: "ActedInInterface", direction: OUT) + } + + union ActedInUnion = Movie | Show + + interface ActedInInterface { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($screenTime: Int!, $actorName: String!) { + updateActors( + where: { name: $actorName } + disconnect: { + actedIn: { + Movie: { + where: { relationship: { screenTime: $screenTime } } + } + } + } + ) { + actors { + name + actedIn { + __typename + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieTitle, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver }, + variableValues: { actorName, screenTime }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateActors.actors).toEqual([ + { + name: actorName, + actedIn: [], + }, + ]); + + const cypher = ` + MATCH (:Actor {name: $actorName}) + -[actedIn:ACTED_IN {screenTime: $screenTime}]-> + (:Movie {title: $movieTitle}) + RETURN actedIn + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(0); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/unions.int.test.ts b/packages/graphql/tests/integration/unions.int.test.ts index c3eba1902c..425c2f6c0d 100644 --- a/packages/graphql/tests/integration/unions.int.test.ts +++ b/packages/graphql/tests/integration/unions.int.test.ts @@ -138,12 +138,14 @@ describe("unions", () => { mutation { createMovies(input: [{ title: "${movieTitle}", - search_Genre: { - create: [{ - node: { - name: "${genreName}" - } - }] + search: { + Genre: { + create: [{ + node: { + name: "${genreName}" + } + }] + } } }]) { movies { @@ -176,6 +178,107 @@ describe("unions", () => { } }); + test("should create multiple nested unions", async () => { + const session = driver.session(); + + const typeDefs = ` + union Search = Movie | Genre + + type Genre { + name: String + } + + type Movie { + title: String + search: [Search] @relationship(type: "SEARCH", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: {}, + }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const genreName = generate({ + charset: "alphabetic", + }); + + const nestedMovieTitle = generate({ + charset: "alphabetic", + }); + + const mutation = ` + mutation { + createMovies(input: [{ + title: "${movieTitle}", + search: { + Genre: { + create: [{ + node: { + name: "${genreName}" + } + }] + } + Movie: { + create: [{ + node: { + title: "${nestedMovieTitle}" + } + }] + } + } + }]) { + movies { + title + search { + __typename + ...on Genre { + name + } + ... on Movie { + title + } + } + } + } + } + `; + + try { + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver }, + }); + + expect(gqlResult.errors).toBeFalsy(); + // expect((gqlResult.data as any).createMovies.movies[0]).toEqual({ + // title: movieTitle, + // search: [ + // { __typename: "Genre", name: genreName }, + // { __typename: "Movie", title: nestedMovieTitle }, + // ], + // }); + + expect((gqlResult.data as any).createMovies.movies[0].title).toEqual(movieTitle); + expect((gqlResult.data as any).createMovies.movies[0].search).toHaveLength(2); + expect((gqlResult.data as any).createMovies.movies[0].search).toContainEqual({ + __typename: "Genre", + name: genreName, + }); + expect((gqlResult.data as any).createMovies.movies[0].search).toContainEqual({ + __typename: "Movie", + title: nestedMovieTitle, + }); + } finally { + await session.close(); + } + }); + test("should connect to a union", async () => { const session = driver.session(); @@ -209,10 +312,12 @@ describe("unions", () => { mutation { createMovies(input: [{ title: "${movieTitle}", - search_Genre: { - connect: [{ - where: { node: { name: "${genreName}" } } - }] + search: { + Genre: { + connect: [{ + where: { node: { name: "${genreName}" } } + }] + } } }]) { movies { @@ -287,11 +392,11 @@ describe("unions", () => { updateMovies( where: { title: "${movieTitle}" }, update: { - search_Genre: { - where: { Genre: { name: "${genreName}" } }, - update: { - node: { - name: "${newGenreName}" + search: { + Genre: { + where: { node: { name: "${genreName}" } }, + update: { + node: { name: "${newGenreName}" } } } } @@ -331,6 +436,112 @@ describe("unions", () => { } }); + test("should update multiple unions", async () => { + const session = driver.session(); + + const typeDefs = ` + union Search = Movie | Genre + + type Genre { + name: String + } + + type Movie { + title: String + search: [Search] @relationship(type: "SEARCH", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: {}, + }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const genreName = generate({ + charset: "alphabetic", + }); + + const newGenreName = generate({ + charset: "alphabetic", + }); + + const nestedMovieTitle = generate({ + charset: "alphabetic", + }); + + const newNestedMovieTitle = generate({ + charset: "alphabetic", + }); + + const mutation = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" }, + update: { + search: { + Genre: { + where: { node: { name: "${genreName}" } }, + update: { + node: { name: "${newGenreName}" } + } + } + Movie: { + where: { node: { title: "${nestedMovieTitle}" } }, + update: { + node: { title: "${newNestedMovieTitle}" } + } + } + } + } + ) { + movies { + title + search { + __typename + ...on Genre { + name + } + ...on Movie { + title + } + } + } + } + } + `; + + try { + await session.run(` + CREATE (m:Movie {title: "${movieTitle}"})-[:SEARCH]->(:Genre {name: "${genreName}"}) + CREATE (m)-[:SEARCH]->(:Movie {title: "${nestedMovieTitle}"}) + `); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver }, + }); + + expect(gqlResult.errors).toBeFalsy(); + expect((gqlResult.data as any).updateMovies.movies[0].title).toEqual(movieTitle); + expect((gqlResult.data as any).updateMovies.movies[0].search).toHaveLength(2); + expect((gqlResult.data as any).updateMovies.movies[0].search).toContainEqual({ + __typename: "Genre", + name: newGenreName, + }); + expect((gqlResult.data as any).updateMovies.movies[0].search).toContainEqual({ + __typename: "Movie", + title: newNestedMovieTitle, + }); + } finally { + await session.close(); + } + }); + test("should disconnect from a union", async () => { const session = driver.session(); @@ -365,10 +576,12 @@ describe("unions", () => { updateMovies( where: { title: "${movieTitle}" }, update: { - search_Genre: { - disconnect: [{ - where: { node: { name: "${genreName}" } } - }] + search: { + Genre: { + disconnect: [{ + where: { node: { name: "${genreName}" } } + }] + } } } ) { diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/allow.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/allow.md index b0cb95be02..9059b772d3 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/allow.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/allow.md @@ -802,7 +802,7 @@ FOREACH(_ IN CASE this_post0_disconnect0 WHEN NULL THEN [] ELSE [1] END | WITH this, this_post0_disconnect0 OPTIONAL MATCH (this_post0_disconnect0)<-[this_post0_disconnect0_creator0_rel:HAS_POST]-(this_post0_disconnect0_creator0:User) -WHERE this_post0_disconnect0_creator0.id = $updateComments.args.update.post.disconnect.creator[0].disconnect.where.node.id +WHERE this_post0_disconnect0_creator0.id = $updateComments.args.update.post.disconnect.disconnect.creator.where.node.id WITH this, this_post0_disconnect0, this_post0_disconnect0_creator0, this_post0_disconnect0_creator0_rel CALL apoc.util.validate(NOT(EXISTS((this_post0_disconnect0_creator0)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_post0_disconnect0_creator0)<-[:HAS_POST]-(creator:User) | creator] WHERE EXISTS(creator.id) AND creator.id = $this_post0_disconnect0_creator0Post0_allow_auth_allow0_creator_id) AND EXISTS(this_post0_disconnect0_creator0.id) AND this_post0_disconnect0_creator0.id = $this_post0_disconnect0_creator0User1_allow_auth_allow0_id), "@neo4j/graphql/FORBIDDEN", [0]) diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/nested-unions.md b/packages/graphql/tests/tck/tck-test-files/cypher/nested-unions.md new file mode 100644 index 0000000000..bf88220f41 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/nested-unions.md @@ -0,0 +1,192 @@ +## Nested Unions + +Tests for edge cases where either end of a relationship might be a union. + +Schema: + +```schema +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +type Series { + name: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +union Production = Movie | Series + +type LeadActor { + name: String! + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) +} + +type Extra { + name: String + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) +} + +union Actor = LeadActor | Extra +``` + +--- + +### Nested Unions - Connect -> Connect + +**GraphQL input** + +```graphql +mutation { + updateMovies( + where: { title: "Movie" } + connect: { + actors: { + LeadActor: { + where: { node: { name: "Actor" } } + connect: { + actedIn: { + Series: { where: { node: { name: "Series" } } } + } + } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +OPTIONAL MATCH (this_connect_actors_LeadActor0_node:LeadActor) +WHERE this_connect_actors_LeadActor0_node.name = $this_connect_actors_LeadActor0_node_name +FOREACH(_ IN CASE this_connect_actors_LeadActor0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)<-[:ACTED_IN]-(this_connect_actors_LeadActor0_node) ) +WITH this, this_connect_actors_LeadActor0_node +OPTIONAL MATCH (this_connect_actors_LeadActor0_node_actedIn_Series0_node:Series) +WHERE this_connect_actors_LeadActor0_node_actedIn_Series0_node.name = $this_connect_actors_LeadActor0_node_actedIn_Series0_node_name +FOREACH(_ IN CASE this_connect_actors_LeadActor0_node_actedIn_Series0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this_connect_actors_LeadActor0_node)-[:ACTED_IN]->(this_connect_actors_LeadActor0_node_actedIn_Series0_node) ) +RETURN this { .title, actors: [(this)<-[:ACTED_IN]-(this_actors) WHERE "LeadActor" IN labels(this_actors) OR "Extra" IN labels(this_actors) | head( [ this_actors IN [this_actors] WHERE "LeadActor" IN labels (this_actors) | this_actors { __resolveType: "LeadActor", .name, actedIn: [(this_actors)-[:ACTED_IN]->(this_actors_actedIn) WHERE "Movie" IN labels(this_actors_actedIn) OR "Series" IN labels(this_actors_actedIn) | head( [ this_actors_actedIn IN [this_actors_actedIn] WHERE "Movie" IN labels (this_actors_actedIn) | this_actors_actedIn { __resolveType: "Movie" } ] + [ this_actors_actedIn IN [this_actors_actedIn] WHERE "Series" IN labels (this_actors_actedIn) | this_actors_actedIn { __resolveType: "Series", .name } ] ) ] } ] + [ this_actors IN [this_actors] WHERE "Extra" IN labels (this_actors) | this_actors { __resolveType: "Extra" } ] ) ] } AS this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_connect_actors_LeadActor0_node_actedIn_Series0_node_name": "Series", + "this_connect_actors_LeadActor0_node_name": "Actor", + "this_title": "Movie" +} +``` + +--- + +### Nested Unions - Disconnect -> Disconnect + +**GraphQL input** + +```graphql +mutation { + updateMovies( + where: { title: "Movie" } + disconnect: { + actors: { + LeadActor: { + where: { node: { name: "Actor" } } + disconnect: { + actedIn: { + Series: { where: { node: { name: "Series" } } } + } + } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title WITH this +OPTIONAL MATCH (this)<-[this_disconnect_actors_LeadActor0_rel:ACTED_IN]-(this_disconnect_actors_LeadActor0:LeadActor) +WHERE this_disconnect_actors_LeadActor0.name = $updateMovies.args.disconnect.actors.LeadActor[0].where.node.name +FOREACH(_ IN CASE this_disconnect_actors_LeadActor0 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_actors_LeadActor0_rel ) +WITH this, this_disconnect_actors_LeadActor0 +OPTIONAL MATCH (this_disconnect_actors_LeadActor0)-[this_disconnect_actors_LeadActor0_actedIn_Series0_rel:ACTED_IN]->(this_disconnect_actors_LeadActor0_actedIn_Series0:Series) +WHERE this_disconnect_actors_LeadActor0_actedIn_Series0.name = $updateMovies.args.disconnect.actors.LeadActor[0].disconnect.actedIn.Series[0].where.node.name +FOREACH(_ IN CASE this_disconnect_actors_LeadActor0_actedIn_Series0 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_actors_LeadActor0_actedIn_Series0_rel ) +RETURN this { .title, actors: [(this)<-[:ACTED_IN]-(this_actors) WHERE "LeadActor" IN labels(this_actors) OR "Extra" IN labels(this_actors) | head( [ this_actors IN [this_actors] WHERE "LeadActor" IN labels (this_actors) | this_actors { __resolveType: "LeadActor", .name, actedIn: [(this_actors)-[:ACTED_IN]->(this_actors_actedIn) WHERE "Movie" IN labels(this_actors_actedIn) OR "Series" IN labels(this_actors_actedIn) | head( [ this_actors_actedIn IN [this_actors_actedIn] WHERE "Movie" IN labels (this_actors_actedIn) | this_actors_actedIn { __resolveType: "Movie" } ] + [ this_actors_actedIn IN [this_actors_actedIn] WHERE "Series" IN labels (this_actors_actedIn) | this_actors_actedIn { __resolveType: "Series", .name } ] ) ] } ] + [ this_actors IN [this_actors] WHERE "Extra" IN labels (this_actors) | this_actors { __resolveType: "Extra" } ] ) ] } AS this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "Movie", + "updateMovies": { + "args": { + "disconnect": { + "actors": { + "LeadActor": [ + { + "disconnect": { + "actedIn": { + "Series": [ + { + "where": { + "node": { + "name": "Series" + } + } + } + ] + } + }, + "where": { + "node": { + "name": "Actor" + } + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/operations/disconnect.md b/packages/graphql/tests/tck/tck-test-files/cypher/operations/disconnect.md new file mode 100644 index 0000000000..239361ade7 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/operations/disconnect.md @@ -0,0 +1,173 @@ +## Cypher Disconnect + +Tests connect operations. + +Schema: + +```schema +type Product { + id: ID! + name: String + sizes: [Size] @relationship(type: "HAS_SIZE", direction: OUT) + colors: [Color] @relationship(type: "HAS_COLOR", direction: OUT) + photos: [Photo] @relationship(type: "HAS_PHOTO", direction: OUT) +} + +type Size { + id: ID! + name: String! +} + +type Color { + id: ID! + name: String! + photos: [Photo] @relationship(type: "OF_COLOR", direction: IN) +} + +type Photo { + id: ID! + description: String! + url: String! + color: Color @relationship(type: "OF_COLOR", direction: OUT) +} +``` + +--- + +### Recursive Connect + +**GraphQL input** + +```graphql +mutation { + createProducts( + input: [ + { + id: "123" + name: "Nested Connect" + colors: { + connect: [ + { + where: { node: { name: "Red" } } + connect: { + photos: [ + { + where: { node: { id: "123" } } + connect: { + color: { + where: { node: { id: "134" } } + } + } + } + ] + } + } + ] + } + photos: { + connect: [ + { + where: { node: { id: "321" } } + connect: { + color: { where: { node: { name: "Green" } } } + } + } + { + where: { node: { id: "33211" } } + connect: { + color: { where: { node: { name: "Red" } } } + } + } + ] + } + } + ] + ) { + products { + id + } + } +} +``` + +**Expected Cypher output** + +```cypher +CALL { + CREATE (this0:Product) + SET this0.id = $this0_id + SET this0.name = $this0_name + + WITH this0 + OPTIONAL MATCH (this0_colors_connect0_node:Color) + WHERE this0_colors_connect0_node.name = $this0_colors_connect0_node_name + FOREACH(_ IN CASE this0_colors_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_COLOR]->(this0_colors_connect0_node) + ) + + WITH this0, this0_colors_connect0_node + OPTIONAL MATCH (this0_colors_connect0_node_photos0_node:Photo) + WHERE this0_colors_connect0_node_photos0_node.id = $this0_colors_connect0_node_photos0_node_id + FOREACH(_ IN CASE this0_colors_connect0_node_photos0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_colors_connect0_node)<-[:OF_COLOR]-(this0_colors_connect0_node_photos0_node) + ) + + WITH this0, this0_colors_connect0_node, this0_colors_connect0_node_photos0_node + OPTIONAL MATCH (this0_colors_connect0_node_photos0_node_color0_node:Color) + WHERE this0_colors_connect0_node_photos0_node_color0_node.id = $this0_colors_connect0_node_photos0_node_color0_node_id + FOREACH(_ IN CASE this0_colors_connect0_node_photos0_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_colors_connect0_node_photos0_node)-[:OF_COLOR]->(this0_colors_connect0_node_photos0_node_color0_node) + ) + + WITH this0 + OPTIONAL MATCH (this0_photos_connect0_node:Photo) + WHERE this0_photos_connect0_node.id = $this0_photos_connect0_node_id + FOREACH(_ IN CASE this0_photos_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_PHOTO]->(this0_photos_connect0_node) + ) + + WITH this0, this0_photos_connect0_node + OPTIONAL MATCH (this0_photos_connect0_node_color0_node:Color) + WHERE this0_photos_connect0_node_color0_node.name = $this0_photos_connect0_node_color0_node_name + FOREACH(_ IN CASE this0_photos_connect0_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_photos_connect0_node)-[:OF_COLOR]->(this0_photos_connect0_node_color0_node) + ) + + WITH this0 + OPTIONAL MATCH (this0_photos_connect1_node:Photo) + WHERE this0_photos_connect1_node.id = $this0_photos_connect1_node_id + FOREACH(_ IN CASE this0_photos_connect1_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_PHOTO]->(this0_photos_connect1_node) + ) + + WITH this0, this0_photos_connect1_node + OPTIONAL MATCH (this0_photos_connect1_node_color0_node:Color) + WHERE this0_photos_connect1_node_color0_node.name = $this0_photos_connect1_node_color0_node_name + FOREACH(_ IN CASE this0_photos_connect1_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_photos_connect1_node)-[:OF_COLOR]->(this0_photos_connect1_node_color0_node) + ) + + RETURN this0 +} + +RETURN +this0 { .id } AS this0 +``` + +**Expected Cypher params** + +```cypher-params +{ + "this0_id": "123", + "this0_name": "Nested Connect", + "this0_colors_connect0_node_name": "Red", + "this0_colors_connect0_node_photos0_node_id": "123", + "this0_colors_connect0_node_photos0_node_color0_node_id": "134", + "this0_photos_connect0_node_id": "321", + "this0_photos_connect0_node_color0_node_name": "Green", + "this0_photos_connect1_node_id": "33211", + "this0_photos_connect1_node_color0_node_name": "Red" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/union.md b/packages/graphql/tests/tck/tck-test-files/cypher/union.md index 54310072aa..ecaef9d6a7 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/union.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/union.md @@ -109,7 +109,9 @@ mutation { input: [ { title: "some movie" - search_Genre: { create: [{ node: { name: "some genre" } }] } + search: { + Genre: { create: [{ node: { name: "some genre" } }] } + } } ] ) { @@ -157,7 +159,9 @@ RETURN this0 { ```graphql mutation { - updateMovies(create: { search_Genre: [{ node: { name: "some genre" } }] }) { + updateMovies( + create: { search: { Genre: [{ node: { name: "some genre" } }] } } + ) { movies { title } @@ -185,7 +189,7 @@ RETURN this { .title } AS this --- -### Connect Unions +### Connect Unions (in create) **GraphQL input** @@ -195,8 +199,10 @@ mutation { input: [ { title: "some movie" - search_Genre: { - connect: [{ where: { node: { name: "some genre" } } }] + search: { + Genre: { + connect: [{ where: { node: { name: "some genre" } } }] + } } } ] @@ -249,9 +255,11 @@ mutation { updateMovies( where: { title: "some movie" } update: { - search_Genre: { - where: { Genre: { name: "some genre" } } - update: { node: { name: "some new genre" } } + search: { + Genre: { + where: { node: { name: "some genre" } } + update: { node: { name: "some new genre" } } + } } } ) { @@ -270,7 +278,7 @@ WHERE this.title = $this_title WITH this OPTIONAL MATCH (this)-[this_search0:SEARCH]->(this_search_Genre0:Genre) -WHERE this_search_Genre0.name = $updateMovies.args.update.search_Genre[0].where.Genre.name +WHERE this_search_Genre0.name = $updateMovies.args.update.search.Genre[0].where.node.name CALL apoc.do.when(this_search_Genre0 IS NOT NULL, " SET this_search_Genre0.name = $this_update_search_Genre0_name RETURN count(*) ", "", {this:this, updateMovies: $updateMovies, this_search_Genre0:this_search_Genre0, auth:$auth,this_update_search_Genre0_name:$this_update_search_Genre0_name}) YIELD value as _ RETURN this { .title } AS this @@ -280,39 +288,41 @@ RETURN this { .title } AS this ```cypher-params { - "this_title": "some movie", - "this_update_search_Genre0_name": "some new genre", - "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} - }, - "updateMovies": { - "args": { - "update": { - "search_Genre": [ - { - "update": { - "node": { - "name": "some new genre" - } - }, - "where": { - "Genre": { - "name": "some genre" - } + "this_title": "some movie", + "this_update_search_Genre0_name": "some new genre", + "auth": { + "isAuthenticated": true, + "roles": [], + "jwt": {} + }, + "updateMovies": { + "args": { + "update": { + "search": { + "Genre": [ + { + "update": { + "node": { + "name": "some new genre" + } + }, + "where": { + "node": { + "name": "some genre" + } + } + } + ] } - } - ] - } - } - } + } + } + } } ``` --- -### Disconnect Unions +### Disconnect Unions (in update) **GraphQL input** @@ -321,8 +331,10 @@ mutation { updateMovies( where: { title: "some movie" } update: { - search_Genre: { - disconnect: [{ where: { node: { name: "some genre" } } }] + search: { + Genre: { + disconnect: [{ where: { node: { name: "some genre" } } }] + } } } ) { @@ -341,7 +353,7 @@ WHERE this.title = $this_title WITH this OPTIONAL MATCH (this)-[this_search_Genre0_disconnect0_rel:SEARCH]->(this_search_Genre0_disconnect0:Genre) -WHERE this_search_Genre0_disconnect0.name = $updateMovies.args.update.search_Genre[0].disconnect[0].where.node.name +WHERE this_search_Genre0_disconnect0.name = $updateMovies.args.update.search.Genre[0].disconnect[0].where.node.name FOREACH(_ IN CASE this_search_Genre0_disconnect0 WHEN NULL THEN [] ELSE [1] END | DELETE this_search_Genre0_disconnect0_rel ) @@ -357,21 +369,188 @@ RETURN this { .title } AS this "updateMovies": { "args": { "update": { - "search_Genre": [ - { - "disconnect": [ - { - "where": { - "node": { - "name": "some genre" + "search": { + "Genre": [ + { + "disconnect": [ + { + "where": { + "node": { + "name": "some genre" + } } } + ] + } + ] + } + } + } + } +} +``` + +--- + +### Disconnect Unions + +**GraphQL input** + +```graphql +mutation { + updateMovies( + where: { title: "some movie" } + disconnect: { + search: { Genre: { where: { node: { name: "some genre" } } } } + } + ) { + movies { + title + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title + +WITH this +OPTIONAL MATCH (this)-[this_disconnect_search_Genre0_rel:SEARCH]->(this_disconnect_search_Genre0:Genre) +WHERE this_disconnect_search_Genre0.name = $updateMovies.args.disconnect.search.Genre[0].where.node.name +FOREACH(_ IN CASE this_disconnect_search_Genre0 WHEN NULL THEN [] ELSE [1] END | + DELETE this_disconnect_search_Genre0_rel +) + +RETURN this { .title } AS this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "some movie", + "updateMovies": { + "args": { + "disconnect": { + "search": { + "Genre": [ + { + "where": { + "node": { + "name": "some genre" + } } - ] - } - ] + } + ] + } } } } } ``` + +--- + +### Connect Unions (in update) + +**GraphQL input** + +```graphql +mutation { + updateMovies( + where: { title: "some movie" } + connect: { + search: { Genre: { where: { node: { name: "some genre" } } } } + } + ) { + movies { + title + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +OPTIONAL MATCH (this_connect_search_Genre0_node:Genre) +WHERE this_connect_search_Genre0_node.name = $this_connect_search_Genre0_node_name +FOREACH(_ IN CASE this_connect_search_Genre0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:SEARCH]->(this_connect_search_Genre0_node) ) +RETURN this { .title } AS this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "some movie", + "this_connect_search_Genre0_node_name": "some genre" +} + +``` + +--- + +### Delete Unions (from update) + +**GraphQL input** + +```graphql +mutation { + updateMovies( + where: { title: "some movie" } + delete: { + search: { Genre: { where: { node: { name: "some genre" } } } } + } + ) { + movies { + title + } + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +OPTIONAL MATCH (this)-[this_delete_search_Genre0_relationship:SEARCH]->(this_delete_search_Genre0:Genre) +WHERE this_delete_search_Genre0.name = $updateMovies.args.delete.search.Genre[0].where.node.name +FOREACH(_ IN CASE this_delete_search_Genre0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_delete_search_Genre0 ) +RETURN this { .title } AS this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_title": "some movie", + "updateMovies": { + "args": { + "delete": { + "search": { + "Genre": [ + { + "where": { + "node": { + "name": "some genre" + } + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md b/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md index e1a63eb972..1bad2fe854 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md @@ -13,17 +13,20 @@ union Publication = Book | Journal type Author { name: String! - publications: [Publication] @relationship(type: "WROTE", direction: OUT, properties: "Wrote") + publications: [Publication] + @relationship(type: "WROTE", direction: OUT, properties: "Wrote") } type Book { title: String! - author: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") + author: [Author!]! + @relationship(type: "WROTE", direction: IN, properties: "Wrote") } type Journal { subject: String! - author: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") + author: [Author!]! + @relationship(type: "WROTE", direction: IN, properties: "Wrote") } interface Wrote { @@ -43,24 +46,20 @@ type Author { } input AuthorConnectInput { - publications_Book: [AuthorPublicationsConnectFieldInput!] - publications_Journal: [AuthorPublicationsConnectFieldInput!] + publications: AuthorPublicationsConnectInput } input AuthorCreateInput { name: String! - publications_Book: AuthorPublicationsBookFieldInput - publications_Journal: AuthorPublicationsJournalFieldInput + publications: AuthorPublicationsCreateInput } input AuthorDeleteInput { - publications_Book: [AuthorPublicationsBookDeleteFieldInput!] - publications_Journal: [AuthorPublicationsJournalDeleteFieldInput!] + publications: AuthorPublicationsDeleteInput } input AuthorDisconnectInput { - publications_Book: [AuthorPublicationsBookDisconnectFieldInput!] - publications_Journal: [AuthorPublicationsJournalDisconnectFieldInput!] + publications: AuthorPublicationsDisconnectInput } input AuthorOptions { @@ -97,8 +96,8 @@ input AuthorPublicationsBookDisconnectFieldInput { } input AuthorPublicationsBookFieldInput { + connect: [AuthorPublicationsBookConnectFieldInput!] create: [AuthorPublicationsBookCreateFieldInput!] - connect: [AuthorPublicationsConnectFieldInput!] } input AuthorPublicationsBookUpdateConnectionInput { @@ -107,9 +106,9 @@ input AuthorPublicationsBookUpdateConnectionInput { } input AuthorPublicationsBookUpdateFieldInput { - where: AuthorPublicationsConnectionWhere + where: AuthorPublicationsBookConnectionWhere update: AuthorPublicationsBookUpdateConnectionInput - connect: [AuthorPublicationsConnectFieldInput!] + connect: [AuthorPublicationsBookConnectFieldInput!] disconnect: [AuthorPublicationsBookDisconnectFieldInput!] create: [AuthorPublicationsBookCreateFieldInput!] delete: [AuthorPublicationsBookDeleteFieldInput!] @@ -119,12 +118,22 @@ input BookConnectWhere { node: BookWhere! } -input AuthorPublicationsConnectFieldInput { +input JournalConnectWhere { + node: JournalWhere! +} + +input AuthorPublicationsBookConnectFieldInput { where: BookConnectWhere connect: [BookConnectInput!] properties: WroteCreateInput! } +input AuthorPublicationsJournalConnectFieldInput { + where: JournalConnectWhere + connect: [JournalConnectInput!] + properties: WroteCreateInput! +} + type AuthorPublicationsConnection { edges: [AuthorPublicationsRelationship!]! pageInfo: PageInfo! @@ -142,6 +151,36 @@ input AuthorPublicationsConnectionWhere { Journal_NOT: JournalWhere } +input AuthorPublicationsConnectInput { + Book: [AuthorPublicationsBookConnectFieldInput!] + Journal: [AuthorPublicationsJournalConnectFieldInput!] +} + +input AuthorPublicationsCreateFieldInput { + Book: [AuthorPublicationsBookCreateFieldInput!] + Journal: [AuthorPublicationsJournalCreateFieldInput!] +} + +input AuthorPublicationsCreateInput { + Book: AuthorPublicationsBookFieldInput + Journal: AuthorPublicationsJournalFieldInput +} + +input AuthorPublicationsDeleteInput { + Book: [AuthorPublicationsBookDeleteFieldInput!] + Journal: [AuthorPublicationsJournalDeleteFieldInput!] +} + +input AuthorPublicationsDisconnectInput { + Book: [AuthorPublicationsBookDisconnectFieldInput!] + Journal: [AuthorPublicationsJournalDisconnectFieldInput!] +} + +input AuthorPublicationsUpdateInput { + Book: [AuthorPublicationsBookUpdateFieldInput!] + Journal: [AuthorPublicationsJournalUpdateFieldInput!] +} + input AuthorPublicationsJournalConnectionWhere { node: JournalWhere node_NOT: JournalWhere @@ -167,8 +206,8 @@ input AuthorPublicationsJournalDisconnectFieldInput { } input AuthorPublicationsJournalFieldInput { + connect: [AuthorPublicationsJournalConnectFieldInput!] create: [AuthorPublicationsJournalCreateFieldInput!] - connect: [AuthorPublicationsConnectFieldInput!] } input AuthorPublicationsJournalUpdateConnectionInput { @@ -177,9 +216,9 @@ input AuthorPublicationsJournalUpdateConnectionInput { } input AuthorPublicationsJournalUpdateFieldInput { - where: AuthorPublicationsConnectionWhere + where: AuthorPublicationsJournalConnectionWhere update: AuthorPublicationsJournalUpdateConnectionInput - connect: [AuthorPublicationsConnectFieldInput!] + connect: [AuthorPublicationsJournalConnectFieldInput!] disconnect: [AuthorPublicationsJournalDisconnectFieldInput!] create: [AuthorPublicationsJournalCreateFieldInput!] delete: [AuthorPublicationsJournalDeleteFieldInput!] @@ -192,8 +231,7 @@ type AuthorPublicationsRelationship implements Wrote { } input AuthorRelationInput { - publications_Book: [AuthorPublicationsBookCreateFieldInput!] - publications_Journal: [AuthorPublicationsJournalCreateFieldInput!] + publications: AuthorPublicationsCreateFieldInput } """ @@ -205,8 +243,7 @@ input AuthorSort { input AuthorUpdateInput { name: String - publications_Book: [AuthorPublicationsBookUpdateFieldInput!] - publications_Journal: [AuthorPublicationsJournalUpdateFieldInput!] + publications: AuthorPublicationsUpdateInput } input AuthorWhere { diff --git a/packages/graphql/tests/tck/tck-test-files/schema/unions.md b/packages/graphql/tests/tck/tck-test-files/schema/unions.md index 9c4dd0f19b..6080b443fb 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/unions.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/unions.md @@ -94,24 +94,20 @@ type Movie { } input MovieConnectInput { - search_Genre: [MovieSearchConnectFieldInput!] - search_Movie: [MovieSearchConnectFieldInput!] + search: MovieSearchConnectInput } input MovieCreateInput { id: ID - search_Genre: MovieSearchGenreFieldInput - search_Movie: MovieSearchMovieFieldInput + search: MovieSearchCreateInput } input MovieDeleteInput { - search_Genre: [MovieSearchGenreDeleteFieldInput!] - search_Movie: [MovieSearchMovieDeleteFieldInput!] + search: MovieSearchDeleteInput } input MovieDisconnectInput { - search_Genre: [MovieSearchGenreDisconnectFieldInput!] - search_Movie: [MovieSearchMovieDisconnectFieldInput!] + search: MovieSearchDisconnectInput } input MovieOptions { @@ -123,19 +119,32 @@ input MovieOptions { offset: Int } +input MovieSearchCreateFieldInput { + Genre: [MovieSearchGenreCreateFieldInput!] + Movie: [MovieSearchMovieCreateFieldInput!] +} + input MovieRelationInput { - search_Genre: [MovieSearchGenreCreateFieldInput!] - search_Movie: [MovieSearchMovieCreateFieldInput!] + search: MovieSearchCreateFieldInput } input GenreConnectWhere { node: GenreWhere! } -input MovieSearchConnectFieldInput { +input MovieConnectWhere { + node: MovieWhere! +} + +input MovieSearchGenreConnectFieldInput { where: GenreConnectWhere } +input MovieSearchMovieConnectFieldInput { + where: MovieConnectWhere + connect: [MovieConnectInput!] +} + type MovieSearchConnection { edges: [MovieSearchRelationship!]! pageInfo: PageInfo! @@ -155,6 +164,31 @@ input MovieSearchGenreCreateFieldInput { node: GenreCreateInput! } +input MovieSearchConnectInput { + Genre: [MovieSearchGenreConnectFieldInput!] + Movie: [MovieSearchMovieConnectFieldInput!] +} + +input MovieSearchCreateInput { + Genre: MovieSearchGenreFieldInput + Movie: MovieSearchMovieFieldInput +} + +input MovieSearchDeleteInput { + Genre: [MovieSearchGenreDeleteFieldInput!] + Movie: [MovieSearchMovieDeleteFieldInput!] +} + +input MovieSearchDisconnectInput { + Genre: [MovieSearchGenreDisconnectFieldInput!] + Movie: [MovieSearchMovieDisconnectFieldInput!] +} + +input MovieSearchUpdateInput { + Genre: [MovieSearchGenreUpdateFieldInput!] + Movie: [MovieSearchMovieUpdateFieldInput!] +} + input MovieSearchGenreConnectionWhere { node: GenreWhere node_NOT: GenreWhere @@ -172,7 +206,7 @@ input MovieSearchGenreDeleteFieldInput { input MovieSearchGenreFieldInput { create: [MovieSearchGenreCreateFieldInput!] - connect: [MovieSearchConnectFieldInput!] + connect: [MovieSearchGenreConnectFieldInput!] } input MovieSearchGenreUpdateConnectionInput { @@ -180,9 +214,9 @@ input MovieSearchGenreUpdateConnectionInput { } input MovieSearchGenreUpdateFieldInput { - where: MovieSearchConnectionWhere + where: MovieSearchGenreConnectionWhere update: MovieSearchGenreUpdateConnectionInput - connect: [MovieSearchConnectFieldInput!] + connect: [MovieSearchGenreConnectFieldInput!] disconnect: [MovieSearchGenreDisconnectFieldInput!] create: [MovieSearchGenreCreateFieldInput!] delete: [MovieSearchGenreDeleteFieldInput!] @@ -211,7 +245,7 @@ input MovieSearchMovieDeleteFieldInput { input MovieSearchMovieFieldInput { create: [MovieSearchMovieCreateFieldInput!] - connect: [MovieSearchConnectFieldInput!] + connect: [MovieSearchMovieConnectFieldInput!] } input MovieSearchMovieUpdateConnectionInput { @@ -219,9 +253,9 @@ input MovieSearchMovieUpdateConnectionInput { } input MovieSearchMovieUpdateFieldInput { - where: MovieSearchConnectionWhere + where: MovieSearchMovieConnectionWhere update: MovieSearchMovieUpdateConnectionInput - connect: [MovieSearchConnectFieldInput!] + connect: [MovieSearchMovieConnectFieldInput!] disconnect: [MovieSearchMovieDisconnectFieldInput!] create: [MovieSearchMovieCreateFieldInput!] delete: [MovieSearchMovieDeleteFieldInput!] @@ -241,8 +275,7 @@ input MovieSort { input MovieUpdateInput { id: ID - search_Genre: [MovieSearchGenreUpdateFieldInput!] - search_Movie: [MovieSearchMovieUpdateFieldInput!] + search: MovieSearchUpdateInput } input MovieWhere {