diff --git a/packages/graphql/src/api-v6/queryIR/DeleteOperation.ts b/packages/graphql/src/api-v6/queryIR/DeleteOperation.ts new file mode 100644 index 0000000000..a0f173abaa --- /dev/null +++ b/packages/graphql/src/api-v6/queryIR/DeleteOperation.ts @@ -0,0 +1,136 @@ +/* + * 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 Cypher from "@neo4j/cypher-builder"; +import type { ConcreteEntityAdapter } from "../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; +import type { InterfaceEntityAdapter } from "../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; +import type { Filter } from "../../translate/queryAST/ast/filters/Filter"; +import type { OperationTranspileResult } from "../../translate/queryAST/ast/operations/operations"; +import { MutationOperation } from "../../translate/queryAST/ast/operations/operations"; +import type { QueryASTContext } from "../../translate/queryAST/ast/QueryASTContext"; +import type { QueryASTNode } from "../../translate/queryAST/ast/QueryASTNode"; +import type { EntitySelection, SelectionClause } from "../../translate/queryAST/ast/selection/EntitySelection"; +import { wrapSubqueriesInCypherCalls } from "../../translate/queryAST/utils/wrap-subquery-in-calls"; + +export class V6DeleteOperation extends MutationOperation { + public readonly target: ConcreteEntityAdapter | InterfaceEntityAdapter; + private selection: EntitySelection; + private filters: Filter[]; + + constructor({ + target, + selection, + filters = [], + }: { + target: ConcreteEntityAdapter | InterfaceEntityAdapter; + selection: EntitySelection; + filters?: Filter[]; + }) { + super(); + this.target = target; + this.selection = selection; + this.filters = filters; + } + + public getChildren(): QueryASTNode[] { + return [this.selection, ...this.filters]; + } + + public transpile(context: QueryASTContext): OperationTranspileResult { + if (!context.hasTarget()) { + throw new Error("No parent node found!"); + } + const { selection, nestedContext } = this.selection.apply(context); + return this.transpileTopLevel(selection, nestedContext); + } + + private transpileTopLevel( + selection: SelectionClause, + context: QueryASTContext + ): OperationTranspileResult { + this.validateSelection(selection); + const filterSubqueries = wrapSubqueriesInCypherCalls(context, this.filters, [context.target]); + const predicate = this.getPredicate(context); + const extraSelections = this.getExtraSelections(context); + + let statements = [selection, ...extraSelections, ...filterSubqueries]; + statements = this.appendFilters(statements, predicate); + statements = this.appendDeleteClause(statements, context); + const ret = Cypher.concat(...statements); + + return { clauses: [ret], projectionExpr: context.target }; + } + + private appendDeleteClause(clauses: Cypher.Clause[], context: QueryASTContext): Cypher.Clause[] { + const lastClause = this.getLastClause(clauses); + if ( + lastClause instanceof Cypher.Match || + lastClause instanceof Cypher.OptionalMatch || + lastClause instanceof Cypher.With + ) { + lastClause.detachDelete(context.target); + return clauses; + } + const extraWith = new Cypher.With("*"); + extraWith.detachDelete(context.target); + clauses.push(extraWith); + return clauses; + } + + private getLastClause(clauses: Cypher.Clause[]): Cypher.Clause { + const lastClause = clauses[clauses.length - 1]; + if (!lastClause) { + throw new Error("Transpile error"); + } + return lastClause; + } + + private appendFilters(clauses: Cypher.Clause[], predicate: Cypher.Predicate | undefined): Cypher.Clause[] { + if (!predicate) { + return clauses; + } + const lastClause = this.getLastClause(clauses); + if ( + lastClause instanceof Cypher.Match || + lastClause instanceof Cypher.OptionalMatch || + lastClause instanceof Cypher.With + ) { + lastClause.where(predicate); + return clauses; + } + const withClause = new Cypher.With("*"); + withClause.where(predicate); + clauses.push(withClause); + return clauses; + } + + private validateSelection(selection: SelectionClause): asserts selection is Cypher.Match | Cypher.With { + if (!(selection instanceof Cypher.Match || selection instanceof Cypher.With)) { + throw new Error("Cypher Yield statement is not a valid selection for Delete Operation"); + } + } + + private getPredicate(queryASTContext: QueryASTContext): Cypher.Predicate | undefined { + return Cypher.and(...this.filters.map((f) => f.getPredicate(queryASTContext))); + } + + private getExtraSelections(context: QueryASTContext): (Cypher.Match | Cypher.With)[] { + return this.getChildren().flatMap((f) => f.getSelection(context)); + } +} diff --git a/packages/graphql/src/api-v6/queryIRFactory/DeleteOperationFactory.ts b/packages/graphql/src/api-v6/queryIRFactory/DeleteOperationFactory.ts new file mode 100644 index 0000000000..c3d06ce2a8 --- /dev/null +++ b/packages/graphql/src/api-v6/queryIRFactory/DeleteOperationFactory.ts @@ -0,0 +1,78 @@ +/* + * 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 type { Neo4jGraphQLSchemaModel } from "../../schema-model/Neo4jGraphQLSchemaModel"; +import type { ConcreteEntity } from "../../schema-model/entity/ConcreteEntity"; +import { ConcreteEntityAdapter } from "../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; +import { QueryAST } from "../../translate/queryAST/ast/QueryAST"; +import { NodeSelection } from "../../translate/queryAST/ast/selection/NodeSelection"; +import { filterTruthy } from "../../utils/utils"; +import { V6DeleteOperation } from "../queryIR/DeleteOperation"; +import { FilterFactory } from "./FilterFactory"; +import type { GraphQLTreeDelete } from "./resolve-tree-parser/graphql-tree/graphql-tree"; + +export class DeleteOperationFactory { + public schemaModel: Neo4jGraphQLSchemaModel; + private filterFactory: FilterFactory; + + constructor(schemaModel: Neo4jGraphQLSchemaModel) { + this.schemaModel = schemaModel; + this.filterFactory = new FilterFactory(schemaModel); + } + + public deleteAST({ + graphQLTreeDelete, + entity, + }: { + graphQLTreeDelete: GraphQLTreeDelete; + entity: ConcreteEntity; + }): QueryAST { + const operation = this.generateDeleteOperation({ + graphQLTreeDelete, + entity, + }); + return new QueryAST(operation); + } + + private generateDeleteOperation({ + graphQLTreeDelete, + entity, + }: { + graphQLTreeDelete: GraphQLTreeDelete; + entity: ConcreteEntity; + }): V6DeleteOperation { + const targetAdapter = new ConcreteEntityAdapter(entity); + + const selection = new NodeSelection({ + target: targetAdapter, + }); + + const filters = filterTruthy([ + this.filterFactory.createFilters({ entity, where: graphQLTreeDelete.args.where }), + ]); + + const deleteOP = new V6DeleteOperation({ + target: targetAdapter, + selection, + filters, + }); + + return deleteOP; + } +} diff --git a/packages/graphql/src/api-v6/queryIRFactory/resolve-tree-parser/graphql-tree/graphql-tree.ts b/packages/graphql/src/api-v6/queryIRFactory/resolve-tree-parser/graphql-tree/graphql-tree.ts index 91af080064..5196267d61 100644 --- a/packages/graphql/src/api-v6/queryIRFactory/resolve-tree-parser/graphql-tree/graphql-tree.ts +++ b/packages/graphql/src/api-v6/queryIRFactory/resolve-tree-parser/graphql-tree/graphql-tree.ts @@ -30,6 +30,13 @@ export type GraphQLTreeUpdateInput = Record; export type UpdateOperation = "set"; export type GraphQLTreeUpdateField = Record; +export interface GraphQLTreeDelete extends GraphQLTreeNode { + name: string; + args: { + where?: GraphQLWhereTopLevel; + }; +} + export interface GraphQLTreeCreate extends GraphQLTreeNode { name: string; args: { diff --git a/packages/graphql/src/api-v6/queryIRFactory/resolve-tree-parser/parse-resolve-info-tree.ts b/packages/graphql/src/api-v6/queryIRFactory/resolve-tree-parser/parse-resolve-info-tree.ts index 2c35ad324d..da73beb3f1 100644 --- a/packages/graphql/src/api-v6/queryIRFactory/resolve-tree-parser/parse-resolve-info-tree.ts +++ b/packages/graphql/src/api-v6/queryIRFactory/resolve-tree-parser/parse-resolve-info-tree.ts @@ -32,6 +32,7 @@ import type { GraphQLTreeConnection, GraphQLTreeConnectionTopLevel, GraphQLTreeCreate, + GraphQLTreeDelete, GraphQLTreeReadOperation, GraphQLTreeReadOperationTopLevel, GraphQLTreeUpdate, @@ -116,6 +117,34 @@ export function parseResolveInfoTreeUpdate({ }; } +export function parseResolveInfoTreeDelete({ + resolveTree, + entity, +}: { + resolveTree: ResolveTree; + entity: ConcreteEntity; +}): GraphQLTreeDelete { + const entityTypes = entity.typeNames; + const deleteResponse = findFieldByName(resolveTree, "DeleteResponse", entityTypes.queryField); + const deleteArgs = parseOperationArgsTopLevel(resolveTree.args); + if (!deleteResponse) { + return { + alias: resolveTree.alias, + name: resolveTree.name, + args: deleteArgs, + fields: {}, + }; + } + const fieldsResolveTree = deleteResponse.fieldsByTypeName[entityTypes.node] ?? {}; + const fields = getNodeFields(fieldsResolveTree, entity); + return { + alias: resolveTree.alias, + name: resolveTree.name, + args: deleteArgs, + fields, + }; +} + export function parseConnection(resolveTree: ResolveTree, entity: Relationship): GraphQLTreeConnection { const entityTypes = entity.typeNames; const edgesResolveTree = findFieldByName(resolveTree, entityTypes.connection, "edges"); diff --git a/packages/graphql/src/api-v6/resolvers/translate-delete-resolver.ts b/packages/graphql/src/api-v6/resolvers/translate-delete-resolver.ts index 3a3707626e..0df06f1250 100644 --- a/packages/graphql/src/api-v6/resolvers/translate-delete-resolver.ts +++ b/packages/graphql/src/api-v6/resolvers/translate-delete-resolver.ts @@ -22,8 +22,8 @@ import type { ConcreteEntity } from "../../schema-model/entity/ConcreteEntity"; import type { Neo4jGraphQLTranslationContext } from "../../types/neo4j-graphql-translation-context"; import { execute } from "../../utils"; import getNeo4jResolveTree from "../../utils/get-neo4j-resolve-tree"; -import { parseResolveInfoTreeCreate } from "../queryIRFactory/resolve-tree-parser/parse-resolve-info-tree"; -import { translateCreateOperation } from "../translators/translate-create-operation"; +import { parseResolveInfoTreeDelete } from "../queryIRFactory/resolve-tree-parser/parse-resolve-info-tree"; +import { translateDeleteResolver } from "../translators/translate-delete-operation"; export function generateDeleteResolver({ entity }: { entity: ConcreteEntity }) { return async function resolve( @@ -35,10 +35,10 @@ export function generateDeleteResolver({ entity }: { entity: ConcreteEntity }) { const resolveTree = getNeo4jResolveTree(info, { args }); context.resolveTree = resolveTree; // TODO: Implement delete resolver - const graphQLTreeCreate = parseResolveInfoTreeCreate({ resolveTree: context.resolveTree, entity }); - const { cypher, params } = translateCreateOperation({ + const graphQLTreeDelete = parseResolveInfoTreeDelete({ resolveTree: context.resolveTree, entity }); + const { cypher, params } = translateDeleteResolver({ context: context, - graphQLTreeCreate, + graphQLTreeDelete, entity, }); const executeResult = await execute({ diff --git a/packages/graphql/src/api-v6/translators/translate-delete-operation.ts b/packages/graphql/src/api-v6/translators/translate-delete-operation.ts new file mode 100644 index 0000000000..fff0cbe358 --- /dev/null +++ b/packages/graphql/src/api-v6/translators/translate-delete-operation.ts @@ -0,0 +1,44 @@ +/* + * 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 type Cypher from "@neo4j/cypher-builder"; +import Debug from "debug"; +import { DEBUG_TRANSLATE } from "../../constants"; +import type { ConcreteEntity } from "../../schema-model/entity/ConcreteEntity"; +import type { Neo4jGraphQLTranslationContext } from "../../types/neo4j-graphql-translation-context"; +import { DeleteOperationFactory } from "../queryIRFactory/DeleteOperationFactory"; +import type { GraphQLTreeDelete } from "../queryIRFactory/resolve-tree-parser/graphql-tree/graphql-tree"; + +const debug = Debug(DEBUG_TRANSLATE); + +export function translateDeleteResolver({ + context, + entity, + graphQLTreeDelete, +}: { + context: Neo4jGraphQLTranslationContext; + graphQLTreeDelete: GraphQLTreeDelete; + entity: ConcreteEntity; +}): Cypher.CypherResult { + const deleteFactory = new DeleteOperationFactory(context.schemaModel); + const deleteAST = deleteFactory.deleteAST({ graphQLTreeDelete, entity }); + debug(deleteAST.print()); + const results = deleteAST.build(context); + return results.build(); +} diff --git a/packages/graphql/tests/api-v6/integration/delete/delete.int.test.ts b/packages/graphql/tests/api-v6/integration/delete/delete.int.test.ts new file mode 100644 index 0000000000..9eb3ed478d --- /dev/null +++ b/packages/graphql/tests/api-v6/integration/delete/delete.int.test.ts @@ -0,0 +1,148 @@ +/* + * 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 type { UniqueType } from "../../../utils/graphql-types"; +import { TestHelper } from "../../../utils/tests-helper"; + +describe("Top-Level Delete", () => { + const testHelper = new TestHelper({ v6Api: true }); + + let Movie: UniqueType; + beforeEach(async () => { + Movie = testHelper.createUniqueType("Movie"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String! + released: Int + } + `; + await testHelper.initNeo4jGraphQL({ typeDefs }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("should delete one movie", async () => { + await testHelper.executeCypher(` + CREATE(n:${Movie} {title: "The Matrix"}) + CREATE(:${Movie} {title: "The Matrix 2"}) + `); + + const mutation = /* GraphQL */ ` + mutation { + ${Movie.operations.delete}( + where: { + node: { + title: { + equals: "The Matrix" + } + } + }) { + info { + nodesDeleted + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(mutation); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data).toEqual({ + [Movie.operations.delete]: { + info: { + nodesDeleted: 1, + }, + }, + }); + + const cypherMatch = await testHelper.executeCypher( + ` + MATCH (m:${Movie}) + RETURN {title: m.title} as m + `, + {} + ); + const records = cypherMatch.records.map((record) => record.toObject()); + expect(records).toEqual( + expect.toIncludeSameMembers([ + { + m: { + title: "The Matrix 2", + }, + }, + ]) + ); + }); + + test("should delete one movie and a relationship", async () => { + await testHelper.executeCypher(` + CREATE(n:${Movie} {title: "The Matrix"})-[:ACTED_IN]->(:Person {name: "Keanu Reeves"}) + CREATE(:${Movie} {title: "The Matrix 2"}) + `); + + const mutation = /* GraphQL */ ` + mutation { + ${Movie.operations.delete}( + where: { + node: { + title: { + equals: "The Matrix" + } + } + }) { + info { + nodesDeleted + relationshipsDeleted + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(mutation); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data).toEqual({ + [Movie.operations.delete]: { + info: { + nodesDeleted: 1, + relationshipsDeleted: 1, + }, + }, + }); + + const cypherMatch = await testHelper.executeCypher( + ` + MATCH (m:${Movie}) + RETURN {title: m.title} as m + `, + {} + ); + const records = cypherMatch.records.map((record) => record.toObject()); + expect(records).toEqual( + expect.toIncludeSameMembers([ + { + m: { + title: "The Matrix 2", + }, + }, + ]) + ); + }); +}); diff --git a/packages/graphql/tests/api-v6/tck/delete/delete.test.ts b/packages/graphql/tests/api-v6/tck/delete/delete.test.ts new file mode 100644 index 0000000000..91a0370c29 --- /dev/null +++ b/packages/graphql/tests/api-v6/tck/delete/delete.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { Neo4jGraphQL } from "../../../../src"; +import { formatCypher, formatParams, translateQuery } from "../../../tck/utils/tck-test-utils"; + +describe("Top-Level Delete", () => { + let typeDefs: string; + let neoSchema: Neo4jGraphQL; + + beforeAll(() => { + typeDefs = /* GraphQL */ ` + type Movie @node { + title: String! + released: Int + } + `; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + }); + + test("should delete one movie", async () => { + const mutation = /* GraphQL */ ` + mutation { + deleteMovies(where: { node: { title: { equals: "The Matrix" } } }) { + info { + nodesDeleted + relationshipsDeleted + } + } + } + `; + + const result = await translateQuery(neoSchema, mutation, { v6Api: true }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this0:Movie) + WHERE this0.title = $param0 + DETACH DELETE this0" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\" + }" + `); + }); +});