From b276e13c7c01037d2c515e7a9b38e6df91997071 Mon Sep 17 00:00:00 2001 From: Darrell Warde Date: Mon, 7 Mar 2022 14:00:48 +0000 Subject: [PATCH 1/7] Basic single level delete with subscriptions meta --- .../graphql/src/schema/resolvers/delete.ts | 35 ++ .../graphql/src/translate/translate-delete.ts | 21 +- .../subscriptions/delete/delete.int.test.ts | 111 +++++ .../subscriptions/delete.test.ts | 420 ++++++++++++++++++ 4 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts create mode 100644 packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts diff --git a/packages/graphql/src/schema/resolvers/delete.ts b/packages/graphql/src/schema/resolvers/delete.ts index 7cf88e6b49..5b82df2e2d 100644 --- a/packages/graphql/src/schema/resolvers/delete.ts +++ b/packages/graphql/src/schema/resolvers/delete.ts @@ -23,6 +23,8 @@ import { execute } from "../../utils"; import { translateDelete } from "../../translate"; import { Context } from "../../types"; import { Node } from "../../classes"; +import { EventMeta, RawEventMeta } from "../../subscriptions/event-meta"; +import { serializeNeo4jValue } from "../../utils/neo4j-serializers"; export default function deleteResolver({ node }: { node: Node }) { async function resolve(_root: any, args: any, _context: unknown, info: GraphQLResolveInfo) { @@ -36,6 +38,16 @@ export default function deleteResolver({ node }: { node: Node }) { context, }); + const subscriptionsPlugin = context.plugins?.subscriptions; + if (subscriptionsPlugin) { + const metaData: RawEventMeta[] = executeResult.records[0]?.meta || []; + for (const meta of metaData) { + const serializedMeta = serializeEventMeta(meta); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + subscriptionsPlugin.publish(serializedMeta); + } + } + return { bookmark: executeResult.bookmark, ...executeResult.statistics }; } @@ -52,3 +64,26 @@ export default function deleteResolver({ node }: { node: Node }) { }, }; } + +function serializeProperties(properties: Record | undefined): Record | undefined { + if (!properties) { + return undefined; + } + + return Object.entries(properties).reduce((serializedProps, [k, v]) => { + serializedProps[k] = serializeNeo4jValue(v); + return serializedProps; + }, {} as Record); +} + +function serializeEventMeta(event: RawEventMeta): EventMeta { + return { + id: serializeNeo4jValue(event.id), + timestamp: serializeNeo4jValue(event.timestamp), + event: event.event, + properties: { + old: serializeProperties(event.properties.old), + new: serializeProperties(event.properties.new), + }, + } as EventMeta; +} diff --git a/packages/graphql/src/translate/translate-delete.ts b/packages/graphql/src/translate/translate-delete.ts index 6ffc848b76..7b8a3e4cbb 100644 --- a/packages/graphql/src/translate/translate-delete.ts +++ b/packages/graphql/src/translate/translate-delete.ts @@ -19,10 +19,11 @@ import { Node } from "../classes"; import { Context } from "../types"; -import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import { AUTH_FORBIDDEN_ERROR, META_CYPHER_VARIABLE } from "../constants"; import createAuthAndParams from "./create-auth-and-params"; import createDeleteAndParams from "./create-delete-and-params"; import translateTopLevelMatch from "./translate-top-level-match"; +import { createEventMeta } from "./subscriptions/create-event-meta"; function translateDelete({ context, node }: { context: Context; node: Node }): [string, any] { const { resolveTree } = context; @@ -71,7 +72,23 @@ function translateDelete({ context, node }: { context: Context; node: Node }): [ }; } - const cypher = [matchAndWhereStr, deleteStr, allowStr, `DETACH DELETE ${varName}`]; + const eventMeta = createEventMeta({ event: "delete", nodeVariable: varName }); + + const cypher = [ + ...(context.subscriptionsEnabled ? [`WITH [] AS ${META_CYPHER_VARIABLE}`] : []), + matchAndWhereStr, + ...(context.subscriptionsEnabled ? [`WITH ${varName}, ${eventMeta}`] : []), + deleteStr, + allowStr, + `DETACH DELETE ${varName}`, + ...(context.subscriptionsEnabled + ? [ + `WITH ${META_CYPHER_VARIABLE}`, + `UNWIND ${META_CYPHER_VARIABLE} AS m`, + `RETURN collect(DISTINCT m) AS meta`, + ] + : []), + ]; return [cypher.filter(Boolean).join("\n"), cypherParams]; } diff --git a/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts new file mode 100644 index 0000000000..aa823acb80 --- /dev/null +++ b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { gql } from "apollo-server"; +import { graphql } from "graphql"; +import { Driver, Session } from "neo4j-driver"; +import { Neo4jGraphQL } from "../../../../src"; +import { generateUniqueType } from "../../../utils/graphql-types"; +import { TestSubscriptionsPlugin } from "../../../utils/TestSubscriptionPlugin"; +import neo4j from "../../neo4j"; + +describe("Subscriptions delete", () => { + let driver: Driver; + let session: Session; + let neoSchema: Neo4jGraphQL; + let plugin: TestSubscriptionsPlugin; + + const typeActor = generateUniqueType("Actor"); + const typeMovie = generateUniqueType("Movie"); + + beforeAll(async () => { + driver = await neo4j(); + plugin = new TestSubscriptionsPlugin(); + const typeDefs = gql` + type ${typeActor.name} { + name: String! + movies: [${typeMovie.name}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + + type ${typeMovie.name} { + id: ID! + actors: [${typeActor.name}!]! @relationship(type: "ACTED_IN", direction: IN) + } + `; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + config: { enableRegex: true }, + plugins: { + subscriptions: plugin, + } as any, + }); + }); + + beforeEach(() => { + session = driver.session(); + }); + + afterEach(async () => { + await session.close(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("simple delete with subscriptions enabled", async () => { + const query = ` + mutation { + ${typeMovie.operations.delete} { + nodesDeleted + } + } + `; + + await session.run(` + CREATE (:${typeMovie.name} { id: "1" }) + CREATE (:${typeMovie.name} { id: "2" }) + `); + + const gqlResult: any = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: { driver }, + }); + + expect(gqlResult.errors).toBeUndefined(); + expect(gqlResult.data[typeMovie.operations.delete].nodesDeleted).toEqual(2); + + expect(plugin.eventList).toEqual([ + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "1" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "2" }, new: undefined }, + }, + ]); + }); +}); diff --git a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts new file mode 100644 index 0000000000..3584d04d23 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts @@ -0,0 +1,420 @@ +/* + * 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 { gql } from "apollo-server"; +import { DocumentNode } from "graphql"; +import { TestSubscriptionsPlugin } from "../../../utils/TestSubscriptionPlugin"; +import { Neo4jGraphQL } from "../../../../src"; +import { createJwtRequest } from "../../../utils/create-jwt-request"; +import { formatCypher, translateQuery, formatParams } from "../../utils/tck-test-utils"; + +describe("Subscriptions metadata on delete", () => { + let typeDefs: DocumentNode; + let neoSchema: Neo4jGraphQL; + let plugin: TestSubscriptionsPlugin; + + beforeAll(() => { + plugin = new TestSubscriptionsPlugin(); + typeDefs = gql` + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + + type Movie { + id: ID! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + `; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + config: { enableRegex: true }, + plugins: { + subscriptions: plugin, + } as any, + }); + }); + + test("Simple delete", async () => { + const query = gql` + mutation { + deleteMovies(where: { id: "1" }) { + nodesDeleted + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "WITH [] AS meta + MATCH (this:Movie) + WHERE this.id = $this_id + WITH this, meta + { event: \\"delete\\", id: id(this), properties: { old: this { .* }, new: null }, timestamp: timestamp() } AS meta + DETACH DELETE this + WITH meta + UNWIND meta AS m + RETURN collect(DISTINCT m) AS meta" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"this_id\\": \\"1\\" + }" + `); + }); + + // test("Multi Create", async () => { + // const query = gql` + // mutation { + // createMovies(input: [{ id: "1" }, { id: "2" }]) { + // movies { + // id + // } + // } + // } + // `; + + // const req = createJwtRequest("secret", {}); + // const result = await translateQuery(neoSchema, query, { + // req, + // }); + + // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + // "CALL { + // WITH [] AS meta + // CREATE (this0:Movie) + // SET this0.id = $this0_id + // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 + // RETURN this0, meta AS this0_meta + // } + // CALL { + // WITH [] AS meta + // CREATE (this1:Movie) + // SET this1.id = $this1_id + // WITH meta + { event: \\"create\\", id: id(this1), properties: { old: null, new: this1 { .* } }, timestamp: timestamp() } AS meta, this1 + // RETURN this1, meta AS this1_meta + // } + // WITH this0, this1, this0_meta + this1_meta AS meta + // RETURN [ + // this0 { .id }, + // this1 { .id }] AS data, meta" + // `); + + // expect(formatParams(result.params)).toMatchInlineSnapshot(` + // "{ + // \\"this0_id\\": \\"1\\", + // \\"this1_id\\": \\"2\\" + // }" + // `); + // }); + + // test("Nested Create", async () => { + // const query = gql` + // mutation { + // createMovies(input: [{ id: "1", actors: { create: { node: { name: "Andrés" } } } }]) { + // movies { + // id + // actors { + // name + // } + // } + // } + // } + // `; + + // const req = createJwtRequest("secret", {}); + // const result = await translateQuery(neoSchema, query, { + // req, + // }); + + // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + // "CALL { + // WITH [] AS meta + // CREATE (this0:Movie) + // SET this0.id = $this0_id + // CREATE (this0_actors0_node:Actor) + // SET this0_actors0_node.name = $this0_actors0_node_name + // WITH meta + { event: \\"create\\", id: id(this0_actors0_node), properties: { old: null, new: this0_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node + // MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) + // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 + // RETURN this0, meta AS this0_meta + // } + // WITH this0, this0_meta AS meta + // RETURN [ + // this0 { .id, actors: [ (this0)<-[:ACTED_IN]-(this0_actors:Actor) | this0_actors { .name } ] }] AS data, meta" + // `); + + // expect(formatParams(result.params)).toMatchInlineSnapshot(` + // "{ + // \\"this0_id\\": \\"1\\", + // \\"this0_actors0_node_name\\": \\"Andrés\\" + // }" + // `); + // }); + + // test("Triple nested Create", async () => { + // const query = gql` + // mutation { + // createMovies( + // input: [ + // { + // id: "1" + // actors: { create: { node: { name: "Andrés", movies: { create: { node: { id: 6 } } } } } } + // } + // ] + // ) { + // movies { + // id + // actors { + // name + // } + // } + // } + // } + // `; + + // const req = createJwtRequest("secret", {}); + // const result = await translateQuery(neoSchema, query, { + // req, + // }); + + // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + // "CALL { + // WITH [] AS meta + // CREATE (this0:Movie) + // SET this0.id = $this0_id + // CREATE (this0_actors0_node:Actor) + // SET this0_actors0_node.name = $this0_actors0_node_name + // CREATE (this0_actors0_node_movies0_node:Movie) + // SET this0_actors0_node_movies0_node.id = $this0_actors0_node_movies0_node_id + // WITH meta + { event: \\"create\\", id: id(this0_actors0_node_movies0_node), properties: { old: null, new: this0_actors0_node_movies0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node, this0_actors0_node_movies0_node + // MERGE (this0_actors0_node)-[:ACTED_IN]->(this0_actors0_node_movies0_node) + // WITH meta + { event: \\"create\\", id: id(this0_actors0_node), properties: { old: null, new: this0_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node + // MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) + // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 + // RETURN this0, meta AS this0_meta + // } + // WITH this0, this0_meta AS meta + // RETURN [ + // this0 { .id, actors: [ (this0)<-[:ACTED_IN]-(this0_actors:Actor) | this0_actors { .name } ] }] AS data, meta" + // `); + + // expect(formatParams(result.params)).toMatchInlineSnapshot(` + // "{ + // \\"this0_id\\": \\"1\\", + // \\"this0_actors0_node_name\\": \\"Andrés\\", + // \\"this0_actors0_node_movies0_node_id\\": \\"6\\" + // }" + // `); + // }); + + // test("Quadruple nested Create", async () => { + // const query = gql` + // mutation { + // createMovies( + // input: [ + // { + // id: "1" + // actors: { + // create: { + // node: { + // name: "Andrés" + // movies: { + // create: { + // node: { id: 6, actors: { create: { node: { name: "Thomas" } } } } + // } + // } + // } + // } + // } + // } + // ] + // ) { + // movies { + // id + // actors { + // name + // movies { + // id + // actors { + // name + // } + // } + // } + // } + // } + // } + // `; + + // const req = createJwtRequest("secret", {}); + // const result = await translateQuery(neoSchema, query, { + // req, + // }); + + // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + // "CALL { + // WITH [] AS meta + // CREATE (this0:Movie) + // SET this0.id = $this0_id + // CREATE (this0_actors0_node:Actor) + // SET this0_actors0_node.name = $this0_actors0_node_name + // CREATE (this0_actors0_node_movies0_node:Movie) + // SET this0_actors0_node_movies0_node.id = $this0_actors0_node_movies0_node_id + // CREATE (this0_actors0_node_movies0_node_actors0_node:Actor) + // SET this0_actors0_node_movies0_node_actors0_node.name = $this0_actors0_node_movies0_node_actors0_node_name + // WITH meta + { event: \\"create\\", id: id(this0_actors0_node_movies0_node_actors0_node), properties: { old: null, new: this0_actors0_node_movies0_node_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node, this0_actors0_node_movies0_node, this0_actors0_node_movies0_node_actors0_node + // MERGE (this0_actors0_node_movies0_node)<-[:ACTED_IN]-(this0_actors0_node_movies0_node_actors0_node) + // WITH meta + { event: \\"create\\", id: id(this0_actors0_node_movies0_node), properties: { old: null, new: this0_actors0_node_movies0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node, this0_actors0_node_movies0_node + // MERGE (this0_actors0_node)-[:ACTED_IN]->(this0_actors0_node_movies0_node) + // WITH meta + { event: \\"create\\", id: id(this0_actors0_node), properties: { old: null, new: this0_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node + // MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) + // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 + // RETURN this0, meta AS this0_meta + // } + // WITH this0, this0_meta AS meta + // RETURN [ + // this0 { .id, actors: [ (this0)<-[:ACTED_IN]-(this0_actors:Actor) | this0_actors { .name, movies: [ (this0_actors)-[:ACTED_IN]->(this0_actors_movies:Movie) | this0_actors_movies { .id, actors: [ (this0_actors_movies)<-[:ACTED_IN]-(this0_actors_movies_actors:Actor) | this0_actors_movies_actors { .name } ] } ] } ] }] AS data, meta" + // `); + + // expect(formatParams(result.params)).toMatchInlineSnapshot(` + // "{ + // \\"this0_id\\": \\"1\\", + // \\"this0_actors0_node_name\\": \\"Andrés\\", + // \\"this0_actors0_node_movies0_node_id\\": \\"6\\", + // \\"this0_actors0_node_movies0_node_actors0_node_name\\": \\"Thomas\\" + // }" + // `); + // }); + + // test("Multi Create with nested", async () => { + // const query = gql` + // mutation { + // createMovies( + // input: [ + // { + // id: "1" + // actors: { create: { node: { name: "Andrés", movies: { create: { node: { id: 6 } } } } } } + // } + // { + // id: "2" + // actors: { create: { node: { name: "Darrell", movies: { create: { node: { id: 8 } } } } } } + // } + // ] + // ) { + // movies { + // id + // } + // } + // } + // `; + + // const req = createJwtRequest("secret", {}); + // const result = await translateQuery(neoSchema, query, { + // req, + // }); + + // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + // "CALL { + // WITH [] AS meta + // CREATE (this0:Movie) + // SET this0.id = $this0_id + // CREATE (this0_actors0_node:Actor) + // SET this0_actors0_node.name = $this0_actors0_node_name + // CREATE (this0_actors0_node_movies0_node:Movie) + // SET this0_actors0_node_movies0_node.id = $this0_actors0_node_movies0_node_id + // WITH meta + { event: \\"create\\", id: id(this0_actors0_node_movies0_node), properties: { old: null, new: this0_actors0_node_movies0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node, this0_actors0_node_movies0_node + // MERGE (this0_actors0_node)-[:ACTED_IN]->(this0_actors0_node_movies0_node) + // WITH meta + { event: \\"create\\", id: id(this0_actors0_node), properties: { old: null, new: this0_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node + // MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) + // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 + // RETURN this0, meta AS this0_meta + // } + // CALL { + // WITH [] AS meta + // CREATE (this1:Movie) + // SET this1.id = $this1_id + // CREATE (this1_actors0_node:Actor) + // SET this1_actors0_node.name = $this1_actors0_node_name + // CREATE (this1_actors0_node_movies0_node:Movie) + // SET this1_actors0_node_movies0_node.id = $this1_actors0_node_movies0_node_id + // WITH meta + { event: \\"create\\", id: id(this1_actors0_node_movies0_node), properties: { old: null, new: this1_actors0_node_movies0_node { .* } }, timestamp: timestamp() } AS meta, this1, this1_actors0_node, this1_actors0_node_movies0_node + // MERGE (this1_actors0_node)-[:ACTED_IN]->(this1_actors0_node_movies0_node) + // WITH meta + { event: \\"create\\", id: id(this1_actors0_node), properties: { old: null, new: this1_actors0_node { .* } }, timestamp: timestamp() } AS meta, this1, this1_actors0_node + // MERGE (this1)<-[:ACTED_IN]-(this1_actors0_node) + // WITH meta + { event: \\"create\\", id: id(this1), properties: { old: null, new: this1 { .* } }, timestamp: timestamp() } AS meta, this1 + // RETURN this1, meta AS this1_meta + // } + // WITH this0, this1, this0_meta + this1_meta AS meta + // RETURN [ + // this0 { .id }, + // this1 { .id }] AS data, meta" + // `); + + // expect(formatParams(result.params)).toMatchInlineSnapshot(` + // "{ + // \\"this0_id\\": \\"1\\", + // \\"this0_actors0_node_name\\": \\"Andrés\\", + // \\"this0_actors0_node_movies0_node_id\\": \\"6\\", + // \\"this1_id\\": \\"2\\", + // \\"this1_actors0_node_name\\": \\"Darrell\\", + // \\"this1_actors0_node_movies0_node_id\\": \\"8\\" + // }" + // `); + // }); + + // test("Simple create without returned data", async () => { + // const query = gql` + // mutation { + // createMovies(input: [{ id: "1" }]) { + // info { + // nodesCreated + // } + // } + // } + // `; + + // const req = createJwtRequest("secret", {}); + // const result = await translateQuery(neoSchema, query, { + // req, + // }); + + // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + // "CALL { + // WITH [] AS meta + // CREATE (this0:Movie) + // SET this0.id = $this0_id + // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 + // RETURN this0, meta AS this0_meta + // } + // WITH this0, this0_meta AS meta + // RETURN meta" + // `); + + // expect(formatParams(result.params)).toMatchInlineSnapshot(` + // "{ + // \\"this0_id\\": \\"1\\" + // }" + // `); + // }); +}); From 35388447373bdf15f9e7d7b96c9de80830e2c876 Mon Sep 17 00:00:00 2001 From: Darrell Warde Date: Mon, 7 Mar 2022 14:27:51 +0000 Subject: [PATCH 2/7] Subscriptions with nested delete WIP --- .../src/translate/create-delete-and-params.ts | 11 +- .../graphql/src/translate/translate-delete.ts | 8 +- .../subscriptions/delete/delete.int.test.ts | 60 ++- .../subscriptions/delete.test.ts | 379 +++--------------- 4 files changed, 124 insertions(+), 334 deletions(-) diff --git a/packages/graphql/src/translate/create-delete-and-params.ts b/packages/graphql/src/translate/create-delete-and-params.ts index 9d457cfe19..5220748a7b 100644 --- a/packages/graphql/src/translate/create-delete-and-params.ts +++ b/packages/graphql/src/translate/create-delete-and-params.ts @@ -21,7 +21,8 @@ import { Node, Relationship } from "../classes"; import { Context } from "../types"; import createAuthAndParams from "./create-auth-and-params"; import createConnectionWhereAndParams from "./where/create-connection-where-and-params"; -import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import { AUTH_FORBIDDEN_ERROR, META_CYPHER_VARIABLE } from "../constants"; +import { createEventMeta } from "./subscriptions/create-event-meta"; interface Res { strs: string[]; @@ -211,7 +212,13 @@ function createDeleteAndParams({ res.strs.push( `WITH ${[...withVars, `collect(DISTINCT ${_varName}) as ${_varName}_to_delete`].join(", ")}` ); - res.strs.push(`FOREACH(x IN ${_varName}_to_delete | DETACH DELETE x)`); + res.strs.push(`UNWIND ${_varName}_to_delete AS x`); + const eventMeta = createEventMeta({ event: "delete", nodeVariable: "x" }); + res.strs.push( + `WITH ${withVars.filter((w) => w !== META_CYPHER_VARIABLE).join(", ")}, ${eventMeta}, x` + ); + + res.strs.push(`DETACH DELETE x`); // TODO - relationship validation }); diff --git a/packages/graphql/src/translate/translate-delete.ts b/packages/graphql/src/translate/translate-delete.ts index 7b8a3e4cbb..b39796cd98 100644 --- a/packages/graphql/src/translate/translate-delete.ts +++ b/packages/graphql/src/translate/translate-delete.ts @@ -34,6 +34,12 @@ function translateDelete({ context, node }: { context: Context; node: Node }): [ let deleteStr = ""; let cypherParams: { [k: string]: any } = {}; + const withVars = [varName]; + + if (context.subscriptionsEnabled) { + withVars.push(META_CYPHER_VARIABLE); + } + const topLevelMatch = translateTopLevelMatch({ node, context, varName, operation: "DELETE" }); matchAndWhereStr = topLevelMatch[0]; cypherParams = { ...cypherParams, ...topLevelMatch[1] }; @@ -59,7 +65,7 @@ function translateDelete({ context, node }: { context: Context; node: Node }): [ deleteInput, varName, parentVar: varName, - withVars: [varName], + withVars, parameterPrefix: `${varName}_${resolveTree.name}.args.delete`, }); [deleteStr] = deleteAndParams; diff --git a/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts index aa823acb80..e3f10f1107 100644 --- a/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts +++ b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts @@ -91,7 +91,7 @@ describe("Subscriptions delete", () => { }); expect(gqlResult.errors).toBeUndefined(); - expect(gqlResult.data[typeMovie.operations.delete].nodesDeleted).toEqual(2); + expect(gqlResult.data[typeMovie.operations.delete].nodesDeleted).toBe(2); expect(plugin.eventList).toEqual([ { @@ -108,4 +108,62 @@ describe("Subscriptions delete", () => { }, ]); }); + + test("simple nested delete with subscriptions enabled", async () => { + const query = ` + mutation { + ${typeMovie.operations.delete}(delete: { actors: { where: { } } }) { + nodesDeleted + } + } + `; + + await session.run(` + CREATE (m1:${typeMovie.name} { id: "1" })<-[:ACTED_IN]-(:${typeActor.name} { id: "3" }) + CREATE (m2:${typeMovie.name} { id: "2" })<-[:ACTED_IN]-(:${typeActor.name} { id: "4" }) + CREATE (m2)<-[:ACTED_IN]-(:${typeActor.name} { id: "5" }) + `); + + const gqlResult: any = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: { driver }, + }); + + expect(gqlResult.errors).toBeUndefined(); + expect(gqlResult.data[typeMovie.operations.delete].nodesDeleted).toBe(5); + + expect(plugin.eventList).toEqual([ + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "1" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "2" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "3" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "4" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "5" }, new: undefined }, + }, + ]); + }); }); diff --git a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts index 3584d04d23..d3570b04a8 100644 --- a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts +++ b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts @@ -84,337 +84,56 @@ describe("Subscriptions metadata on delete", () => { `); }); - // test("Multi Create", async () => { - // const query = gql` - // mutation { - // createMovies(input: [{ id: "1" }, { id: "2" }]) { - // movies { - // id - // } - // } - // } - // `; - - // const req = createJwtRequest("secret", {}); - // const result = await translateQuery(neoSchema, query, { - // req, - // }); - - // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - // "CALL { - // WITH [] AS meta - // CREATE (this0:Movie) - // SET this0.id = $this0_id - // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 - // RETURN this0, meta AS this0_meta - // } - // CALL { - // WITH [] AS meta - // CREATE (this1:Movie) - // SET this1.id = $this1_id - // WITH meta + { event: \\"create\\", id: id(this1), properties: { old: null, new: this1 { .* } }, timestamp: timestamp() } AS meta, this1 - // RETURN this1, meta AS this1_meta - // } - // WITH this0, this1, this0_meta + this1_meta AS meta - // RETURN [ - // this0 { .id }, - // this1 { .id }] AS data, meta" - // `); - - // expect(formatParams(result.params)).toMatchInlineSnapshot(` - // "{ - // \\"this0_id\\": \\"1\\", - // \\"this1_id\\": \\"2\\" - // }" - // `); - // }); - - // test("Nested Create", async () => { - // const query = gql` - // mutation { - // createMovies(input: [{ id: "1", actors: { create: { node: { name: "Andrés" } } } }]) { - // movies { - // id - // actors { - // name - // } - // } - // } - // } - // `; - - // const req = createJwtRequest("secret", {}); - // const result = await translateQuery(neoSchema, query, { - // req, - // }); - - // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - // "CALL { - // WITH [] AS meta - // CREATE (this0:Movie) - // SET this0.id = $this0_id - // CREATE (this0_actors0_node:Actor) - // SET this0_actors0_node.name = $this0_actors0_node_name - // WITH meta + { event: \\"create\\", id: id(this0_actors0_node), properties: { old: null, new: this0_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node - // MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) - // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 - // RETURN this0, meta AS this0_meta - // } - // WITH this0, this0_meta AS meta - // RETURN [ - // this0 { .id, actors: [ (this0)<-[:ACTED_IN]-(this0_actors:Actor) | this0_actors { .name } ] }] AS data, meta" - // `); - - // expect(formatParams(result.params)).toMatchInlineSnapshot(` - // "{ - // \\"this0_id\\": \\"1\\", - // \\"this0_actors0_node_name\\": \\"Andrés\\" - // }" - // `); - // }); - - // test("Triple nested Create", async () => { - // const query = gql` - // mutation { - // createMovies( - // input: [ - // { - // id: "1" - // actors: { create: { node: { name: "Andrés", movies: { create: { node: { id: 6 } } } } } } - // } - // ] - // ) { - // movies { - // id - // actors { - // name - // } - // } - // } - // } - // `; - - // const req = createJwtRequest("secret", {}); - // const result = await translateQuery(neoSchema, query, { - // req, - // }); - - // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - // "CALL { - // WITH [] AS meta - // CREATE (this0:Movie) - // SET this0.id = $this0_id - // CREATE (this0_actors0_node:Actor) - // SET this0_actors0_node.name = $this0_actors0_node_name - // CREATE (this0_actors0_node_movies0_node:Movie) - // SET this0_actors0_node_movies0_node.id = $this0_actors0_node_movies0_node_id - // WITH meta + { event: \\"create\\", id: id(this0_actors0_node_movies0_node), properties: { old: null, new: this0_actors0_node_movies0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node, this0_actors0_node_movies0_node - // MERGE (this0_actors0_node)-[:ACTED_IN]->(this0_actors0_node_movies0_node) - // WITH meta + { event: \\"create\\", id: id(this0_actors0_node), properties: { old: null, new: this0_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node - // MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) - // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 - // RETURN this0, meta AS this0_meta - // } - // WITH this0, this0_meta AS meta - // RETURN [ - // this0 { .id, actors: [ (this0)<-[:ACTED_IN]-(this0_actors:Actor) | this0_actors { .name } ] }] AS data, meta" - // `); - - // expect(formatParams(result.params)).toMatchInlineSnapshot(` - // "{ - // \\"this0_id\\": \\"1\\", - // \\"this0_actors0_node_name\\": \\"Andrés\\", - // \\"this0_actors0_node_movies0_node_id\\": \\"6\\" - // }" - // `); - // }); - - // test("Quadruple nested Create", async () => { - // const query = gql` - // mutation { - // createMovies( - // input: [ - // { - // id: "1" - // actors: { - // create: { - // node: { - // name: "Andrés" - // movies: { - // create: { - // node: { id: 6, actors: { create: { node: { name: "Thomas" } } } } - // } - // } - // } - // } - // } - // } - // ] - // ) { - // movies { - // id - // actors { - // name - // movies { - // id - // actors { - // name - // } - // } - // } - // } - // } - // } - // `; - - // const req = createJwtRequest("secret", {}); - // const result = await translateQuery(neoSchema, query, { - // req, - // }); - - // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - // "CALL { - // WITH [] AS meta - // CREATE (this0:Movie) - // SET this0.id = $this0_id - // CREATE (this0_actors0_node:Actor) - // SET this0_actors0_node.name = $this0_actors0_node_name - // CREATE (this0_actors0_node_movies0_node:Movie) - // SET this0_actors0_node_movies0_node.id = $this0_actors0_node_movies0_node_id - // CREATE (this0_actors0_node_movies0_node_actors0_node:Actor) - // SET this0_actors0_node_movies0_node_actors0_node.name = $this0_actors0_node_movies0_node_actors0_node_name - // WITH meta + { event: \\"create\\", id: id(this0_actors0_node_movies0_node_actors0_node), properties: { old: null, new: this0_actors0_node_movies0_node_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node, this0_actors0_node_movies0_node, this0_actors0_node_movies0_node_actors0_node - // MERGE (this0_actors0_node_movies0_node)<-[:ACTED_IN]-(this0_actors0_node_movies0_node_actors0_node) - // WITH meta + { event: \\"create\\", id: id(this0_actors0_node_movies0_node), properties: { old: null, new: this0_actors0_node_movies0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node, this0_actors0_node_movies0_node - // MERGE (this0_actors0_node)-[:ACTED_IN]->(this0_actors0_node_movies0_node) - // WITH meta + { event: \\"create\\", id: id(this0_actors0_node), properties: { old: null, new: this0_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node - // MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) - // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 - // RETURN this0, meta AS this0_meta - // } - // WITH this0, this0_meta AS meta - // RETURN [ - // this0 { .id, actors: [ (this0)<-[:ACTED_IN]-(this0_actors:Actor) | this0_actors { .name, movies: [ (this0_actors)-[:ACTED_IN]->(this0_actors_movies:Movie) | this0_actors_movies { .id, actors: [ (this0_actors_movies)<-[:ACTED_IN]-(this0_actors_movies_actors:Actor) | this0_actors_movies_actors { .name } ] } ] } ] }] AS data, meta" - // `); - - // expect(formatParams(result.params)).toMatchInlineSnapshot(` - // "{ - // \\"this0_id\\": \\"1\\", - // \\"this0_actors0_node_name\\": \\"Andrés\\", - // \\"this0_actors0_node_movies0_node_id\\": \\"6\\", - // \\"this0_actors0_node_movies0_node_actors0_node_name\\": \\"Thomas\\" - // }" - // `); - // }); - - // test("Multi Create with nested", async () => { - // const query = gql` - // mutation { - // createMovies( - // input: [ - // { - // id: "1" - // actors: { create: { node: { name: "Andrés", movies: { create: { node: { id: 6 } } } } } } - // } - // { - // id: "2" - // actors: { create: { node: { name: "Darrell", movies: { create: { node: { id: 8 } } } } } } - // } - // ] - // ) { - // movies { - // id - // } - // } - // } - // `; - - // const req = createJwtRequest("secret", {}); - // const result = await translateQuery(neoSchema, query, { - // req, - // }); - - // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - // "CALL { - // WITH [] AS meta - // CREATE (this0:Movie) - // SET this0.id = $this0_id - // CREATE (this0_actors0_node:Actor) - // SET this0_actors0_node.name = $this0_actors0_node_name - // CREATE (this0_actors0_node_movies0_node:Movie) - // SET this0_actors0_node_movies0_node.id = $this0_actors0_node_movies0_node_id - // WITH meta + { event: \\"create\\", id: id(this0_actors0_node_movies0_node), properties: { old: null, new: this0_actors0_node_movies0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node, this0_actors0_node_movies0_node - // MERGE (this0_actors0_node)-[:ACTED_IN]->(this0_actors0_node_movies0_node) - // WITH meta + { event: \\"create\\", id: id(this0_actors0_node), properties: { old: null, new: this0_actors0_node { .* } }, timestamp: timestamp() } AS meta, this0, this0_actors0_node - // MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) - // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 - // RETURN this0, meta AS this0_meta - // } - // CALL { - // WITH [] AS meta - // CREATE (this1:Movie) - // SET this1.id = $this1_id - // CREATE (this1_actors0_node:Actor) - // SET this1_actors0_node.name = $this1_actors0_node_name - // CREATE (this1_actors0_node_movies0_node:Movie) - // SET this1_actors0_node_movies0_node.id = $this1_actors0_node_movies0_node_id - // WITH meta + { event: \\"create\\", id: id(this1_actors0_node_movies0_node), properties: { old: null, new: this1_actors0_node_movies0_node { .* } }, timestamp: timestamp() } AS meta, this1, this1_actors0_node, this1_actors0_node_movies0_node - // MERGE (this1_actors0_node)-[:ACTED_IN]->(this1_actors0_node_movies0_node) - // WITH meta + { event: \\"create\\", id: id(this1_actors0_node), properties: { old: null, new: this1_actors0_node { .* } }, timestamp: timestamp() } AS meta, this1, this1_actors0_node - // MERGE (this1)<-[:ACTED_IN]-(this1_actors0_node) - // WITH meta + { event: \\"create\\", id: id(this1), properties: { old: null, new: this1 { .* } }, timestamp: timestamp() } AS meta, this1 - // RETURN this1, meta AS this1_meta - // } - // WITH this0, this1, this0_meta + this1_meta AS meta - // RETURN [ - // this0 { .id }, - // this1 { .id }] AS data, meta" - // `); - - // expect(formatParams(result.params)).toMatchInlineSnapshot(` - // "{ - // \\"this0_id\\": \\"1\\", - // \\"this0_actors0_node_name\\": \\"Andrés\\", - // \\"this0_actors0_node_movies0_node_id\\": \\"6\\", - // \\"this1_id\\": \\"2\\", - // \\"this1_actors0_node_name\\": \\"Darrell\\", - // \\"this1_actors0_node_movies0_node_id\\": \\"8\\" - // }" - // `); - // }); - - // test("Simple create without returned data", async () => { - // const query = gql` - // mutation { - // createMovies(input: [{ id: "1" }]) { - // info { - // nodesCreated - // } - // } - // } - // `; + test("Nested delete", async () => { + const query = gql` + mutation { + deleteMovies(where: { id: "1" }, delete: { actors: { where: { node: { name: "1" } } } }) { + nodesDeleted + } + } + `; - // const req = createJwtRequest("secret", {}); - // const result = await translateQuery(neoSchema, query, { - // req, - // }); + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); - // expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - // "CALL { - // WITH [] AS meta - // CREATE (this0:Movie) - // SET this0.id = $this0_id - // WITH meta + { event: \\"create\\", id: id(this0), properties: { old: null, new: this0 { .* } }, timestamp: timestamp() } AS meta, this0 - // RETURN this0, meta AS this0_meta - // } - // WITH this0, this0_meta AS meta - // RETURN meta" - // `); + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "WITH [] AS meta + MATCH (this:Movie) + WHERE this.id = $this_id + WITH this, meta + { event: \\"delete\\", id: id(this), properties: { old: this { .* }, new: null }, timestamp: timestamp() } AS meta + WITH this, meta + OPTIONAL MATCH (this)<-[this_actors0_relationship:ACTED_IN]-(this_actors0:Actor) + WHERE this_actors0.name = $this_deleteMovies.args.delete.actors[0].where.node.name + WITH this, meta + { event: \\"delete\\", id: id(this_actors0), properties: { old: this_actors0 { .* }, new: null }, timestamp: timestamp() } AS meta + WITH this, meta, collect(DISTINCT this_actors0) as this_actors0_to_delete + FOREACH(x IN this_actors0_to_delete | DETACH DELETE x) + DETACH DELETE this + WITH meta + UNWIND meta AS m + RETURN collect(DISTINCT m) AS meta" + `); - // expect(formatParams(result.params)).toMatchInlineSnapshot(` - // "{ - // \\"this0_id\\": \\"1\\" - // }" - // `); - // }); + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"this_id\\": \\"1\\", + \\"this_deleteMovies\\": { + \\"args\\": { + \\"delete\\": { + \\"actors\\": [ + { + \\"where\\": { + \\"node\\": { + \\"name\\": \\"1\\" + } + } + } + ] + } + } + } + }" + `); + }); }); From bb9ac6415ace1d50d3141b6c98cd045b64f93bc8 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Mon, 7 Mar 2022 16:53:15 +0000 Subject: [PATCH 3/7] Add delete metadata for nested subscriptions --- .../src/translate/create-delete-and-params.ts | 23 +++-- .../subscriptions/create-event-meta.ts | 7 +- .../subscriptions/delete/delete.int.test.ts | 85 ++++++++++--------- .../subscriptions/delete.test.ts | 2 +- packages/graphql/tests/utils/graphql-types.ts | 13 ++- 5 files changed, 79 insertions(+), 51 deletions(-) diff --git a/packages/graphql/src/translate/create-delete-and-params.ts b/packages/graphql/src/translate/create-delete-and-params.ts index 5220748a7b..a3b44f54cf 100644 --- a/packages/graphql/src/translate/create-delete-and-params.ts +++ b/packages/graphql/src/translate/create-delete-and-params.ts @@ -22,7 +22,7 @@ import { Context } from "../types"; import createAuthAndParams from "./create-auth-and-params"; import createConnectionWhereAndParams from "./where/create-connection-where-and-params"; import { AUTH_FORBIDDEN_ERROR, META_CYPHER_VARIABLE } from "../constants"; -import { createEventMeta } from "./subscriptions/create-event-meta"; +import { createEventMetaObject } from "./subscriptions/create-event-meta"; interface Res { strs: string[]; @@ -209,17 +209,24 @@ function createDeleteAndParams({ } } + const nodeToDelete = `${_varName}_to_delete`; res.strs.push( - `WITH ${[...withVars, `collect(DISTINCT ${_varName}) as ${_varName}_to_delete`].join(", ")}` - ); - res.strs.push(`UNWIND ${_varName}_to_delete AS x`); - const eventMeta = createEventMeta({ event: "delete", nodeVariable: "x" }); - res.strs.push( - `WITH ${withVars.filter((w) => w !== META_CYPHER_VARIABLE).join(", ")}, ${eventMeta}, x` + `WITH ${[...withVars, `collect(DISTINCT ${_varName}) as ${nodeToDelete}`].join(", ")}` ); - res.strs.push(`DETACH DELETE x`); + if (context.subscriptionsEnabled) { + res.strs.push( + `WITH ${[ + ...withVars.filter((v) => v !== META_CYPHER_VARIABLE), + nodeToDelete, + ]}, REDUCE(m=${META_CYPHER_VARIABLE}, n IN ${nodeToDelete} | m + ${createEventMetaObject({ + event: "delete", + nodeVariable: "n", + })}) AS ${META_CYPHER_VARIABLE}` + ); + } + res.strs.push(`FOREACH(x IN ${_varName}_to_delete | DETACH DELETE x)`); // TODO - relationship validation }); }); diff --git a/packages/graphql/src/translate/subscriptions/create-event-meta.ts b/packages/graphql/src/translate/subscriptions/create-event-meta.ts index e5a9c8f6a6..db30dfd07b 100644 --- a/packages/graphql/src/translate/subscriptions/create-event-meta.ts +++ b/packages/graphql/src/translate/subscriptions/create-event-meta.ts @@ -22,9 +22,12 @@ import { META_CYPHER_VARIABLE } from "../../constants"; export type EventMetaType = "create" | "update" | "delete"; export function createEventMeta({ event, nodeVariable }: { event: EventMetaType; nodeVariable: string }): string { - const properties = createEventMetaProperties({ event, nodeVariable }); + return `${META_CYPHER_VARIABLE} + ${createEventMetaObject({ event, nodeVariable })} AS ${META_CYPHER_VARIABLE}`; +} - return `${META_CYPHER_VARIABLE} + { event: "${event}", id: id(${nodeVariable}), ${properties}, timestamp: timestamp() } AS ${META_CYPHER_VARIABLE}`; +export function createEventMetaObject({ event, nodeVariable }: { event: EventMetaType; nodeVariable: string }): string { + const properties = createEventMetaProperties({ event, nodeVariable }); + return `{ event: "${event}", id: id(${nodeVariable}), ${properties}, timestamp: timestamp() }`; } function createEventMetaProperties({ event, nodeVariable }: { event: EventMetaType; nodeVariable: string }): string { diff --git a/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts index e3f10f1107..ff7d6aab40 100644 --- a/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts +++ b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts @@ -21,7 +21,7 @@ import { gql } from "apollo-server"; import { graphql } from "graphql"; import { Driver, Session } from "neo4j-driver"; import { Neo4jGraphQL } from "../../../../src"; -import { generateUniqueType } from "../../../utils/graphql-types"; +import { generateUniqueType, UniqueType } from "../../../utils/graphql-types"; import { TestSubscriptionsPlugin } from "../../../utils/TestSubscriptionPlugin"; import neo4j from "../../neo4j"; @@ -31,11 +31,19 @@ describe("Subscriptions delete", () => { let neoSchema: Neo4jGraphQL; let plugin: TestSubscriptionsPlugin; - const typeActor = generateUniqueType("Actor"); - const typeMovie = generateUniqueType("Movie"); + let typeActor: UniqueType; + let typeMovie: UniqueType; beforeAll(async () => { driver = await neo4j(); + }); + + beforeEach(() => { + session = driver.session(); + + typeActor = generateUniqueType("Actor"); + typeMovie = generateUniqueType("Movie"); + plugin = new TestSubscriptionsPlugin(); const typeDefs = gql` type ${typeActor.name} { @@ -58,10 +66,6 @@ describe("Subscriptions delete", () => { }); }); - beforeEach(() => { - session = driver.session(); - }); - afterEach(async () => { await session.close(); }); @@ -133,37 +137,40 @@ describe("Subscriptions delete", () => { expect(gqlResult.errors).toBeUndefined(); expect(gqlResult.data[typeMovie.operations.delete].nodesDeleted).toBe(5); - expect(plugin.eventList).toEqual([ - { - id: expect.any(Number), - timestamp: expect.any(Number), - event: "delete", - properties: { old: { id: "1" }, new: undefined }, - }, - { - id: expect.any(Number), - timestamp: expect.any(Number), - event: "delete", - properties: { old: { id: "2" }, new: undefined }, - }, - { - id: expect.any(Number), - timestamp: expect.any(Number), - event: "delete", - properties: { old: { id: "3" }, new: undefined }, - }, - { - id: expect.any(Number), - timestamp: expect.any(Number), - event: "delete", - properties: { old: { id: "4" }, new: undefined }, - }, - { - id: expect.any(Number), - timestamp: expect.any(Number), - event: "delete", - properties: { old: { id: "5" }, new: undefined }, - }, - ]); + expect(plugin.eventList).toHaveLength(5); + expect(plugin.eventList).toEqual( + expect.arrayContaining([ + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "1" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "3" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "2" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "5" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "4" }, new: undefined }, + }, + ]) + ); }); }); diff --git a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts index d3570b04a8..51e404f00a 100644 --- a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts +++ b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts @@ -106,8 +106,8 @@ describe("Subscriptions metadata on delete", () => { WITH this, meta OPTIONAL MATCH (this)<-[this_actors0_relationship:ACTED_IN]-(this_actors0:Actor) WHERE this_actors0.name = $this_deleteMovies.args.delete.actors[0].where.node.name - WITH this, meta + { event: \\"delete\\", id: id(this_actors0), properties: { old: this_actors0 { .* }, new: null }, timestamp: timestamp() } AS meta WITH this, meta, collect(DISTINCT this_actors0) as this_actors0_to_delete + WITH this,this_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta FOREACH(x IN this_actors0_to_delete | DETACH DELETE x) DETACH DELETE this WITH meta diff --git a/packages/graphql/tests/utils/graphql-types.ts b/packages/graphql/tests/utils/graphql-types.ts index 90714a27d3..c18d82987e 100644 --- a/packages/graphql/tests/utils/graphql-types.ts +++ b/packages/graphql/tests/utils/graphql-types.ts @@ -24,7 +24,18 @@ import pluralize from "pluralize"; import camelcase from "camelcase"; import { upperFirst } from "../../src/utils/upper-first"; -export function generateUniqueType(baseName: string) { +export type UniqueType = { + name: string; + plural: string; + operations: { + create: string; + update: string; + delete: string; + aggregate: string; + }; +}; + +export function generateUniqueType(baseName: string): UniqueType { const type = `${generate({ length: 8, charset: "alphabetic", From e9ac06411e14aa8b467e905dc4fe25c6b6ae1d81 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Mon, 7 Mar 2022 17:32:24 +0000 Subject: [PATCH 4/7] Fix delete meta with auth --- .../graphql/src/translate/translate-delete.ts | 4 +- .../delete/delete-auth.int.test.ts | 99 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 packages/graphql/tests/integration/subscriptions/delete/delete-auth.int.test.ts diff --git a/packages/graphql/src/translate/translate-delete.ts b/packages/graphql/src/translate/translate-delete.ts index b39796cd98..74c7e11db5 100644 --- a/packages/graphql/src/translate/translate-delete.ts +++ b/packages/graphql/src/translate/translate-delete.ts @@ -55,7 +55,9 @@ function translateDelete({ context, node }: { context: Context; node: Node }): [ }); if (allowAuth[0]) { cypherParams = { ...cypherParams, ...allowAuth[1] }; - allowStr = `WITH ${varName}\nCALL apoc.util.validate(NOT(${allowAuth[0]}), "${AUTH_FORBIDDEN_ERROR}", [0])`; + allowStr = `WITH ${withVars.join(", ")}\nCALL apoc.util.validate(NOT(${ + allowAuth[0] + }), "${AUTH_FORBIDDEN_ERROR}", [0])`; } if (deleteInput) { diff --git a/packages/graphql/tests/integration/subscriptions/delete/delete-auth.int.test.ts b/packages/graphql/tests/integration/subscriptions/delete/delete-auth.int.test.ts new file mode 100644 index 0000000000..a757f75104 --- /dev/null +++ b/packages/graphql/tests/integration/subscriptions/delete/delete-auth.int.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { graphql } from "graphql"; +import { Driver } from "neo4j-driver"; +import { Neo4jGraphQLAuthJWTPlugin } from "@neo4j/graphql-plugin-auth"; +import { generate } from "randomstring"; +import { Neo4jGraphQL } from "../../../../src"; +import { generateUniqueType } from "../../../utils/graphql-types"; +import { TestSubscriptionsPlugin } from "../../../utils/TestSubscriptionPlugin"; +import neo4j from "../../neo4j"; +import { createJwtRequest } from "../../../utils/create-jwt-request"; + +describe("Subscriptions delete", () => { + let driver: Driver; + let plugin: TestSubscriptionsPlugin; + + beforeAll(async () => { + driver = await neo4j(); + }); + + beforeEach(() => { + plugin = new TestSubscriptionsPlugin(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should throw Forbidden when deleting a node with invalid allow", async () => { + const session = driver.session({ defaultAccessMode: "WRITE" }); + const typeUser = generateUniqueType("User"); + const typeDefs = ` + type ${typeUser.name} { + id: ID + } + + extend type ${typeUser.name} @auth(rules: [{ operations: [DELETE], allow: { id: "$jwt.sub" }}]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const query = ` + mutation { + ${typeUser.operations.delete}( + where: { id: "${userId}" } + ) { + nodesDeleted + } + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + plugins: { + auth: new Neo4jGraphQLAuthJWTPlugin({ + secret: "secret", + }), + subscriptions: plugin, + } as any, + }); + + try { + await session.run(` + CREATE (:${typeUser.name} {id: "${userId}"}) + `); + + const req = createJwtRequest("secret", { sub: "invalid" }); + + const gqlResult = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: { driver, req, driverConfig: { bookmarks: session.lastBookmark() } }, + }); + console.log(JSON.stringify(gqlResult, null, 4)); + expect((gqlResult.errors as any[])[0].message).toBe("Forbidden"); + } finally { + await session.close(); + } + }); +}); From 95ad48459263863605eb554f82f25f74f60bbc2a Mon Sep 17 00:00:00 2001 From: angrykoala Date: Tue, 8 Mar 2022 09:07:05 +0000 Subject: [PATCH 5/7] Triple nested test on subscriptions --- .../subscriptions/delete/delete.int.test.ts | 70 ++++++++++++- .../subscriptions/delete.test.ts | 99 +++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts index ff7d6aab40..e13058a86c 100644 --- a/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts +++ b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts @@ -47,7 +47,7 @@ describe("Subscriptions delete", () => { plugin = new TestSubscriptionsPlugin(); const typeDefs = gql` type ${typeActor.name} { - name: String! + id: ID! movies: [${typeMovie.name}!]! @relationship(type: "ACTED_IN", direction: OUT) } @@ -173,4 +173,72 @@ describe("Subscriptions delete", () => { ]) ); }); + + test("triple nested delete with subscriptions enabled", async () => { + const query = ` + mutation { + ${typeMovie.operations.delete}( + where: { id: 1 } + delete: { + actors: { + where: { node: { id: 3 } } + delete: { + movies: { + where: { node: { id: 2 } } + delete: { actors: { where: { node: { id: 4 } } } } + } + } + } + } + ) { + nodesDeleted + } + } + `; + + await session.run(` + CREATE (m1:${typeMovie.name} { id: "1" })<-[:ACTED_IN]-(a:${typeActor.name} { id: "3" }) + CREATE (m2:${typeMovie.name} { id: "2" })<-[:ACTED_IN]-(:${typeActor.name} { id: "4" }) + CREATE (m2)<-[:ACTED_IN]-(a) + `); + + const gqlResult: any = await graphql({ + schema: await neoSchema.getSchema(), + source: query, + contextValue: { driver }, + }); + + expect(gqlResult.errors).toBeUndefined(); + expect(gqlResult.data[typeMovie.operations.delete].nodesDeleted).toBe(4); + + expect(plugin.eventList).toHaveLength(4); + expect(plugin.eventList).toEqual( + expect.arrayContaining([ + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "1" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "3" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "2" }, new: undefined }, + }, + { + id: expect.any(Number), + timestamp: expect.any(Number), + event: "delete", + properties: { old: { id: "4" }, new: undefined }, + }, + ]) + ); + }); }); diff --git a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts index 51e404f00a..bf8cb188df 100644 --- a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts +++ b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts @@ -136,4 +136,103 @@ describe("Subscriptions metadata on delete", () => { }" `); }); + test("Triple Nested Delete", async () => { + const query = gql` + mutation { + deleteMovies( + where: { id: 123 } + delete: { + actors: { + where: { node: { name: "Actor to delete" } } + delete: { + movies: { + where: { node: { id: 321 } } + delete: { actors: { where: { node: { name: "Another actor to delete" } } } } + } + } + } + } + ) { + nodesDeleted + } + } + `; + + const req = createJwtRequest("secret", {}); + const result = await translateQuery(neoSchema, query, { + req, + }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "WITH [] AS meta + MATCH (this:Movie) + WHERE this.id = $this_id + WITH this, meta + { event: \\"delete\\", id: id(this), properties: { old: this { .* }, new: null }, timestamp: timestamp() } AS meta + WITH this, meta + OPTIONAL MATCH (this)<-[this_actors0_relationship:ACTED_IN]-(this_actors0:Actor) + WHERE this_actors0.name = $this_deleteMovies.args.delete.actors[0].where.node.name + WITH this, meta, this_actors0 + OPTIONAL MATCH (this_actors0)-[this_actors0_movies0_relationship:ACTED_IN]->(this_actors0_movies0:Movie) + WHERE this_actors0_movies0.id = $this_deleteMovies.args.delete.actors[0].delete.movies[0].where.node.id + WITH this, meta, this_actors0, this_actors0_movies0 + OPTIONAL MATCH (this_actors0_movies0)<-[this_actors0_movies0_actors0_relationship:ACTED_IN]-(this_actors0_movies0_actors0:Actor) + WHERE this_actors0_movies0_actors0.name = $this_deleteMovies.args.delete.actors[0].delete.movies[0].delete.actors[0].where.node.name + WITH this, meta, this_actors0, this_actors0_movies0, collect(DISTINCT this_actors0_movies0_actors0) as this_actors0_movies0_actors0_to_delete + WITH this,this_actors0,this_actors0_movies0,this_actors0_movies0_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_movies0_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta + FOREACH(x IN this_actors0_movies0_actors0_to_delete | DETACH DELETE x) + WITH this, meta, this_actors0, collect(DISTINCT this_actors0_movies0) as this_actors0_movies0_to_delete + WITH this,this_actors0,this_actors0_movies0_to_delete, REDUCE(m=meta, n IN this_actors0_movies0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta + FOREACH(x IN this_actors0_movies0_to_delete | DETACH DELETE x) + WITH this, meta, collect(DISTINCT this_actors0) as this_actors0_to_delete + WITH this,this_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta + FOREACH(x IN this_actors0_to_delete | DETACH DELETE x) + DETACH DELETE this + WITH meta + UNWIND meta AS m + RETURN collect(DISTINCT m) AS meta" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"this_id\\": \\"123\\", + \\"this_deleteMovies\\": { + \\"args\\": { + \\"delete\\": { + \\"actors\\": [ + { + \\"where\\": { + \\"node\\": { + \\"name\\": \\"Actor to delete\\" + } + }, + \\"delete\\": { + \\"movies\\": [ + { + \\"where\\": { + \\"node\\": { + \\"id\\": \\"321\\" + } + }, + \\"delete\\": { + \\"actors\\": [ + { + \\"where\\": { + \\"node\\": { + \\"name\\": \\"Another actor to delete\\" + } + } + } + ] + } + } + ] + } + } + ] + } + } + } + }" + `); + }); }); From 3bee34632b73c80ca2013e3c412a8608dfea1f1e Mon Sep 17 00:00:00 2001 From: angrykoala Date: Tue, 8 Mar 2022 12:58:32 +0000 Subject: [PATCH 6/7] delete meta fixes --- docs/rfcs/rfc-007-subscriptions.md | 6 +- .../graphql/src/schema/resolvers/create.ts | 36 +---------- .../graphql/src/schema/resolvers/delete.ts | 36 +---------- .../subscriptions/publish-events-to-plugin.ts | 61 +++++++++++++++++++ .../{event-meta.ts => subscriptions-event.ts} | 4 +- .../src/translate/create-delete-and-params.ts | 3 +- .../subscriptions/create-event-meta.ts | 8 +-- packages/graphql/src/types.ts | 4 +- packages/graphql/src/utils/execute.ts | 8 +-- .../tests/utils/TestSubscriptionPlugin.ts | 6 +- 10 files changed, 85 insertions(+), 87 deletions(-) create mode 100644 packages/graphql/src/schema/resolvers/subscriptions/publish-events-to-plugin.ts rename packages/graphql/src/subscriptions/{event-meta.ts => subscriptions-event.ts} (95%) diff --git a/docs/rfcs/rfc-007-subscriptions.md b/docs/rfcs/rfc-007-subscriptions.md index 31f2abf28c..b8029e099b 100644 --- a/docs/rfcs/rfc-007-subscriptions.md +++ b/docs/rfcs/rfc-007-subscriptions.md @@ -172,7 +172,7 @@ class Neo4jGraphQLSubscriptionsPlugin { this.events = new EventEmitter(); } - abstract public publish(eventMeta: EventMeta); + abstract public publish(eventMeta: SubscriptionsEvent); } ``` @@ -180,7 +180,7 @@ The "local" implementation of this will look something like: ```ts class Neo4jGraphQLSubscriptionsLocalPlugin extends Neo4jGraphQLSubscriptionsPlugin { - public publish(eventMeta: EventMeta) { + public publish(eventMeta: SubscriptionsEvent) { this.events.emit(eventMeta); } } @@ -192,7 +192,7 @@ And in rough pseudocode, an implementation of this using an AMQP broker would lo class Neo4jGraphQLSubscriptionsAMQPPlugin extends Neo4jGraphQLSubscriptionsPlugin { private amqpConnection; - public publish(eventMeta: EventMeta) { + public publish(eventMeta: SubscriptionsEvent) { amqpConnection.publish(eventMeta); } diff --git a/packages/graphql/src/schema/resolvers/create.ts b/packages/graphql/src/schema/resolvers/create.ts index 94eb5d71ea..9e9adf6626 100644 --- a/packages/graphql/src/schema/resolvers/create.ts +++ b/packages/graphql/src/schema/resolvers/create.ts @@ -23,8 +23,7 @@ import { translateCreate } from "../../translate"; import { Node } from "../../classes"; import { Context } from "../../types"; import getNeo4jResolveTree from "../../utils/get-neo4j-resolve-tree"; -import { EventMeta, RawEventMeta } from "../../subscriptions/event-meta"; -import { serializeNeo4jValue } from "../../utils/neo4j-serializers"; +import { publishEventsToPlugin } from "./subscriptions/publish-events-to-plugin"; export default function createResolver({ node }: { node: Node }) { async function resolve(_root: any, args: any, _context: unknown, info: GraphQLResolveInfo) { @@ -44,15 +43,7 @@ export default function createResolver({ node }: { node: Node }) { ) as FieldNode; const nodeKey = nodeProjection?.alias ? nodeProjection.alias.value : nodeProjection?.name?.value; - const subscriptionsPlugin = context.plugins?.subscriptions; - if (subscriptionsPlugin) { - const metaData: RawEventMeta[] = executeResult.records[0]?.meta || []; - for (const meta of metaData) { - const serializedMeta = serializeEventMeta(meta); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - subscriptionsPlugin.publish(serializedMeta); - } - } + publishEventsToPlugin(executeResult, context.plugins?.subscriptions); return { info: { @@ -69,26 +60,3 @@ export default function createResolver({ node }: { node: Node }) { args: { input: `[${node.name}CreateInput!]!` }, }; } - -function serializeProperties(properties: Record | undefined): Record | undefined { - if (!properties) { - return undefined; - } - - return Object.entries(properties).reduce((serializedProps, [k, v]) => { - serializedProps[k] = serializeNeo4jValue(v); - return serializedProps; - }, {} as Record); -} - -function serializeEventMeta(event: RawEventMeta): EventMeta { - return { - id: serializeNeo4jValue(event.id), - timestamp: serializeNeo4jValue(event.timestamp), - event: event.event, - properties: { - old: serializeProperties(event.properties.old), - new: serializeProperties(event.properties.new), - }, - } as EventMeta; -} diff --git a/packages/graphql/src/schema/resolvers/delete.ts b/packages/graphql/src/schema/resolvers/delete.ts index 5b82df2e2d..0a061d82be 100644 --- a/packages/graphql/src/schema/resolvers/delete.ts +++ b/packages/graphql/src/schema/resolvers/delete.ts @@ -23,8 +23,7 @@ import { execute } from "../../utils"; import { translateDelete } from "../../translate"; import { Context } from "../../types"; import { Node } from "../../classes"; -import { EventMeta, RawEventMeta } from "../../subscriptions/event-meta"; -import { serializeNeo4jValue } from "../../utils/neo4j-serializers"; +import { publishEventsToPlugin } from "./subscriptions/publish-events-to-plugin"; export default function deleteResolver({ node }: { node: Node }) { async function resolve(_root: any, args: any, _context: unknown, info: GraphQLResolveInfo) { @@ -38,15 +37,7 @@ export default function deleteResolver({ node }: { node: Node }) { context, }); - const subscriptionsPlugin = context.plugins?.subscriptions; - if (subscriptionsPlugin) { - const metaData: RawEventMeta[] = executeResult.records[0]?.meta || []; - for (const meta of metaData) { - const serializedMeta = serializeEventMeta(meta); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - subscriptionsPlugin.publish(serializedMeta); - } - } + publishEventsToPlugin(executeResult, context.plugins?.subscriptions); return { bookmark: executeResult.bookmark, ...executeResult.statistics }; } @@ -64,26 +55,3 @@ export default function deleteResolver({ node }: { node: Node }) { }, }; } - -function serializeProperties(properties: Record | undefined): Record | undefined { - if (!properties) { - return undefined; - } - - return Object.entries(properties).reduce((serializedProps, [k, v]) => { - serializedProps[k] = serializeNeo4jValue(v); - return serializedProps; - }, {} as Record); -} - -function serializeEventMeta(event: RawEventMeta): EventMeta { - return { - id: serializeNeo4jValue(event.id), - timestamp: serializeNeo4jValue(event.timestamp), - event: event.event, - properties: { - old: serializeProperties(event.properties.old), - new: serializeProperties(event.properties.new), - }, - } as EventMeta; -} diff --git a/packages/graphql/src/schema/resolvers/subscriptions/publish-events-to-plugin.ts b/packages/graphql/src/schema/resolvers/subscriptions/publish-events-to-plugin.ts new file mode 100644 index 0000000000..ea36810b3a --- /dev/null +++ b/packages/graphql/src/schema/resolvers/subscriptions/publish-events-to-plugin.ts @@ -0,0 +1,61 @@ +/* + * 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 { ExecuteResult } from "../../../utils/execute"; +import { serializeNeo4jValue } from "../../../utils/neo4j-serializers"; +import { Neo4jGraphQLSubscriptionsPlugin } from "../../../types"; +import { EventMeta, SubscriptionsEvent } from "../../../subscriptions/subscriptions-event"; + +export function publishEventsToPlugin( + executeResult: ExecuteResult, + plugin: Neo4jGraphQLSubscriptionsPlugin | undefined +): void { + if (plugin) { + const metaData: EventMeta[] = executeResult.records[0]?.meta || []; + + for (const meta of metaData) { + const serializedMeta = serializeEvent(meta); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + plugin.publish(serializedMeta); + } + } +} + +function serializeEvent(event: EventMeta): SubscriptionsEvent { + return { + id: serializeNeo4jValue(event.id), + timestamp: serializeNeo4jValue(event.timestamp), + event: event.event, + properties: { + old: serializeProperties(event.properties.old), + new: serializeProperties(event.properties.new), + }, + } as SubscriptionsEvent; +} + +function serializeProperties(properties: Record | undefined): Record | undefined { + if (!properties) { + return undefined; + } + + return Object.entries(properties).reduce((serializedProps, [k, v]) => { + serializedProps[k] = serializeNeo4jValue(v); + return serializedProps; + }, {} as Record); +} diff --git a/packages/graphql/src/subscriptions/event-meta.ts b/packages/graphql/src/subscriptions/subscriptions-event.ts similarity index 95% rename from packages/graphql/src/subscriptions/event-meta.ts rename to packages/graphql/src/subscriptions/subscriptions-event.ts index ff043fdb44..55dea7ac2b 100644 --- a/packages/graphql/src/subscriptions/event-meta.ts +++ b/packages/graphql/src/subscriptions/subscriptions-event.ts @@ -19,7 +19,7 @@ import * as neo4j from "neo4j-driver"; -export type RawEventMeta = { +export type EventMeta = { event: "create" | "update" | "delete"; properties: { old: Record; @@ -29,7 +29,7 @@ export type RawEventMeta = { timestamp: neo4j.Integer | string | number; }; -export type EventMeta = ( +export type SubscriptionsEvent = ( | { event: "create"; properties: { diff --git a/packages/graphql/src/translate/create-delete-and-params.ts b/packages/graphql/src/translate/create-delete-and-params.ts index a3b44f54cf..ced48c0f79 100644 --- a/packages/graphql/src/translate/create-delete-and-params.ts +++ b/packages/graphql/src/translate/create-delete-and-params.ts @@ -23,6 +23,7 @@ import createAuthAndParams from "./create-auth-and-params"; import createConnectionWhereAndParams from "./where/create-connection-where-and-params"; import { AUTH_FORBIDDEN_ERROR, META_CYPHER_VARIABLE } from "../constants"; import { createEventMetaObject } from "./subscriptions/create-event-meta"; +import { filterMetaVariable } from "./subscriptions/filter-meta-variable"; interface Res { strs: string[]; @@ -217,7 +218,7 @@ function createDeleteAndParams({ if (context.subscriptionsEnabled) { res.strs.push( `WITH ${[ - ...withVars.filter((v) => v !== META_CYPHER_VARIABLE), + ...filterMetaVariable(withVars), nodeToDelete, ]}, REDUCE(m=${META_CYPHER_VARIABLE}, n IN ${nodeToDelete} | m + ${createEventMetaObject({ event: "delete", diff --git a/packages/graphql/src/translate/subscriptions/create-event-meta.ts b/packages/graphql/src/translate/subscriptions/create-event-meta.ts index db30dfd07b..32d293c520 100644 --- a/packages/graphql/src/translate/subscriptions/create-event-meta.ts +++ b/packages/graphql/src/translate/subscriptions/create-event-meta.ts @@ -19,18 +19,18 @@ import { META_CYPHER_VARIABLE } from "../../constants"; -export type EventMetaType = "create" | "update" | "delete"; +export type SubscriptionsEventType = "create" | "update" | "delete"; -export function createEventMeta({ event, nodeVariable }: { event: EventMetaType; nodeVariable: string }): string { +export function createEventMeta({ event, nodeVariable }: { event: SubscriptionsEventType; nodeVariable: string }): string { return `${META_CYPHER_VARIABLE} + ${createEventMetaObject({ event, nodeVariable })} AS ${META_CYPHER_VARIABLE}`; } -export function createEventMetaObject({ event, nodeVariable }: { event: EventMetaType; nodeVariable: string }): string { +export function createEventMetaObject({ event, nodeVariable }: { event: SubscriptionsEventType; nodeVariable: string }): string { const properties = createEventMetaProperties({ event, nodeVariable }); return `{ event: "${event}", id: id(${nodeVariable}), ${properties}, timestamp: timestamp() }`; } -function createEventMetaProperties({ event, nodeVariable }: { event: EventMetaType; nodeVariable: string }): string { +function createEventMetaProperties({ event, nodeVariable }: { event: SubscriptionsEventType; nodeVariable: string }): string { let oldProps: string; let newProps: string; diff --git a/packages/graphql/src/types.ts b/packages/graphql/src/types.ts index fd63fd6c19..2239f6022c 100644 --- a/packages/graphql/src/types.ts +++ b/packages/graphql/src/types.ts @@ -23,7 +23,7 @@ import { ResolveTree } from "graphql-parse-resolve-info"; import { Driver, Integer } from "neo4j-driver"; import { Node, Relationship } from "./classes"; import { RelationshipQueryDirectionOption } from "./constants"; -import { EventMeta } from "./subscriptions/event-meta"; +import { SubscriptionsEvent } from "./subscriptions/subscriptions-event"; export type DriverConfig = { database?: string; @@ -346,7 +346,7 @@ export interface Neo4jGraphQLAuthPlugin { export interface Neo4jGraphQLSubscriptionsPlugin { events: EventEmitter; - publish(eventMeta: EventMeta): Promise; + publish(eventMeta: SubscriptionsEvent): Promise; } export interface Neo4jGraphQLPlugins { diff --git a/packages/graphql/src/utils/execute.ts b/packages/graphql/src/utils/execute.ts index 3bbaf4af66..fd9b5c1ad9 100644 --- a/packages/graphql/src/utils/execute.ts +++ b/packages/graphql/src/utils/execute.ts @@ -37,7 +37,7 @@ import environment from "../environment"; const debug = Debug(DEBUG_EXECUTE); -interface ExecuteResult { +export interface ExecuteResult { bookmark: string | null; result: QueryResult; statistics: Record; @@ -100,9 +100,9 @@ async function execute(input: { try { debug("%s", `About to execute Cypher:\nCypher:\n${cypher}\nParams:\n${JSON.stringify(input.params, null, 2)}`); - const result: QueryResult = await session[ - `${input.defaultAccessMode.toLowerCase()}Transaction` - ]((tx: Transaction) => tx.run(cypher, input.params)); + const result: QueryResult = await session[`${input.defaultAccessMode.toLowerCase()}Transaction`]( + (tx: Transaction) => tx.run(cypher, input.params) + ); const records = result.records.map((r) => r.toObject()); diff --git a/packages/graphql/tests/utils/TestSubscriptionPlugin.ts b/packages/graphql/tests/utils/TestSubscriptionPlugin.ts index 0433572016..9b922af07b 100644 --- a/packages/graphql/tests/utils/TestSubscriptionPlugin.ts +++ b/packages/graphql/tests/utils/TestSubscriptionPlugin.ts @@ -17,16 +17,16 @@ * limitations under the License. */ import EventEmitter from "events"; -import { EventMeta } from "../../src/subscriptions/event-meta"; +import { SubscriptionsEvent } from "../../src/subscriptions/subscriptions-event"; import { Neo4jGraphQLSubscriptionsPlugin } from "../../src/types"; export class TestSubscriptionsPlugin implements Neo4jGraphQLSubscriptionsPlugin { public events: EventEmitter = {} as EventEmitter; - public eventList: EventMeta[] = []; + public eventList: SubscriptionsEvent[] = []; // eslint-disable-next-line @typescript-eslint/require-await - async publish(eventMeta: EventMeta): Promise { + async publish(eventMeta: SubscriptionsEvent): Promise { this.eventList.push(eventMeta); } } From 2b594ef05a5fe5daee8b83962803972fbcc23b67 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Tue, 8 Mar 2022 13:14:31 +0000 Subject: [PATCH 7/7] fix delete reduce for subscriptions --- .../subscriptions/publish-events-to-plugin.ts | 10 +++++----- .../src/translate/create-delete-and-params.ts | 13 ++++++------ .../graphql/src/translate/translate-delete.ts | 20 ++++++++++--------- .../delete/delete-auth.int.test.ts | 2 +- .../subscriptions/delete.test.ts | 8 ++++---- 5 files changed, 27 insertions(+), 26 deletions(-) diff --git a/packages/graphql/src/schema/resolvers/subscriptions/publish-events-to-plugin.ts b/packages/graphql/src/schema/resolvers/subscriptions/publish-events-to-plugin.ts index ea36810b3a..dcd126bb90 100644 --- a/packages/graphql/src/schema/resolvers/subscriptions/publish-events-to-plugin.ts +++ b/packages/graphql/src/schema/resolvers/subscriptions/publish-events-to-plugin.ts @@ -27,12 +27,12 @@ export function publishEventsToPlugin( plugin: Neo4jGraphQLSubscriptionsPlugin | undefined ): void { if (plugin) { - const metaData: EventMeta[] = executeResult.records[0]?.meta || []; + const metadata: EventMeta[] = executeResult.records[0]?.meta || []; - for (const meta of metaData) { - const serializedMeta = serializeEvent(meta); + for (const rawEvent of metadata) { + const subscriptionsEvent = serializeEvent(rawEvent); // eslint-disable-next-line @typescript-eslint/no-floating-promises - plugin.publish(serializedMeta); + plugin.publish(subscriptionsEvent); } } } @@ -46,7 +46,7 @@ function serializeEvent(event: EventMeta): SubscriptionsEvent { old: serializeProperties(event.properties.old), new: serializeProperties(event.properties.new), }, - } as SubscriptionsEvent; + } as SubscriptionsEvent; // Casting here because ts is not smart enough to get the difference between create|update|delete } function serializeProperties(properties: Record | undefined): Record | undefined { diff --git a/packages/graphql/src/translate/create-delete-and-params.ts b/packages/graphql/src/translate/create-delete-and-params.ts index ced48c0f79..d865172023 100644 --- a/packages/graphql/src/translate/create-delete-and-params.ts +++ b/packages/graphql/src/translate/create-delete-and-params.ts @@ -216,14 +216,13 @@ function createDeleteAndParams({ ); if (context.subscriptionsEnabled) { + const metaObjectStr = createEventMetaObject({ + event: "delete", + nodeVariable: "n", + }); + const reduceStr = `REDUCE(m=${META_CYPHER_VARIABLE}, n IN ${nodeToDelete} | m + ${metaObjectStr}) AS ${META_CYPHER_VARIABLE}`; res.strs.push( - `WITH ${[ - ...filterMetaVariable(withVars), - nodeToDelete, - ]}, REDUCE(m=${META_CYPHER_VARIABLE}, n IN ${nodeToDelete} | m + ${createEventMetaObject({ - event: "delete", - nodeVariable: "n", - })}) AS ${META_CYPHER_VARIABLE}` + `WITH ${[...filterMetaVariable(withVars), nodeToDelete].join(", ")}, ${reduceStr}` ); } diff --git a/packages/graphql/src/translate/translate-delete.ts b/packages/graphql/src/translate/translate-delete.ts index 74c7e11db5..178c020704 100644 --- a/packages/graphql/src/translate/translate-delete.ts +++ b/packages/graphql/src/translate/translate-delete.ts @@ -25,7 +25,7 @@ import createDeleteAndParams from "./create-delete-and-params"; import translateTopLevelMatch from "./translate-top-level-match"; import { createEventMeta } from "./subscriptions/create-event-meta"; -function translateDelete({ context, node }: { context: Context; node: Node }): [string, any] { +export default function translateDelete({ context, node }: { context: Context; node: Node }): [string, any] { const { resolveTree } = context; const deleteInput = resolveTree.args.delete; const varName = "this"; @@ -89,16 +89,18 @@ function translateDelete({ context, node }: { context: Context; node: Node }): [ deleteStr, allowStr, `DETACH DELETE ${varName}`, - ...(context.subscriptionsEnabled - ? [ - `WITH ${META_CYPHER_VARIABLE}`, - `UNWIND ${META_CYPHER_VARIABLE} AS m`, - `RETURN collect(DISTINCT m) AS meta`, - ] - : []), + ...getDeleteReturn(context), ]; return [cypher.filter(Boolean).join("\n"), cypherParams]; } -export default translateDelete; +function getDeleteReturn(context: Context): Array { + return context.subscriptionsEnabled + ? [ + `WITH ${META_CYPHER_VARIABLE}`, + `UNWIND ${META_CYPHER_VARIABLE} AS m`, + `RETURN collect(DISTINCT m) AS ${META_CYPHER_VARIABLE}`, + ] + : []; +} diff --git a/packages/graphql/tests/integration/subscriptions/delete/delete-auth.int.test.ts b/packages/graphql/tests/integration/subscriptions/delete/delete-auth.int.test.ts index a757f75104..1a162d1e30 100644 --- a/packages/graphql/tests/integration/subscriptions/delete/delete-auth.int.test.ts +++ b/packages/graphql/tests/integration/subscriptions/delete/delete-auth.int.test.ts @@ -90,7 +90,7 @@ describe("Subscriptions delete", () => { source: query, contextValue: { driver, req, driverConfig: { bookmarks: session.lastBookmark() } }, }); - console.log(JSON.stringify(gqlResult, null, 4)); + expect((gqlResult.errors as any[])[0].message).toBe("Forbidden"); } finally { await session.close(); diff --git a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts index bf8cb188df..ce2059c370 100644 --- a/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts +++ b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts @@ -107,7 +107,7 @@ describe("Subscriptions metadata on delete", () => { OPTIONAL MATCH (this)<-[this_actors0_relationship:ACTED_IN]-(this_actors0:Actor) WHERE this_actors0.name = $this_deleteMovies.args.delete.actors[0].where.node.name WITH this, meta, collect(DISTINCT this_actors0) as this_actors0_to_delete - WITH this,this_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta + WITH this, this_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta FOREACH(x IN this_actors0_to_delete | DETACH DELETE x) DETACH DELETE this WITH meta @@ -178,13 +178,13 @@ describe("Subscriptions metadata on delete", () => { OPTIONAL MATCH (this_actors0_movies0)<-[this_actors0_movies0_actors0_relationship:ACTED_IN]-(this_actors0_movies0_actors0:Actor) WHERE this_actors0_movies0_actors0.name = $this_deleteMovies.args.delete.actors[0].delete.movies[0].delete.actors[0].where.node.name WITH this, meta, this_actors0, this_actors0_movies0, collect(DISTINCT this_actors0_movies0_actors0) as this_actors0_movies0_actors0_to_delete - WITH this,this_actors0,this_actors0_movies0,this_actors0_movies0_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_movies0_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta + WITH this, this_actors0, this_actors0_movies0, this_actors0_movies0_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_movies0_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta FOREACH(x IN this_actors0_movies0_actors0_to_delete | DETACH DELETE x) WITH this, meta, this_actors0, collect(DISTINCT this_actors0_movies0) as this_actors0_movies0_to_delete - WITH this,this_actors0,this_actors0_movies0_to_delete, REDUCE(m=meta, n IN this_actors0_movies0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta + WITH this, this_actors0, this_actors0_movies0_to_delete, REDUCE(m=meta, n IN this_actors0_movies0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta FOREACH(x IN this_actors0_movies0_to_delete | DETACH DELETE x) WITH this, meta, collect(DISTINCT this_actors0) as this_actors0_to_delete - WITH this,this_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta + WITH this, this_actors0_to_delete, REDUCE(m=meta, n IN this_actors0_to_delete | m + { event: \\"delete\\", id: id(n), properties: { old: n { .* }, new: null }, timestamp: timestamp() }) AS meta FOREACH(x IN this_actors0_to_delete | DETACH DELETE x) DETACH DELETE this WITH meta