From 10bfb8d565b7f8e66afaff9464bfd0b8d36e1170 Mon Sep 17 00:00:00 2001 From: Daniel Starns Date: Fri, 30 Oct 2020 15:37:51 +0000 Subject: [PATCH 1/5] test: add test cases for delete mutation --- .../tests/integration/delete.int.test.ts | 74 +++++++++++++++++++ .../tests/tck/tck-test-files/cypher-delete.md | 43 +++++++++++ .../tck/tck-test-files/schema-generation.md | 27 +++++++ packages/graphql/tests/tck/tck.test.ts | 51 ++++++++++--- .../tests/unit/schema/delete.unit.test.ts | 22 ++++++ 5 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 packages/graphql/tests/integration/delete.int.test.ts create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher-delete.md create mode 100644 packages/graphql/tests/unit/schema/delete.unit.test.ts diff --git a/packages/graphql/tests/integration/delete.int.test.ts b/packages/graphql/tests/integration/delete.int.test.ts new file mode 100644 index 0000000000..c3bb022176 --- /dev/null +++ b/packages/graphql/tests/integration/delete.int.test.ts @@ -0,0 +1,74 @@ +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { generate } from "randomstring"; +import neo4j from "./neo4j"; +import makeAugmentedSchema from "../../src/schema/make-augmented-schema"; + +describe("delete", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should delete a single movie", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID! + } + `; + + const neoSchema = makeAugmentedSchema({ typeDefs }); + + const id = generate({ + charset: "alphabetic", + }); + + const mutation = ` + mutation($id: ID!) { + deleteMovies(where: { id: $id }) { + nodesDeleted + relationshipsDeleted + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {id: $id}) + `, + { id } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: mutation, + variableValues: { id }, + contextValue: { driver }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult?.data?.deleteMovies).toEqual({ nodesDeleted: 1, relationshipsDeleted: 0 }); + + const reFind = await session.run( + ` + MATCH (m:Movie {id: $id}) + RETURN m + `, + { id } + ); + + expect(reFind.records.length).toEqual(0); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/tck/tck-test-files/cypher-delete.md b/packages/graphql/tests/tck/tck-test-files/cypher-delete.md new file mode 100644 index 0000000000..f6da990809 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher-delete.md @@ -0,0 +1,43 @@ +## Cypher Delete + +Tests delete operations. + +Schema: + +```schema +type Movie { + id: ID +} +``` + +--- + +### Simple Delete + +**GraphQL input** + +```graphql +mutation { + deleteMovies(where: {id: "123"}) { + nodesDeleted + } +} +``` + +**Expected Cypher output** + +```cypher +MATCH (this:Movie) +WHERE this.id = $this_id +DETACH DELETE this +``` + +**Expected Cypher params** + +```cypher-params +{ + "this_id": "123" +} +``` + +--- \ No newline at end of file diff --git a/packages/graphql/tests/tck/tck-test-files/schema-generation.md b/packages/graphql/tests/tck/tck-test-files/schema-generation.md index fc4a5cf8cd..ef3bd71658 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema-generation.md +++ b/packages/graphql/tests/tck/tck-test-files/schema-generation.md @@ -21,6 +21,11 @@ type Movie { id: ID } +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + input MovieAND { id: ID id_IN: [ID] @@ -59,6 +64,7 @@ input MovieWhere { type Mutation { createMovies(input: [MovieCreateInput]!): [Movie]! + deleteMovies(where: MovieWhere): DeleteInfo! } type Query { @@ -90,6 +96,11 @@ type Actor { name: String } +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + input ActorAND { name: String name_IN: [String] @@ -175,6 +186,8 @@ input MovieWhere { type Mutation { createActors(input: [ActorCreateInput]!): [Actor]! createMovies(input: [MovieCreateInput]!): [Movie]! + deleteMovies(where: MovieWhere): DeleteInfo! + deleteActors(where: ActorWhere): DeleteInfo! } type Query { @@ -209,6 +222,11 @@ type Actor { movies(where: MovieWhere, options: MovieOptions): [Movie] } +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + input ActorAND { name: String name_IN: [String] @@ -319,6 +337,8 @@ input MovieWhere { type Mutation { createActors(input: [ActorCreateInput]!): [Actor]! createMovies(input: [MovieCreateInput]!): [Movie]! + deleteMovies(where: MovieWhere): DeleteInfo! + deleteActors(where: ActorWhere): DeleteInfo! } type Query { @@ -355,6 +375,11 @@ type Actor { name: String } +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + input ActorAND { name: String name_IN: [String] @@ -435,6 +460,8 @@ input MovieWhere { type Mutation { createActors(input: [ActorCreateInput]!): [Actor]! createMovies(input: [MovieCreateInput]!): [Movie]! + deleteMovies(where: MovieWhere): DeleteInfo! + deleteActors(where: ActorWhere): DeleteInfo! } type Query { diff --git a/packages/graphql/tests/tck/tck.test.ts b/packages/graphql/tests/tck/tck.test.ts index 04b48c9fb3..96b05b01d3 100644 --- a/packages/graphql/tests/tck/tck.test.ts +++ b/packages/graphql/tests/tck/tck.test.ts @@ -30,17 +30,10 @@ describe("TCK Generated tests", () => { const cypherQuery = test.cypherQuery as string; const cypherParams = test.cypherParams as any; - const resolver = (_roto: any, _params: any, context: any, resolveInfo: any) => { - if (!context) { - context = {}; - } - context.neoSchema = neoSchema; - + const compare = (context: any, resolveInfo: any) => { const [cQuery, cQueryParams] = translate({ context, resolveInfo }); expect(trimmer(cQuery)).toEqual(trimmer(cypherQuery)); expect(serialize(cQueryParams)).toEqual(cypherParams); - - return []; }; const queries = document.definitions.reduce((res, def) => { @@ -50,7 +43,16 @@ describe("TCK Generated tests", () => { return { ...res, - [pluralize(def.name.value)]: resolver, + [pluralize(def.name.value)]: (_root: any, _params: any, context: any, resolveInfo: any) => { + if (!context) { + context = {}; + } + context.neoSchema = neoSchema; + + compare(context, resolveInfo); + + return []; + }, }; }, {}); @@ -61,7 +63,36 @@ describe("TCK Generated tests", () => { return { ...res, - [`create${pluralize(def.name.value)}`]: resolver, + [`create${pluralize(def.name.value)}`]: ( + _root: any, + _params: any, + context: any, + resolveInfo: any + ) => { + if (!context) { + context = {}; + } + context.neoSchema = neoSchema; + + compare(context, resolveInfo); + + return []; + }, + [`delete${pluralize(def.name.value)}`]: ( + _root: any, + _params: any, + context: any, + resolveInfo: any + ) => { + if (!context) { + context = {}; + } + context.neoSchema = neoSchema; + + compare(context, resolveInfo); + + return { nodesDeleted: 1, relationshipsDeleted: 1 }; + }, }; }, {}); diff --git a/packages/graphql/tests/unit/schema/delete.unit.test.ts b/packages/graphql/tests/unit/schema/delete.unit.test.ts new file mode 100644 index 0000000000..3e83227f59 --- /dev/null +++ b/packages/graphql/tests/unit/schema/delete.unit.test.ts @@ -0,0 +1,22 @@ +import { NeoSchema, Node } from "../../../src/classes"; +import deleteResolver from "../../../src/schema/delete"; + +describe("delete", () => { + test("should return the correct; type, args and resolve", () => { + // @ts-ignore + const neoSchema: NeoSchema = {}; + + // @ts-ignore + const node: Node = { + // @ts-ignore + name: "Movie", + }; + + const result = deleteResolver({ node, getSchema: () => neoSchema }); + expect(result.type).toEqual(`DeleteInfo!`); + expect(result.resolve).toBeInstanceOf(Function); + expect(result.args).toMatchObject({ + where: `MovieWhere`, + }); + }); +}); From 85c9f80ea0abfe03515e387ab19447fb6120b2ee Mon Sep 17 00:00:00 2001 From: Daniel Starns Date: Fri, 30 Oct 2020 15:38:32 +0000 Subject: [PATCH 2/5] feat: add delete mutation --- packages/graphql/src/schema/delete.ts | 37 ++++++++++++++++++ .../src/schema/make-augmented-schema.ts | 10 +++++ packages/graphql/src/translate/translate.ts | 39 +++++++++++++++++++ packages/graphql/src/utils/execute.ts | 5 +++ 4 files changed, 91 insertions(+) create mode 100644 packages/graphql/src/schema/delete.ts diff --git a/packages/graphql/src/schema/delete.ts b/packages/graphql/src/schema/delete.ts new file mode 100644 index 0000000000..de7e113226 --- /dev/null +++ b/packages/graphql/src/schema/delete.ts @@ -0,0 +1,37 @@ +import { GraphQLResolveInfo } from "graphql"; +import { execute } from "../utils"; +import { translate } from "../translate"; +import { NeoSchema, Node } from "../classes"; + +function deleteResolver({ node, getSchema }: { node: Node; getSchema: () => NeoSchema }) { + async function resolve(_root: any, _args: any, context: any, resolveInfo: GraphQLResolveInfo) { + const neoSchema = getSchema(); + context.neoSchema = neoSchema; + + const { driver } = context; + if (!driver) { + throw new Error("context.driver missing"); + } + + const [cypher, params] = translate({ context, resolveInfo }); + + const result = await execute({ + cypher, + params, + driver, + defaultAccessMode: "WRITE", + neoSchema, + statistics: true, + }); + + return result; + } + + return { + type: `DeleteInfo!`, + resolve, + args: { where: `${node.name}Where` }, + }; +} + +export default deleteResolver; diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index c2a4ee76e2..62ec0ca926 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -11,6 +11,7 @@ import { RelationField, CypherField, PrimitiveField, BaseField } from "../types" import { upperFirstLetter } from "../utils"; import find from "./find"; import create from "./create"; +import deleteResolver from "./delete"; export interface MakeAugmentedSchemaOptions { typeDefs: any; @@ -26,6 +27,14 @@ function makeAugmentedSchema(options: MakeAugmentedSchemaOptions): NeoSchema { options, }; + composer.createObjectTC({ + name: "DeleteInfo", + fields: { + nodesDeleted: "Int!", + relationshipsDeleted: "Int!", + }, + }); + neoSchemaInput.nodes = (document.definitions.filter( (x) => x.kind === "ObjectTypeDefinition" && !["Query", "Mutation", "Subscription"].includes(x.name.value) ) as ObjectTypeDefinitionNode[]).map((definition) => { @@ -223,6 +232,7 @@ function makeAugmentedSchema(options: MakeAugmentedSchemaOptions): NeoSchema { composer.Mutation.addFields({ [`create${pluralize(node.name)}`]: create({ node, getSchema: () => neoSchema }), + [`delete${pluralize(node.name)}`]: deleteResolver({ node, getSchema: () => neoSchema }), }); }); diff --git a/packages/graphql/src/translate/translate.ts b/packages/graphql/src/translate/translate.ts index 7559046d53..ffab8ccf5c 100644 --- a/packages/graphql/src/translate/translate.ts +++ b/packages/graphql/src/translate/translate.ts @@ -145,6 +145,41 @@ function translateCreate({ return [cypher, { ...params, ...replacedProjectionParams }]; } +function translateDelete({ + neoSchema, + resolveTree, +}: { + neoSchema: NeoSchema; + resolveTree: ResolveTree; +}): [string, any] { + const node = neoSchema.nodes.find( + (x) => x.name === pluralize.singular(resolveTree.name.split("delete")[1]) + ) as Node; + const whereInput = resolveTree.args.where as GraphQLWhereArg; + const varName = "this"; + + const matchStr = `MATCH (${varName}:${node.name})`; + let whereStr = ""; + let cypherParams: { [k: string]: any } = {}; + + if (whereInput) { + const where = createWhereAndParams({ + whereInput, + varName, + }); + whereStr = where[0]; + cypherParams = { ...cypherParams, ...where[1] }; + } + + const cypher = ` + ${matchStr} + ${whereStr} + DETACH DELETE ${varName} + `; + + return [trimmer(cypher), cypherParams]; +} + function translate({ context, resolveInfo }: { context: any; resolveInfo: GraphQLResolveInfo }): [string, any] { const neoSchema: NeoSchema = context.neoSchema; if (!neoSchema || !(neoSchema instanceof NeoSchema)) { @@ -159,6 +194,10 @@ function translate({ context, resolveInfo }: { context: any; resolveInfo: GraphQ if (operationName.includes("create")) { return translateCreate({ resolveTree, neoSchema }); } + + if (operationName.includes("delete")) { + return translateDelete({ resolveTree, neoSchema }); + } } return translateRead({ resolveTree, neoSchema }); diff --git a/packages/graphql/src/utils/execute.ts b/packages/graphql/src/utils/execute.ts index 37485071c3..50feab9704 100644 --- a/packages/graphql/src/utils/execute.ts +++ b/packages/graphql/src/utils/execute.ts @@ -9,6 +9,7 @@ async function execute(input: { params: any; defaultAccessMode: "READ" | "WRITE"; neoSchema: NeoSchema; + statistics?: boolean; }): Promise { const session = input.driver.session({ defaultAccessMode: input.defaultAccessMode }); @@ -33,6 +34,10 @@ async function execute(input: { tx.run(input.cypher, serializedParams) ); + if (input.statistics) { + return result.summary.updateStatistics._stats; + } + return deserialize(result.records.map((r) => r.toObject())); } finally { await session.close(); From fb36e774b427e43c2737b9228c218ade5ea22c70 Mon Sep 17 00:00:00 2001 From: Daniel Starns Date: Mon, 2 Nov 2020 09:32:29 +0000 Subject: [PATCH 3/5] refactor: merge changes remove trimmer --- packages/graphql/src/translate/translate.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/graphql/src/translate/translate.ts b/packages/graphql/src/translate/translate.ts index 270cd3a8f8..26493a412d 100644 --- a/packages/graphql/src/translate/translate.ts +++ b/packages/graphql/src/translate/translate.ts @@ -170,13 +170,9 @@ function translateDelete({ cypherParams = { ...cypherParams, ...where[1] }; } - const cypher = ` - ${matchStr} - ${whereStr} - DETACH DELETE ${varName} - `; + const cypher = [matchStr, whereStr, `DETACH DELETE ${varName}`]; - return [trimmer(cypher), cypherParams]; + return [cypher.filter(Boolean).join("\n"), cypherParams]; } function translate({ context, resolveInfo }: { context: any; resolveInfo: GraphQLResolveInfo }): [string, any] { From 0b280f2a6907c56287cc26bed77e2559b4965029 Mon Sep 17 00:00:00 2001 From: Daniel Starns Date: Tue, 17 Nov 2020 07:48:44 +0000 Subject: [PATCH 4/5] refactor: consistant resolver naming --- packages/graphql/src/schema/make-augmented-schema.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 5850b872c2..338a66d6fb 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -9,8 +9,8 @@ import getCypherMeta from "./get-cypher-meta"; import getRelationshipMeta from "./get-relationship-meta"; import { RelationField, CypherField, PrimitiveField, BaseField } from "../types"; import { upperFirstLetter } from "../utils"; -import find from "./find"; -import create from "./create"; +import findResolver from "./find"; +import createResolver from "./create"; import deleteResolver from "./delete"; export interface MakeAugmentedSchemaOptions { @@ -227,11 +227,11 @@ function makeAugmentedSchema(options: MakeAugmentedSchemaOptions): NeoSchema { }); composer.Query.addFields({ - [pluralize(node.name)]: find({ node, getSchema: () => neoSchema }), + [pluralize(node.name)]: findResolver({ node, getSchema: () => neoSchema }), }); composer.Mutation.addFields({ - [`create${pluralize(node.name)}`]: create({ node, getSchema: () => neoSchema }), + [`create${pluralize(node.name)}`]: createResolver({ node, getSchema: () => neoSchema }), [`delete${pluralize(node.name)}`]: deleteResolver({ node, getSchema: () => neoSchema }), }); }); From 8363cc30d42e858cac82d906f1b7e20fd63534c4 Mon Sep 17 00:00:00 2001 From: Daniel Starns Date: Tue, 17 Nov 2020 07:51:21 +0000 Subject: [PATCH 5/5] test: add coverage for negative path --- .../tests/integration/delete.int.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/graphql/tests/integration/delete.int.test.ts b/packages/graphql/tests/integration/delete.int.test.ts index c3bb022176..4056eb558e 100644 --- a/packages/graphql/tests/integration/delete.int.test.ts +++ b/packages/graphql/tests/integration/delete.int.test.ts @@ -71,4 +71,61 @@ describe("delete", () => { await session.close(); } }); + + test("should not delete a movie if predicate does not yield true", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID! + } + `; + + const neoSchema = makeAugmentedSchema({ typeDefs }); + + const id = generate({ + charset: "alphabetic", + }); + + const mutation = ` + mutation($id: ID!) { + deleteMovies(where: { id: $id }) { + nodesDeleted + relationshipsDeleted + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {id: $id}) + `, + { id } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: mutation, + variableValues: { id: "NOT FOUND" }, + contextValue: { driver }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult?.data?.deleteMovies).toEqual({ nodesDeleted: 0, relationshipsDeleted: 0 }); + + const reFind = await session.run( + ` + MATCH (m:Movie {id: $id}) + RETURN m + `, + { id } + ); + + expect(reFind.records.length).toEqual(1); + } finally { + await session.close(); + } + }); });