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 7cf88e6b49..0a061d82be 100644 --- a/packages/graphql/src/schema/resolvers/delete.ts +++ b/packages/graphql/src/schema/resolvers/delete.ts @@ -23,6 +23,7 @@ import { execute } from "../../utils"; import { translateDelete } from "../../translate"; import { Context } from "../../types"; import { Node } from "../../classes"; +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) { @@ -36,6 +37,8 @@ export default function deleteResolver({ node }: { node: Node }) { context, }); + publishEventsToPlugin(executeResult, context.plugins?.subscriptions); + return { bookmark: executeResult.bookmark, ...executeResult.statistics }; } 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..dcd126bb90 --- /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 rawEvent of metadata) { + const subscriptionsEvent = serializeEvent(rawEvent); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + plugin.publish(subscriptionsEvent); + } + } +} + +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; // Casting here because ts is not smart enough to get the difference between create|update|delete +} + +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 9d457cfe19..d865172023 100644 --- a/packages/graphql/src/translate/create-delete-and-params.ts +++ b/packages/graphql/src/translate/create-delete-and-params.ts @@ -21,7 +21,9 @@ 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 { createEventMetaObject } from "./subscriptions/create-event-meta"; +import { filterMetaVariable } from "./subscriptions/filter-meta-variable"; interface Res { strs: string[]; @@ -208,11 +210,23 @@ function createDeleteAndParams({ } } + const nodeToDelete = `${_varName}_to_delete`; res.strs.push( - `WITH ${[...withVars, `collect(DISTINCT ${_varName}) as ${_varName}_to_delete`].join(", ")}` + `WITH ${[...withVars, `collect(DISTINCT ${_varName}) as ${nodeToDelete}`].join(", ")}` ); - res.strs.push(`FOREACH(x IN ${_varName}_to_delete | DETACH DELETE x)`); + 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].join(", ")}, ${reduceStr}` + ); + } + + 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..32d293c520 100644 --- a/packages/graphql/src/translate/subscriptions/create-event-meta.ts +++ b/packages/graphql/src/translate/subscriptions/create-event-meta.ts @@ -19,15 +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 { - const properties = createEventMetaProperties({ event, nodeVariable }); +export function createEventMeta({ event, nodeVariable }: { event: SubscriptionsEventType; nodeVariable: string }): string { + 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: 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/translate/translate-delete.ts b/packages/graphql/src/translate/translate-delete.ts index 6ffc848b76..178c020704 100644 --- a/packages/graphql/src/translate/translate-delete.ts +++ b/packages/graphql/src/translate/translate-delete.ts @@ -19,12 +19,13 @@ 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] { +export default function translateDelete({ context, node }: { context: Context; node: Node }): [string, any] { const { resolveTree } = context; const deleteInput = resolveTree.args.delete; const varName = "this"; @@ -33,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] }; @@ -48,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) { @@ -58,7 +67,7 @@ function translateDelete({ context, node }: { context: Context; node: Node }): [ deleteInput, varName, parentVar: varName, - withVars: [varName], + withVars, parameterPrefix: `${varName}_${resolveTree.name}.args.delete`, }); [deleteStr] = deleteAndParams; @@ -71,9 +80,27 @@ 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}`, + ...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/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/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..1a162d1e30 --- /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() } }, + }); + + expect((gqlResult.errors as any[])[0].message).toBe("Forbidden"); + } finally { + await session.close(); + } + }); +}); 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..e13058a86c --- /dev/null +++ b/packages/graphql/tests/integration/subscriptions/delete/delete.int.test.ts @@ -0,0 +1,244 @@ +/* + * 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, UniqueType } 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; + + 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} { + id: ID! + 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, + }); + }); + + 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).toBe(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 }, + }, + ]); + }); + + 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).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 }, + }, + ]) + ); + }); + + 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 new file mode 100644 index 0000000000..ce2059c370 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/subscriptions/delete.test.ts @@ -0,0 +1,238 @@ +/* + * 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("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, + }); + + 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, 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\\": \\"1\\", + \\"this_deleteMovies\\": { + \\"args\\": { + \\"delete\\": { + \\"actors\\": [ + { + \\"where\\": { + \\"node\\": { + \\"name\\": \\"1\\" + } + } + } + ] + } + } + } + }" + `); + }); + 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\\" + } + } + } + ] + } + } + ] + } + } + ] + } + } + } + }" + `); + }); +}); 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); } } 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",