diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 1882e4dbea..96ed920c8f 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -81,7 +81,7 @@ "@graphql-tools/resolvers-composition": "^7.0.0", "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "10.9.1", - "@neo4j/cypher-builder": "2.8.1", + "@neo4j/cypher-builder": "2.10.0", "camelcase": "^6.3.0", "debug": "^4.3.4", "dot-prop": "^6.0.1", diff --git a/packages/graphql/src/classes/GraphElement.ts b/packages/graphql/src/classes/GraphElement.ts index 1eaf3a8ca0..bca0d0ea75 100644 --- a/packages/graphql/src/classes/GraphElement.ts +++ b/packages/graphql/src/classes/GraphElement.ts @@ -18,14 +18,14 @@ */ import type { - CypherField, - PrimitiveField, + BaseField, CustomEnumField, + CustomResolverField, CustomScalarField, - TemporalField, + CypherField, PointField, - CustomResolverField, - BaseField, + PrimitiveField, + TemporalField, } from "../types"; export interface GraphElementConstructor { @@ -40,6 +40,7 @@ export interface GraphElementConstructor { customResolverFields: CustomResolverField[]; } +/** @deprecated */ export abstract class GraphElement { public name: string; public description?: string; diff --git a/packages/graphql/src/classes/Node.ts b/packages/graphql/src/classes/Node.ts index 6a6b349f30..b83b5451f0 100644 --- a/packages/graphql/src/classes/Node.ts +++ b/packages/graphql/src/classes/Node.ts @@ -109,6 +109,7 @@ export type SubscriptionEvents = { delete_relationship: string; }; +/** @deprecated */ class Node extends GraphElement { public relationFields: RelationField[]; public connectionFields: ConnectionField[]; diff --git a/packages/graphql/src/classes/Relationship.ts b/packages/graphql/src/classes/Relationship.ts index 8a7afba549..0d082f0e21 100644 --- a/packages/graphql/src/classes/Relationship.ts +++ b/packages/graphql/src/classes/Relationship.ts @@ -45,7 +45,7 @@ interface RelationshipConstructor { pointFields?: PointField[]; customResolverFields?: CustomResolverField[]; } - +/** @deprecated */ class Relationship extends GraphElement { public properties?: string; public source: string; diff --git a/packages/graphql/src/schema/resolvers/mutation/update.ts b/packages/graphql/src/schema/resolvers/mutation/update.ts index 366b6dc0d0..081419cd00 100644 --- a/packages/graphql/src/schema/resolvers/mutation/update.ts +++ b/packages/graphql/src/schema/resolvers/mutation/update.ts @@ -29,6 +29,7 @@ import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphq import { execute } from "../../../utils"; import getNeo4jResolveTree from "../../../utils/get-neo4j-resolve-tree"; import type { Neo4jGraphQLComposedContext } from "../composition/wrap-query-and-mutation"; +import { translateUpdate2 } from "../../../translate/translate-update"; export function updateResolver({ node, @@ -42,7 +43,8 @@ export function updateResolver({ (context as Neo4jGraphQLTranslationContext).resolveTree = resolveTree; - const [cypher, params] = await translateUpdate({ context: context as Neo4jGraphQLTranslationContext, node }); + // const [cypher, params] = await translateUpdate({ context: context as Neo4jGraphQLTranslationContext, node }); + const { cypher, params } = await translateUpdate2({ context: context as Neo4jGraphQLTranslationContext, node }); const executeResult = await execute({ cypher, params, @@ -64,7 +66,8 @@ export function updateResolver({ if (nodeProjection) { const nodeKey = nodeProjection.alias ? nodeProjection.alias.value : nodeProjection.name.value; - resolveResult[nodeKey] = executeResult.records[0]?.data || []; + // resolveResult[nodeKey] = executeResult.records[0]?.data || []; + resolveResult[nodeKey] = executeResult.records.map((x) => x.this); } return resolveResult; diff --git a/packages/graphql/src/translate/queryAST/ast/QueryAST.ts b/packages/graphql/src/translate/queryAST/ast/QueryAST.ts index 12d22daa49..499fc6ed28 100644 --- a/packages/graphql/src/translate/queryAST/ast/QueryAST.ts +++ b/packages/graphql/src/translate/queryAST/ast/QueryAST.ts @@ -27,6 +27,7 @@ import { ConnectionReadOperation } from "./operations/ConnectionReadOperation"; import { DeleteOperation } from "./operations/DeleteOperation"; import { ReadOperation } from "./operations/ReadOperation"; import { TopLevelCreateMutationOperation } from "./operations/TopLevelCreateMutationOperation"; +import { TopLevelUpdateMutationOperation } from "./operations/TopLevelUpdateMutationOperation"; import { UnwindCreateOperation } from "./operations/UnwindCreateOperation"; import type { Operation, OperationTranspileResult } from "./operations/operations"; @@ -87,7 +88,8 @@ export class QueryAST { this.operation instanceof DeleteOperation || this.operation instanceof AggregationOperation || this.operation instanceof UnwindCreateOperation || - this.operation instanceof TopLevelCreateMutationOperation + this.operation instanceof TopLevelCreateMutationOperation || + this.operation instanceof TopLevelUpdateMutationOperation ) { return createNode(varName); } @@ -105,7 +107,7 @@ function getTreeLines(treeNode: QueryASTNode, depth: number = 0): string[] { const line = "────"; if (depth === 0) { - resultLines.push(`${nodeName}`); + resultLines.push(getTopLevelNodeName(nodeName)); } else if (depth === 1) { resultLines.push(`|${line} ${nodeName}`); } else { @@ -129,3 +131,19 @@ function getTreeLines(treeNode: QueryASTNode, depth: number = 0): string[] { return resultLines; } + +function getTopLevelNodeName(nodeName: string): string { + const currentMonth = new Date().getMonth(); + const isApril = currentMonth === 3; + const isOctober = currentMonth === 9; + const isDecember = currentMonth === 11; + if (isOctober) { + return `${nodeName} \u{1F383}\u{1F383}\u{1F383}`; + } else if (isDecember) { + return `${nodeName} \u{1F384}\u{1F384}\u{1F384}`; + } else if (isApril) { + return `${nodeName} \u{1F430}\u{1F430}\u{1F430}`; + } else { + return `${nodeName}`; + } +} diff --git a/packages/graphql/src/translate/queryAST/ast/input-fields/IdField.ts b/packages/graphql/src/translate/queryAST/ast/input-fields/IdField.ts index 2beae683f9..95e336f8bc 100644 --- a/packages/graphql/src/translate/queryAST/ast/input-fields/IdField.ts +++ b/packages/graphql/src/translate/queryAST/ast/input-fields/IdField.ts @@ -33,10 +33,6 @@ export class IdField extends InputField { return []; } - public print(): string { - return `${super.print()} <${this.name}>`; - } - public getSetParams(queryASTContext: QueryASTContext): Cypher.SetParam[] { const target = this.getTarget(queryASTContext); const setParam: Cypher.SetParam = [target.property(this.attribute.databaseName), Cypher.randomUUID()]; diff --git a/packages/graphql/src/translate/queryAST/ast/input-fields/InputField.ts b/packages/graphql/src/translate/queryAST/ast/input-fields/InputField.ts index fb92ce9914..21a6f77b5e 100644 --- a/packages/graphql/src/translate/queryAST/ast/input-fields/InputField.ts +++ b/packages/graphql/src/translate/queryAST/ast/input-fields/InputField.ts @@ -39,6 +39,9 @@ export abstract class InputField extends QueryASTNode { return []; } + public getPredicate(_queryASTContext: QueryASTContext): Cypher.Predicate | undefined { + return undefined; + } protected getTarget(queryASTContext: QueryASTContext): Cypher.Node | Cypher.Relationship { const target = this.attachedTo === "node" ? queryASTContext.target : queryASTContext.relationship; if (!target) { diff --git a/packages/graphql/src/translate/queryAST/ast/input-fields/ParamInputField.ts b/packages/graphql/src/translate/queryAST/ast/input-fields/ParamInputField.ts index 2dfea3ad96..9aa002912c 100644 --- a/packages/graphql/src/translate/queryAST/ast/input-fields/ParamInputField.ts +++ b/packages/graphql/src/translate/queryAST/ast/input-fields/ParamInputField.ts @@ -37,6 +37,8 @@ export class ParamInputField extends InputField { protected attribute: AttributeAdapter; protected inputValue: unknown; + private memoizedParam: Cypher.Param | undefined; + constructor({ attribute, attachedTo, @@ -59,23 +61,43 @@ export class ParamInputField extends InputField { queryASTContext: QueryASTContext, _inputVariable?: Cypher.Variable ): Cypher.SetParam[] { - const target = this.getTarget(queryASTContext); + // This check is needed for populatedBy callbacks + const param = this.getParam(); + if (param instanceof Cypher.Param) { + if (param.value === undefined) { + return []; + } + } + const leftExpr = this.getLeftExpression(queryASTContext); + const rightExpr = this.getRightExpression(queryASTContext); + + const setField: Cypher.SetParam = [leftExpr, rightExpr]; + return [setField]; + } + + protected getLeftExpression(queryASTContext: QueryASTContext): Cypher.Property { + return this.getTarget(queryASTContext).property(this.attribute.databaseName); + } - let rightVariable: Cypher.Expr; + protected getParam(): Cypher.Variable { if (this.inputValue instanceof Cypher.Variable) { - rightVariable = this.inputValue; + return this.inputValue; } else { - rightVariable = new Cypher.Param(this.inputValue); + if (!this.memoizedParam) { + this.memoizedParam = new Cypher.Param(this.inputValue); + } + return this.memoizedParam; } + } - const leftExpr = target.property(this.attribute.databaseName); - const rightExpr = this.coerceReference(rightVariable); - - const setField: Cypher.SetParam = [leftExpr, rightExpr]; - return [setField]; + protected getRightExpression( + _: QueryASTContext + ): Exclude { + const rightVariable = this.getParam(); + return this.coerceReference(rightVariable); } - private coerceReference( + protected coerceReference( variable: Cypher.Variable | Cypher.Property ): Exclude { if (this.attribute.typeHelper.isSpatial()) { @@ -104,7 +126,6 @@ export class ParamInputField extends InputField { const mapTime = Cypher.time(comprehensionVar); return new Cypher.ListComprehension(comprehensionVar, variable).map(mapTime); } - return variable; } } diff --git a/packages/graphql/src/translate/queryAST/ast/input-fields/operators/MathInputField.ts b/packages/graphql/src/translate/queryAST/ast/input-fields/operators/MathInputField.ts new file mode 100644 index 0000000000..624b52f17d --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/input-fields/operators/MathInputField.ts @@ -0,0 +1,102 @@ +/* + * 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 { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; +import { type QueryASTContext } from "../../QueryASTContext"; +import { ParamInputField } from "../ParamInputField"; + +type MathOperator = "increment" | "decrement" | "add" | "subtract" | "divide" | "multiply"; + +export class MathInputField extends ParamInputField { + private operation: MathOperator; + + constructor({ + attribute, + attachedTo, + inputValue, + operation, + }: { + attribute: AttributeAdapter; + attachedTo: "node" | "relationship"; + inputValue: unknown; + operation: MathOperator; + }) { + super({ attribute, attachedTo, inputValue }); + this.operation = operation; + if (operation == "divide" && inputValue === 0) { + throw new Error("Division by zero is not supported"); + } + } + + public getChildren() { + return []; + } + + public getSubqueries(queryASTContext: QueryASTContext): Cypher.Clause[] { + const prop = this.getLeftExpression(queryASTContext); + + const bitSize = this.attribute.typeHelper.isInt() ? 32 : 64; + const rightExpr = this.getRightExpression(queryASTContext); + // Avoid overflows, for 64 bit overflows, a long overflow is raised anyway by Neo4j + + const maxBit = Cypher.minus( + Cypher.pow(new Cypher.Literal(2), new Cypher.Literal(bitSize - 1)), + new Cypher.Literal(1) + ); + + return [ + Cypher.utils.concat( + Cypher.apoc.util.validate( + Cypher.isNull(prop), + "Cannot %s %s to Nan", + new Cypher.List([new Cypher.Literal(this.operation), this.getParam()]) + ), + Cypher.apoc.util.validate( + Cypher.gt(rightExpr, maxBit), + "Overflow: Value returned from operator %s is larger than %s bit", + new Cypher.List([new Cypher.Literal(this.operation), new Cypher.Literal(bitSize)]) + ) + ), + ]; + } + + protected getRightExpression( + queryASTContext: QueryASTContext + ): Exclude { + const rightVariable = super.getRightExpression(queryASTContext); + const targetProperty = this.getLeftExpression(queryASTContext); + + switch (this.operation) { + case "add": + case "increment": + return Cypher.plus(targetProperty, rightVariable); + case "decrement": + case "subtract": + return Cypher.minus(targetProperty, rightVariable); + case "divide": + return Cypher.divide(targetProperty, rightVariable); + case "multiply": + return Cypher.multiply(targetProperty, rightVariable); + + default: + throw new Error(`Unknown operation ${this.operation}`); + } + } +} diff --git a/packages/graphql/src/translate/queryAST/ast/input-fields/operators/PopInputField.ts b/packages/graphql/src/translate/queryAST/ast/input-fields/operators/PopInputField.ts new file mode 100644 index 0000000000..a07a6a2fb5 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/input-fields/operators/PopInputField.ts @@ -0,0 +1,54 @@ +/* + * 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 { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; +import { type QueryASTContext } from "../../QueryASTContext"; +import { ParamInputField } from "../ParamInputField"; + +export class PopInputField extends ParamInputField { + constructor({ + attribute, + attachedTo, + inputValue, + }: { + attribute: AttributeAdapter; + attachedTo: "node" | "relationship"; + inputValue: unknown; + }) { + super({ attribute, attachedTo, inputValue }); + } + + public getChildren() { + return []; + } + + protected getRightExpression( + queryASTContext: QueryASTContext + ): Exclude { + const rightVariable = super.getParam(); + const rightExpr = Cypher.minus(rightVariable); + const leftExpr = this.getLeftExpression(queryASTContext); + return new Cypher.Raw((context) => { + const leftExprCompiled = context.compile(leftExpr); + const poppedValueCompiled = context.compile(rightExpr); + return `${leftExprCompiled}[0..${poppedValueCompiled}]`; + }); + } +} diff --git a/packages/graphql/src/translate/queryAST/ast/input-fields/operators/PushInputField.ts b/packages/graphql/src/translate/queryAST/ast/input-fields/operators/PushInputField.ts new file mode 100644 index 0000000000..7fb229fca5 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/input-fields/operators/PushInputField.ts @@ -0,0 +1,48 @@ +/* + * 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 { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; +import { type QueryASTContext } from "../../QueryASTContext"; +import { ParamInputField } from "../ParamInputField"; + +export class PushInputField extends ParamInputField { + constructor({ + attribute, + attachedTo, + inputValue, + }: { + attribute: AttributeAdapter; + attachedTo: "node" | "relationship"; + inputValue: unknown; + }) { + super({ attribute, attachedTo, inputValue }); + } + + public getChildren() { + return []; + } + + protected getRightExpression( + queryASTContext: QueryASTContext + ): Exclude { + const pushedValue = super.getRightExpression(queryASTContext); + return Cypher.plus(this.getLeftExpression(queryASTContext), pushedValue); + } +} diff --git a/packages/graphql/src/translate/queryAST/ast/operations/ConnectOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/ConnectOperation.ts index 611e3092f9..20b71653b5 100644 --- a/packages/graphql/src/translate/queryAST/ast/operations/ConnectOperation.ts +++ b/packages/graphql/src/translate/queryAST/ast/operations/ConnectOperation.ts @@ -29,7 +29,6 @@ import type { Filter } from "../filters/Filter"; import type { AuthorizationFilters } from "../filters/authorization-filters/AuthorizationFilters"; import type { InputField } from "../input-fields/InputField"; import type { SelectionPattern } from "../selection/SelectionPattern/SelectionPattern"; -import type { ReadOperation } from "./ReadOperation"; import { MutationOperation, type OperationTranspileResult } from "./operations"; export class ConnectOperation extends MutationOperation { @@ -39,9 +38,6 @@ export class ConnectOperation extends MutationOperation { private selectionPattern: SelectionPattern; protected readonly authFilters: AuthorizationFilters[] = []; - // The response fields in the mutation, currently only READ operations are supported in the MutationResponse - public projectionOperations: ReadOperation[] = []; - public readonly inputFields: Map = new Map(); private filters: Filter[] = []; @@ -68,7 +64,6 @@ export class ConnectOperation extends MutationOperation { ...this.filters, ...this.authFilters, ...this.inputFields.values(), - ...this.projectionOperations, ]); } @@ -98,10 +93,6 @@ export class ConnectOperation extends MutationOperation { this.filters.push(...filters); } - public addProjectionOperations(operations: ReadOperation[]) { - this.projectionOperations.push(...operations); - } - public getAuthorizationSubqueries(_context: QueryASTContext): Cypher.Clause[] { const nestedContext = this.nestedContext; @@ -171,8 +162,7 @@ export class ConnectOperation extends MutationOperation { ...this.getAuthorizationClauses(nestedContext), // THESE ARE "BEFORE" AUTH ...mutationSubqueries, connectClause, - ...this.getAuthorizationClausesAfter(nestedContext), // THESE ARE "AFTER" AUTH - ...this.getProjectionClause(nestedContext) + ...this.getAuthorizationClausesAfter(nestedContext) // THESE ARE "AFTER" AUTH ); const callClause = new Cypher.Call(clauses, [context.target]); @@ -180,12 +170,6 @@ export class ConnectOperation extends MutationOperation { return { projectionExpr: context.returnVariable, clauses: [callClause] }; } - private getProjectionClause(context: QueryASTContext): Cypher.Clause[] { - return this.projectionOperations.map((operationField) => { - return Cypher.utils.concat(...operationField.transpile(context).clauses); - }); - } - private getAuthorizationClauses(context: QueryASTContext): Cypher.Clause[] { const { selections, subqueries, predicates, validations } = this.transpileAuthClauses(context); const predicate = Cypher.and(...predicates); diff --git a/packages/graphql/src/translate/queryAST/ast/operations/DisconnectOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/DisconnectOperation.ts new file mode 100644 index 0000000000..ff90813db9 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/operations/DisconnectOperation.ts @@ -0,0 +1,218 @@ +/* + * 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 { RelationshipAdapter } from "../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import { filterTruthy } from "../../../../utils/utils"; +import { getEntityLabels } from "../../utils/create-node-from-entity"; +import { wrapSubqueriesInCypherCalls } from "../../utils/wrap-subquery-in-calls"; +import type { QueryASTContext } from "../QueryASTContext"; +import type { QueryASTNode } from "../QueryASTNode"; +import type { Filter } from "../filters/Filter"; +import type { AuthorizationFilters } from "../filters/authorization-filters/AuthorizationFilters"; +import type { InputField } from "../input-fields/InputField"; +import type { SelectionPattern } from "../selection/SelectionPattern/SelectionPattern"; +import type { ReadOperation } from "./ReadOperation"; +import { MutationOperation, type OperationTranspileResult } from "./operations"; + +export class DisconnectOperation extends MutationOperation { + public readonly target: ConcreteEntityAdapter; + public readonly relationship: RelationshipAdapter; + + private selectionPattern: SelectionPattern; + protected readonly authFilters: AuthorizationFilters[] = []; + + public readonly inputFields: Map = new Map(); + private filters: Filter[] = []; + + private nestedContext: QueryASTContext | undefined; + + constructor({ + target, + relationship, + selectionPattern, + }: { + target: ConcreteEntityAdapter; + selectionPattern: SelectionPattern; + relationship: RelationshipAdapter; + }) { + super(); + this.target = target; + this.relationship = relationship; + this.selectionPattern = selectionPattern; + } + + public getChildren(): QueryASTNode[] { + return filterTruthy([ + this.selectionPattern, + ...this.filters, + ...this.authFilters, + ...this.inputFields.values(), + ]); + } + + public print(): string { + return `${super.print()} <${this.target.name}>`; + } + + public addAuthFilters(...filter: AuthorizationFilters[]) { + this.authFilters.push(...filter); + } + + /** + * Get and set field methods are utilities to remove duplicate fields between separate inputs + * TODO: This logic should be handled in the factory. + */ + public getField(key: string, attachedTo: "node" | "relationship") { + return this.inputFields.get(`${attachedTo}_${key}`); + } + + public addField(field: InputField, attachedTo: "node" | "relationship") { + if (!this.inputFields.has(field.name)) { + this.inputFields.set(`${attachedTo}_${field.name}`, field); + } + } + + public addFilters(...filters: Filter[]): void { + this.filters.push(...filters); + } + + public getAuthorizationSubqueries(_context: QueryASTContext): Cypher.Clause[] { + const nestedContext = this.nestedContext; + + if (!nestedContext) { + throw new Error( + "Error parsing query, nested context not available, need to call transpile first. Please contact support" + ); + } + + return [...this.inputFields.values()].flatMap((inputField) => { + return inputField.getAuthorizationSubqueries(nestedContext); + }); + } + + public transpile(context: QueryASTContext): OperationTranspileResult { + if (!context.hasTarget()) { + throw new Error("No parent node found!"); + } + + const { nestedContext, pattern: matchPattern } = this.selectionPattern.apply(context); + this.nestedContext = nestedContext; + + const allFilters = [...this.authFilters, ...this.filters]; + + const filterSubqueries = wrapSubqueriesInCypherCalls(nestedContext, allFilters, [nestedContext.target]); + + let matchClause: Cypher.Clause; + if (filterSubqueries.length > 0) { + const predicate = Cypher.and(...allFilters.map((f) => f.getPredicate(nestedContext))); + matchClause = Cypher.utils.concat( + new Cypher.Match(matchPattern), + ...filterSubqueries, + new Cypher.With("*").where(predicate) + ); + } else { + const predicate = Cypher.and(...allFilters.map((f) => f.getPredicate(nestedContext))); + matchClause = new Cypher.Match(matchPattern).where(predicate); + } + + const relVar = new Cypher.Relationship(); + + const disconnectContext = context.push({ target: nestedContext.target, relationship: relVar }); + + const mutationSubqueries = Array.from(this.inputFields.values()) + .flatMap((input) => { + return input.getSubqueries(disconnectContext); + }) + .map((sq) => new Cypher.Call(sq, [disconnectContext.target])); + + const deleteClause = new Cypher.With(nestedContext.relationship!).delete(nestedContext.relationship!); + + const clauses = Cypher.utils.concat( + matchClause, + ...this.getAuthorizationClauses(nestedContext), // THESE ARE "BEFORE" AUTH + ...mutationSubqueries, + deleteClause, + ...this.getAuthorizationClausesAfter(nestedContext) // THESE ARE "AFTER" AUTH + ); + + return { projectionExpr: context.returnVariable, clauses: [clauses] }; + } + + private getAuthorizationClauses(context: QueryASTContext): Cypher.Clause[] { + const { selections, subqueries, predicates, validations } = this.transpileAuthClauses(context); + const predicate = Cypher.and(...predicates); + const lastSelection = selections[selections.length - 1]; + + if (!predicates.length && !validations.length) { + return []; + } else { + if (lastSelection) { + lastSelection.where(predicate); + return [...subqueries, new Cypher.With("*"), ...selections, ...validations]; + } + return [...subqueries, new Cypher.With("*").where(predicate), ...selections, ...validations]; + } + } + + private getAuthorizationClausesAfter(context: QueryASTContext): Cypher.Clause[] { + const validationsAfter: Cypher.VoidProcedure[] = []; + for (const authFilter of this.authFilters) { + const validationAfter = authFilter.getValidation(context, "AFTER"); + if (validationAfter) { + validationsAfter.push(validationAfter); + } + } + + if (validationsAfter.length > 0) { + return [new Cypher.With("*"), ...validationsAfter]; + } + return []; + } + + private transpileAuthClauses(context: QueryASTContext): { + selections: (Cypher.With | Cypher.Match)[]; + subqueries: Cypher.Clause[]; + predicates: Cypher.Predicate[]; + validations: Cypher.VoidProcedure[]; + } { + const selections: (Cypher.With | Cypher.Match)[] = []; + const subqueries: Cypher.Clause[] = []; + const predicates: Cypher.Predicate[] = []; + const validations: Cypher.VoidProcedure[] = []; + for (const authFilter of this.authFilters) { + const extraSelections = authFilter.getSelection(context); + const authSubqueries = authFilter.getSubqueries(context); + const validation = authFilter.getValidation(context, "BEFORE"); + + if (extraSelections) { + selections.push(...extraSelections); + } + if (authSubqueries) { + subqueries.push(...authSubqueries); + } + + if (validation) { + validations.push(validation); + } + } + return { selections, subqueries, predicates, validations }; + } +} diff --git a/packages/graphql/src/translate/queryAST/ast/operations/TopLevelUpdateMutationOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/TopLevelUpdateMutationOperation.ts new file mode 100644 index 0000000000..f6ef85e6b0 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/operations/TopLevelUpdateMutationOperation.ts @@ -0,0 +1,103 @@ +/* + * 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 { filterTruthy } from "../../../../utils/utils"; +import type { QueryASTContext } from "../QueryASTContext"; +import type { QueryASTNode } from "../QueryASTNode"; +import type { OperationField } from "../fields/OperationField"; +import { Operation, type OperationTranspileResult } from "./operations"; +import type { UpdateOperation } from "./UpdateOperation"; + +/** Wrapper over TopLevelUpdateMutationOperation for top level update, that support multiple update operations + * This extends Operation because we don't need the mutationOperation API for top level + */ +export class TopLevelUpdateMutationOperation extends Operation { + // The response fields in the mutation, currently only READ operations are supported in the MutationResponse + private readonly projectionOperations: OperationField[]; + + private readonly updateOperations: UpdateOperation[] = []; + + constructor({ + updateOperations, + projectionOperations, + }: { + updateOperations: UpdateOperation[]; + projectionOperations: OperationField[]; + }) { + super(); + this.updateOperations = updateOperations; + this.projectionOperations = projectionOperations; + } + + public getChildren(): QueryASTNode[] { + return filterTruthy([...this.updateOperations, ...this.projectionOperations]); + } + + public transpile(context: QueryASTContext): OperationTranspileResult { + context.env.topLevelOperationName = "UPDATE"; + if (!context.hasTarget()) { + throw new Error("No parent node found!"); + } + const subqueries = this.updateOperations.map((field) => { + const { clauses, projectionExpr } = field.transpile(context); + + return Cypher.utils.concat( + ...clauses, + ...field.getAuthorizationSubqueries(context) + // new Cypher.Return([projectionExpr, context.returnVariable]) + ); + }); + + // const unionStatement = new Cypher.Call(new Cypher.Union(...subqueries)); + const projection: Cypher.Clause = this.getProjectionClause(context); + return { + projectionExpr: context.returnVariable, + clauses: [...subqueries, projection], + }; + } + + private getProjectionClause(context: QueryASTContext): Cypher.Clause { + const projectionOperation = this.projectionOperations[0]; // TODO: multiple projection operations not supported + + if (!projectionOperation) { + return new Cypher.Finish(); + } + + // const subqueries = projectionOperation.getSubqueries(context); + // .map((sq) => new Cypher.Call(sq, [context.target])); + const result = projectionOperation.operation.transpile(context); + + // const projectionField = Object.values(projectionOperation.getProjectionField())[0]; + // if (!projectionField) { + // throw new Error("Fatal Error: Invalid projectionField, please contact support"); + // } + + // const returnClause = new Cypher.Return([projectionField, "data"]); + + // let extraWith: Cypher.With | undefined; + // if (subqueries.length > 0) { + // extraWith = new Cypher.With(context.target); + // } + // return Cypher.utils.concat(extraWith, ...subqueries); + + const extraWith = new Cypher.With(context.target); + return Cypher.utils.concat(extraWith, ...result.clauses); + } +} diff --git a/packages/graphql/src/translate/queryAST/ast/operations/UpdateOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/UpdateOperation.ts index 7e5cffb31b..e9aaab9a7e 100644 --- a/packages/graphql/src/translate/queryAST/ast/operations/UpdateOperation.ts +++ b/packages/graphql/src/translate/queryAST/ast/operations/UpdateOperation.ts @@ -20,43 +20,223 @@ import Cypher from "@neo4j/cypher-builder"; import type { ConcreteEntityAdapter } from "../../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import { filterTruthy } from "../../../../utils/utils"; +import { checkEntityAuthentication } from "../../../authorization/check-authentication"; import type { QueryASTContext } from "../QueryASTContext"; import type { QueryASTNode } from "../QueryASTNode"; +import type { AuthorizationFilters } from "../filters/authorization-filters/AuthorizationFilters"; +import type { InputField } from "../input-fields/InputField"; +import type { SelectionPattern } from "../selection/SelectionPattern/SelectionPattern"; import type { ReadOperation } from "./ReadOperation"; import { Operation, type OperationTranspileResult } from "./operations"; +import type { RelationshipAdapter } from "../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import { wrapSubqueriesInCypherCalls } from "../../utils/wrap-subquery-in-calls"; +import type { Filter } from "../filters/Filter"; +import { ParamInputField } from "../input-fields/ParamInputField"; + /** * This is currently just a dummy tree node, * The whole mutation part is still implemented in the old way, the current scope of this node is just to contains the nested fields. **/ export class UpdateOperation extends Operation { public readonly target: ConcreteEntityAdapter; + public readonly relationship: RelationshipAdapter | undefined; + + protected readonly authFilters: AuthorizationFilters[] = []; + protected filters: Filter[] = []; + + private readonly selectionPattern: SelectionPattern; + private readonly inputFields: InputField[] = []; // The response fields in the mutation, currently only READ operations are supported in the MutationResponse public projectionOperations: ReadOperation[] = []; + private nestedContext: QueryASTContext | undefined; - constructor({ target }: { target: ConcreteEntityAdapter }) { + constructor({ + target, + relationship, + selectionPattern, + }: { + target: ConcreteEntityAdapter; + relationship?: RelationshipAdapter; + selectionPattern: SelectionPattern; + }) { super(); this.target = target; + this.relationship = relationship; + this.selectionPattern = selectionPattern; + } + /** Prints the name of the Node */ + public print(): string { + return `${super.print()} <${this.target.name}>`; } public getChildren(): QueryASTNode[] { - return filterTruthy(this.projectionOperations); + return filterTruthy([ + this.selectionPattern, + ...this.inputFields, + ...this.filters, + ...this.authFilters, + ...this.projectionOperations, + ]); } public addProjectionOperations(operations: ReadOperation[]) { this.projectionOperations.push(...operations); } + public addAuthFilters(...filter: AuthorizationFilters[]) { + this.authFilters.push(...filter); + } + + public addField(field: InputField) { + this.inputFields.push(field); + } + + public addFilters(...filters: Filter[]) { + this.filters.push(...filters); + } public transpile(context: QueryASTContext): OperationTranspileResult { if (!context.target) throw new Error("No parent node found!"); context.env.topLevelOperationName = "UPDATE"; - const clauses = this.getProjectionClause(context); - return { projectionExpr: context.returnVariable, clauses }; - } - private getProjectionClause(context: QueryASTContext): Cypher.Clause[] { - return this.projectionOperations.map((operationField) => { - return Cypher.utils.concat(...operationField.transpile(context).clauses); + const { nestedContext, pattern } = this.selectionPattern.apply(context); + this.nestedContext = nestedContext; + + // We need to call the filter subqueries before predicate to handle aggregate filters + const filterSubqueries = wrapSubqueriesInCypherCalls(nestedContext, this.filters, [nestedContext.target]); + + const predicate = this.getPredicate(nestedContext); + // const matchClause = new Cypher.Match(pattern).where(predicate); + + const matchClause = new Cypher.Match(pattern); + let filtersWith: Cypher.With | undefined; + + const hasFilterSubqueries = filterSubqueries.length > 0; + if (hasFilterSubqueries) { + filtersWith = new Cypher.With("*").where(predicate); + } else { + matchClause.where(predicate); + } + + checkEntityAuthentication({ + context: context.neo4jGraphQLContext, + entity: this.target.entity, + targetOperations: ["UPDATE"], }); + this.inputFields.forEach((field) => { + if (field.attachedTo === "node" && field instanceof ParamInputField) { + checkEntityAuthentication({ + context: context.neo4jGraphQLContext, + entity: this.target.entity, + targetOperations: ["UPDATE"], + field: field.name, + }); + } + }); + + const setParams = Array.from(this.inputFields.values()).flatMap((input) => { + return input.getSetParams(nestedContext); + }); + + const mutationSubqueries = Array.from(this.inputFields.values()).flatMap((input) => { + return input.getSubqueries(nestedContext); + }); + + // This is a small optimisation, to avoid subqueries with no changes + // Top level should still be generated for projection + if (this.relationship) { + if (setParams.length === 0 && mutationSubqueries.length === 0) { + return { projectionExpr: nestedContext.target, clauses: [] }; + } + } + + if (filtersWith) { + filtersWith.set(...setParams); + } else { + matchClause.set(...setParams); + } + + const clauses = Cypher.utils.concat( + matchClause, + ...filterSubqueries, + filtersWith, + ...mutationSubqueries.map((sq) => Cypher.utils.concat(new Cypher.With("*"), new Cypher.Call(sq, "*"))) + ); + + return { projectionExpr: nestedContext.target, clauses: [clauses] }; + } + + /** Post subqueries */ + public getAuthorizationSubqueries(_context: QueryASTContext): Cypher.Clause[] { + const nestedContext = this.nestedContext; + + if (!nestedContext) { + throw new Error( + "Error parsing query, nested context not available, need to call transpile first. Please contact support" + ); + } + + return [ + ...this.getAuthorizationClauses(nestedContext), + ...this.inputFields.flatMap((inputField) => { + return inputField.getAuthorizationSubqueries(nestedContext); + }), + ]; + } + + private getAuthorizationClauses(context: QueryASTContext): Cypher.Clause[] { + const { selections, subqueries, predicates, validations } = this.transpileAuthClauses(context); + const predicate = Cypher.and(...predicates); + const lastSelection = selections[selections.length - 1]; + + if (!predicates.length && !validations.length) { + return []; + } else { + if (lastSelection) { + lastSelection.where(predicate); + return [...subqueries, new Cypher.With("*"), ...selections, ...validations]; + } + return [...subqueries, new Cypher.With("*").where(predicate), ...selections, ...validations]; + } + } + + private transpileAuthClauses(context: QueryASTContext): { + selections: (Cypher.With | Cypher.Match)[]; + subqueries: Cypher.Clause[]; + predicates: Cypher.Predicate[]; + validations: Cypher.VoidProcedure[]; + } { + const selections: (Cypher.With | Cypher.Match)[] = []; + const subqueries: Cypher.Clause[] = []; + const predicates: Cypher.Predicate[] = []; + const validations: Cypher.VoidProcedure[] = []; + for (const authFilter of this.authFilters) { + const extraSelections = authFilter.getSelection(context); + const authSubqueries = authFilter.getSubqueries(context); + const authPredicate = authFilter.getPredicate(context); + const validation = authFilter.getValidation(context, "AFTER"); // CREATE only has AFTER auth + if (extraSelections) { + selections.push(...extraSelections); + } + if (authSubqueries) { + subqueries.push(...authSubqueries); + } + if (authPredicate) { + predicates.push(authPredicate); + } + if (validation) { + validations.push(validation); + } + } + return { selections, subqueries, predicates, validations }; + } + + private getPredicate(queryASTContext: QueryASTContext): Cypher.Predicate | undefined { + const authBeforePredicates = this.getAuthFilterPredicate(queryASTContext); + return Cypher.and(...this.filters.map((f) => f.getPredicate(queryASTContext)), ...authBeforePredicates); + } + + private getAuthFilterPredicate(context: QueryASTContext): Cypher.Predicate[] { + return filterTruthy(this.authFilters.map((f) => f.getPredicate(context))); } } diff --git a/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeDisconnectOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeDisconnectOperation.ts new file mode 100644 index 0000000000..f1889cb46c --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeDisconnectOperation.ts @@ -0,0 +1,69 @@ +/* + * 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 { Clause } from "@neo4j/cypher-builder"; +import type { InterfaceEntityAdapter } from "../../../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; +import type { UnionEntityAdapter } from "../../../../../schema-model/entity/model-adapters/UnionEntityAdapter"; +import { filterTruthy } from "../../../../../utils/utils"; +import type { QueryASTContext } from "../../QueryASTContext"; +import type { QueryASTNode } from "../../QueryASTNode"; +import type { OperationTranspileResult } from "../operations"; +import { MutationOperation } from "../operations"; +import type { CompositeDisconnectPartial } from "./CompositeDisconnectPartial"; + +export class CompositeDisconnectOperation extends MutationOperation { + private partials: CompositeDisconnectPartial[] = []; + private target: InterfaceEntityAdapter | UnionEntityAdapter; + + constructor({ + partials, + target, + }: { + partials: CompositeDisconnectPartial[]; + target: InterfaceEntityAdapter | UnionEntityAdapter; + }) { + super(); + this.partials = partials; + this.target = target; + } + + public print(): string { + return `${super.print()} <${this.target.name}>`; + } + + public getChildren(): QueryASTNode[] { + return filterTruthy([...this.partials]); + } + + transpile(context: QueryASTContext): OperationTranspileResult { + const clauses = this.partials.flatMap((partial) => { + return partial.transpile(context).clauses; + }); + return { + projectionExpr: context.returnVariable, + clauses, + }; + } + + getAuthorizationSubqueries(context: QueryASTContext): Clause[] { + return this.partials.flatMap((partial) => { + return partial.getAuthorizationSubqueries(context); + }); + } +} diff --git a/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeDisconnectPartial.ts b/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeDisconnectPartial.ts new file mode 100644 index 0000000000..7277286d9a --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeDisconnectPartial.ts @@ -0,0 +1,22 @@ +/* + * 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 { DisconnectOperation } from "../DisconnectOperation"; + +export class CompositeDisconnectPartial extends DisconnectOperation {} diff --git a/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/NodeSelectionPattern.ts b/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/NodeSelectionPattern.ts index 54d924ca7c..e881e527f6 100644 --- a/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/NodeSelectionPattern.ts +++ b/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/NodeSelectionPattern.ts @@ -44,6 +44,10 @@ export class NodeSelectionPattern extends SelectionPattern { this.useContextTarget = useContextTarget; } + public print(): string { + return `${super.print()} <${this.target.name}>`; + } + public apply(context: QueryASTContext): { nestedContext: QueryASTContext; pattern: Cypher.Pattern; diff --git a/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/RelationshipSelectionPattern.ts b/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/RelationshipSelectionPattern.ts index 1e800c7d8e..9d3918ac3d 100644 --- a/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/RelationshipSelectionPattern.ts +++ b/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/RelationshipSelectionPattern.ts @@ -18,6 +18,7 @@ */ import Cypher from "@neo4j/cypher-builder"; +import type { EntityAdapter } from "../../../../../schema-model/entity/EntityAdapter"; import type { ConcreteEntityAdapter } from "../../../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import type { RelationshipAdapter } from "../../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; import { hasTarget } from "../../../utils/context-has-target"; @@ -47,6 +48,10 @@ export class RelationshipSelectionPattern extends SelectionPattern { this.targetOverride = targetOverride; } + public print(): string { + return `${super.print()} <${this.relationship.name} -> ${this.target.name}>`; + } + public apply(context: QueryASTContext): { nestedContext: QueryASTContext; pattern: Cypher.Pattern; @@ -54,7 +59,7 @@ export class RelationshipSelectionPattern extends SelectionPattern { if (!hasTarget(context)) throw new Error("No parent node over a nested relationship match!"); const relVar = new Cypher.Relationship(); - const relationshipTarget = this.targetOverride ?? this.relationship.target; + const relationshipTarget = this.target; const targetNode = createNode(this.alias); const labels = getEntityLabels(relationshipTarget, context.neo4jGraphQLContext); const relDirection = this.relationship.getCypherDirection(); @@ -71,4 +76,8 @@ export class RelationshipSelectionPattern extends SelectionPattern { pattern: pattern, }; } + + private get target(): EntityAdapter { + return this.targetOverride ?? this.relationship.target; + } } diff --git a/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/SelectionPattern.ts b/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/SelectionPattern.ts index 380cf0d350..e1406e20f7 100644 --- a/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/SelectionPattern.ts +++ b/packages/graphql/src/translate/queryAST/ast/selection/SelectionPattern/SelectionPattern.ts @@ -26,8 +26,10 @@ export abstract class SelectionPattern extends QueryASTNode { return []; } + // TODO: Improve naming /** Apply selection over the given context, returns the updated context and the selection clause - * TODO: Improve naming */ + * This ensures the new context matches the generated Cypher (i.e. the target is the nested relationship) + */ public abstract apply(context: QueryASTContext): { nestedContext: QueryASTContext; pattern: Cypher.Pattern; diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index 622ed66290..e77b80bd09 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -152,7 +152,7 @@ export class FilterFactory { } if (rel && key === "edge") { - return this.createEdgeFilters(rel, value); + return this.createEdgeFilters(rel, value ?? {}); } if (key === "node") { diff --git a/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts b/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts index 436f87cca2..1d8cb90d58 100644 --- a/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts @@ -32,9 +32,11 @@ import { filterTruthy, isRecord } from "../../../utils/utils"; import type { Filter } from "../ast/filters/Filter"; import type { AggregationOperation } from "../ast/operations/AggregationOperation"; import type { ConnectionReadOperation } from "../ast/operations/ConnectionReadOperation"; +import { type CreateOperation } from "../ast/operations/CreateOperation"; import type { CypherAttributeOperation } from "../ast/operations/CypherAttributeOperation"; import type { CypherEntityOperation } from "../ast/operations/CypherEntityOperation"; import type { ReadOperation } from "../ast/operations/ReadOperation"; +import { type UpdateOperation } from "../ast/operations/UpdateOperation"; import type { CompositeAggregationOperation } from "../ast/operations/composite/CompositeAggregationOperation"; import type { CompositeConnectionReadOperation } from "../ast/operations/composite/CompositeConnectionReadOperation"; import type { CompositeCypherOperation } from "../ast/operations/composite/CompositeCypherOperation"; @@ -55,6 +57,7 @@ import { ConnectionFactory } from "./Operations/ConnectionFactory"; import { CreateFactory } from "./Operations/CreateFactory"; import { CustomCypherFactory } from "./Operations/CustomCypherFactory"; import { DeleteFactory } from "./Operations/DeleteFactory"; +import { DisconnectFactory } from "./Operations/DisconnectFactory"; import { FulltextFactory } from "./Operations/FulltextFactory"; import { ReadFactory } from "./Operations/ReadFactory"; import { UpdateFactory } from "./Operations/UpdateFactory"; @@ -72,6 +75,7 @@ export class OperationsFactory { private authorizationFactory: AuthorizationFactory; private createFactory: CreateFactory; private connectFactory: ConnectFactory; + private disconnectFactory: DisconnectFactory; private updateFactory: UpdateFactory; private deleteFactory: DeleteFactory; private fulltextFactory: FulltextFactory; @@ -88,6 +92,7 @@ export class OperationsFactory { this.authorizationFactory = queryASTFactory.authorizationFactory; this.createFactory = new CreateFactory(queryASTFactory); this.connectFactory = new ConnectFactory(queryASTFactory); + this.disconnectFactory = new DisconnectFactory(queryASTFactory); this.updateFactory = new UpdateFactory(queryASTFactory); this.deleteFactory = new DeleteFactory(queryASTFactory); this.fulltextFactory = new FulltextFactory(queryASTFactory); @@ -203,7 +208,7 @@ export class OperationsFactory { } case "UPDATE": { assertIsConcreteEntity(entity); - return this.updateFactory.createUpdateOperation(entity, resolveTree, context); + return this.updateFactory.createUpdateOperation(entity, resolveTree, context, callbackBucket, varName); } case "DELETE": { assertIsConcreteEntity(entity); @@ -244,6 +249,68 @@ export class OperationsFactory { ); } } + public createDisconnectOperation( + entity: ConcreteEntityAdapter | InterfaceEntityAdapter | UnionEntityAdapter, + relationship: RelationshipAdapter, + input: Record[], + context: Neo4jGraphQLTranslationContext, + callbackBucket: CallbackBucket + ) { + if (isConcreteEntity(entity)) { + return this.disconnectFactory.createDisconnectOperation( + entity, + relationship, + input, + context, + callbackBucket + ); + } else { + return this.disconnectFactory.createCompositeDisconnectOperation( + entity, + relationship, + input, + context, + callbackBucket + ); + } + } + + public createNestedCreateOperation({ + relationship, + targetEntity, + input, + callbackBucket, + context, + operation, + key, + }: { + input: Record | Record[]; + targetEntity: ConcreteEntityAdapter | InterfaceEntityAdapter; + relationship: RelationshipAdapter; + callbackBucket: CallbackBucket; + context: Neo4jGraphQLTranslationContext; + operation: CreateOperation | UpdateOperation; + key: string; + }) { + return this.createFactory.createNestedCreateOperation({ + relationship, + targetEntity, + input, + callbackBucket, + context, + operation, + key, + }); + } + + public createNestedDeleteOperationsForUpdate( + deleteArg: Record, + relationship: RelationshipAdapter, + context: Neo4jGraphQLTranslationContext, + target: ConcreteEntityAdapter | InterfaceEntityAdapter + ) { + return this.deleteFactory.createNestedDeleteOperationsForUpdate(deleteArg, relationship, context, target); + } public createReadOperation(arg: { entityOrRel: EntityAdapter | RelationshipAdapter; diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/CreateFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/CreateFactory.ts index 301ce52511..3d4df4b162 100644 --- a/packages/graphql/src/translate/queryAST/factory/Operations/CreateFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/Operations/CreateFactory.ts @@ -33,6 +33,7 @@ import { CreateOperation } from "../../ast/operations/CreateOperation"; import type { ReadOperation } from "../../ast/operations/ReadOperation"; import { TopLevelCreateMutationOperation } from "../../ast/operations/TopLevelCreateMutationOperation"; import { UnwindCreateOperation } from "../../ast/operations/UnwindCreateOperation"; +import type { UpdateOperation } from "../../ast/operations/UpdateOperation"; import { NodeSelectionPattern } from "../../ast/selection/SelectionPattern/NodeSelectionPattern"; import { RelationshipSelectionPattern } from "../../ast/selection/SelectionPattern/RelationshipSelectionPattern"; import type { CallbackBucket } from "../../utils/callback-bucket"; @@ -392,7 +393,7 @@ export class CreateFactory { }); } - private createNestedCreateOperation({ + public createNestedCreateOperation({ relationship, targetEntity, input, @@ -406,7 +407,7 @@ export class CreateFactory { relationship: RelationshipAdapter; callbackBucket: CallbackBucket; context: Neo4jGraphQLTranslationContext; - operation: CreateOperation; + operation: CreateOperation | UpdateOperation; key: string; }) { asArray(input).forEach((input) => { diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/DeleteFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/DeleteFactory.ts index 11b02d2a1b..37aaf04846 100644 --- a/packages/graphql/src/translate/queryAST/factory/Operations/DeleteFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/Operations/DeleteFactory.ts @@ -183,6 +183,29 @@ export class DeleteFactory { ); } + public createNestedDeleteOperationsForUpdate( + deleteArg: Record, + relationship: RelationshipAdapter, + context: Neo4jGraphQLTranslationContext, + target: ConcreteEntityAdapter | InterfaceEntityAdapter + ): DeleteOperation[] { + if (isInterfaceEntity(target)) { + return this.createNestedDeleteOperationsForInterface({ + deleteArg, + relationship, + target, + context, + }); + } + + return this.createNestedDeleteOperation({ + relationship, + target, + args: deleteArg, + context, + }); + } + private createNestedDeleteOperation({ relationship, target, diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/DisconnectFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/DisconnectFactory.ts new file mode 100644 index 0000000000..84c03ec030 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/factory/Operations/DisconnectFactory.ts @@ -0,0 +1,250 @@ +/* + * 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, { e } 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 { UnionEntityAdapter } from "../../../../schema-model/entity/model-adapters/UnionEntityAdapter"; +import type { RelationshipAdapter } from "../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import type { Neo4jGraphQLTranslationContext } from "../../../../types/neo4j-graphql-translation-context"; +import { asArray } from "../../../../utils/utils"; +import type { Filter } from "../../ast/filters/Filter"; +import { MutationOperationField } from "../../ast/input-fields/MutationOperationField"; +import { NodeSelectionPattern } from "../../ast/selection/SelectionPattern/NodeSelectionPattern"; +import type { CallbackBucket } from "../../utils/callback-bucket"; +import { isConcreteEntity } from "../../utils/is-concrete-entity"; +import { isInterfaceEntity } from "../../utils/is-interface-entity"; +import type { QueryASTFactory } from "../QueryASTFactory"; +import { DisconnectOperation } from "../../ast/operations/DisconnectOperation"; +import { CompositeDisconnectPartial } from "../../ast/operations/composite/CompositeDisconnectPartial"; +import { CompositeDisconnectOperation } from "../../ast/operations/composite/CompositeDisconnectOperation"; +import { RelationshipSelectionPattern } from "../../ast/selection/SelectionPattern/RelationshipSelectionPattern"; + +export class DisconnectFactory { + private queryASTFactory: QueryASTFactory; + + constructor(queryASTFactory: QueryASTFactory) { + this.queryASTFactory = queryASTFactory; + } + + public createDisconnectOperation( + entity: ConcreteEntityAdapter, + relationship: RelationshipAdapter, + input: Record[], + context: Neo4jGraphQLTranslationContext, + callbackBucket: CallbackBucket + ): DisconnectOperation { + const disconnectOP = new DisconnectOperation({ + target: entity, + selectionPattern: new RelationshipSelectionPattern({ + relationship, + }), + relationship, + }); + + this.hydrateDisconnectOperation({ + target: entity, + relationship, + input, + disconnect: disconnectOP, + context, + callbackBucket, + }); + return disconnectOP; + } + + public createCompositeDisconnectOperation( + entity: InterfaceEntityAdapter | UnionEntityAdapter, + relationship: RelationshipAdapter, + input: Record[], + context: Neo4jGraphQLTranslationContext, + callbackBucket: CallbackBucket + ): CompositeDisconnectOperation { + const partials: CompositeDisconnectPartial[] = []; + for (const concreteEntity of entity.concreteEntities) { + const partial = this.createCompositeDisconnectPartial( + concreteEntity, + relationship, + input, + context, + callbackBucket + ); + partials.push(partial); + } + + return new CompositeDisconnectOperation({ + partials, + target: entity, + }); + } + + private createCompositeDisconnectPartial( + entity: ConcreteEntityAdapter, + relationship: RelationshipAdapter, + input: Record[], + context: Neo4jGraphQLTranslationContext, + callbackBucket: CallbackBucket + ): CompositeDisconnectPartial { + const disconnectOp = new CompositeDisconnectPartial({ + target: entity, + selectionPattern: new RelationshipSelectionPattern({ + relationship, + targetOverride: entity, + }), + relationship, + }); + + this.hydrateDisconnectOperation({ + target: entity, + relationship, + input, + disconnect: disconnectOp, + context, + callbackBucket, + }); + return disconnectOp; + } + + private hydrateDisconnectOperation({ + target, + relationship, + input, + disconnect, + context, + callbackBucket, + }: { + target: ConcreteEntityAdapter; + relationship: RelationshipAdapter; + input: Record[]; + disconnect: DisconnectOperation; + context: Neo4jGraphQLTranslationContext; + callbackBucket: CallbackBucket; + }) { + this.addEntityAuthorization({ + entity: target, + context, + operation: disconnect, + }); + + asArray(input).forEach((inputItem) => { + const { whereArg, disconnectArg } = this.parseDisconnectArgs(inputItem); + const nodeFilters: Filter[] = []; + if (whereArg.node) { + if (isConcreteEntity(relationship.target)) { + nodeFilters.push(...this.queryASTFactory.filterFactory.createNodeFilters(target, whereArg.node)); + } else if (isInterfaceEntity(relationship.target)) { + nodeFilters.push( + ...this.queryASTFactory.filterFactory.createInterfaceNodeFilters({ + entity: relationship.target, + targetEntity: target, + whereFields: whereArg.node, + relationship, + }) + ); + } + } + + disconnect.addFilters(...nodeFilters); + + asArray(disconnectArg).forEach((nestedDisconnectInputFields) => { + Object.entries(nestedDisconnectInputFields).forEach(([key, value]) => { + const nestedRelationship = target.relationships.get(key); + if (!nestedRelationship) { + throw new Error("Expected relationship on connect operation. Please contact support"); + } + + const nestedEntity = nestedRelationship.target; + + asArray(value).forEach((nestedDisconnectInputItem) => { + const nestedDisconnectOperation = + this.queryASTFactory.operationsFactory.createDisconnectOperation( + nestedEntity, + nestedRelationship, + nestedDisconnectInputItem, + context, + callbackBucket + ); + + const mutationOperationField = new MutationOperationField(nestedDisconnectOperation, key); + disconnect.addField(mutationOperationField, "node"); + }); + }); + }); + + // const targetInputEdge = this.getInputEdge(inputItem, relationship); + + /* Create the attributes for the edge */ + // raiseAttributeAmbiguity(Object.keys(targetInputEdge), relationship); + // for (const key of Object.keys(targetInputEdge)) { + // const attribute = relationship.attributes.get(key); + // if (attribute) { + // const attachedTo = "relationship"; + + // const paramInputField = new ParamInputField({ + // attachedTo, + // attribute, + // inputValue: targetInputEdge[key], + // }); + // disconnect.addField(paramInputField, attachedTo); + // } + // } + }); + } + + private addEntityAuthorization({ + entity, + context, + operation, + }: { + entity: ConcreteEntityAdapter; + context: Neo4jGraphQLTranslationContext; + operation: DisconnectOperation; + }): void { + const authFilters = this.queryASTFactory.authorizationFactory.getAuthFilters({ + entity, + operations: ["DELETE_RELATIONSHIP"], + context, + afterValidation: true, + }); + + operation.addAuthFilters(...authFilters); + } + + private getInputEdge(inputItem: Record, relationship: RelationshipAdapter): Record { + const edge = inputItem.edge ?? {}; + + // Deals with composite relationships + if (relationship.propertiesTypeName && edge[relationship.propertiesTypeName]) { + return edge[relationship.propertiesTypeName]; + } + + return edge; + } + + private parseDisconnectArgs(args: Record): { + whereArg: { node: Record; edge: Record }; + disconnectArg: Record[]; + } { + const rawWhere = args.where ?? {}; + + const whereArg = { node: rawWhere.node, edge: {} }; + const disconnectArg = args.disconnect ?? {}; + return { whereArg, disconnectArg }; + } +} diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/UpdateFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/UpdateFactory.ts index 4f3e8d55ea..0bd1c68bb0 100644 --- a/packages/graphql/src/translate/queryAST/factory/Operations/UpdateFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/Operations/UpdateFactory.ts @@ -17,11 +17,33 @@ * limitations under the License. */ +import Cypher from "@neo4j/cypher-builder"; +import { GraphQLError } from "graphql"; import type { ResolveTree } from "graphql-parse-resolve-info"; +import type { AttributeAdapter } from "../../../../schema-model/attribute/model-adapters/AttributeAdapter"; import type { ConcreteEntityAdapter } from "../../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; +import type { InterfaceEntityAdapter } from "../../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; +import type { RelationshipAdapter } from "../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; import type { Neo4jGraphQLTranslationContext } from "../../../../types/neo4j-graphql-translation-context"; +import { asArray } from "../../../../utils/utils"; +import { OperationField } from "../../ast/fields/OperationField"; +import { type InputField } from "../../ast/input-fields/InputField"; +import { MutationOperationField } from "../../ast/input-fields/MutationOperationField"; +import { MathInputField } from "../../ast/input-fields/operators/MathInputField"; +import { PopInputField } from "../../ast/input-fields/operators/PopInputField"; +import { PushInputField } from "../../ast/input-fields/operators/PushInputField"; +import { ParamInputField } from "../../ast/input-fields/ParamInputField"; import type { ReadOperation } from "../../ast/operations/ReadOperation"; +import { TopLevelUpdateMutationOperation } from "../../ast/operations/TopLevelUpdateMutationOperation"; import { UpdateOperation } from "../../ast/operations/UpdateOperation"; +import { NodeSelectionPattern } from "../../ast/selection/SelectionPattern/NodeSelectionPattern"; +import { RelationshipSelectionPattern } from "../../ast/selection/SelectionPattern/RelationshipSelectionPattern"; +import type { CallbackBucket } from "../../utils/callback-bucket"; +import { isConcreteEntity } from "../../utils/is-concrete-entity"; +import { isUnionEntity } from "../../utils/is-union-entity"; +import { raiseAttributeAmbiguityForUpdate } from "../../utils/raise-attribute-ambiguity"; +import type { MutationOperator } from "../parsers/parse-mutation-field"; +import { parseMutationField } from "../parsers/parse-mutation-field"; import type { QueryASTFactory } from "../QueryASTFactory"; export class UpdateFactory { @@ -34,13 +56,41 @@ export class UpdateFactory { public createUpdateOperation( entity: ConcreteEntityAdapter, resolveTree: ResolveTree, - context: Neo4jGraphQLTranslationContext - ): UpdateOperation { + context: Neo4jGraphQLTranslationContext, + callbackBucket: CallbackBucket, + varName: string | undefined + ): TopLevelUpdateMutationOperation { + const rawInput = resolveTree.args.update as Record[]; + const input = asArray(rawInput) ?? []; + + const updateOperations: UpdateOperation[] = input.map((inputItem) => { + const updateOperation = new UpdateOperation({ + target: entity, + selectionPattern: new NodeSelectionPattern({ + target: entity, + alias: varName, + }), + }); + + this.hydrateUpdateOperation({ + target: entity, + input: inputItem, + update: updateOperation, + callbackBucket, + context, + whereArgs: { + node: (resolveTree.args.where as Record) ?? {}, + }, + }); + + return updateOperation; + }); + const responseFields = Object.values( resolveTree.fieldsByTypeName[entity.operations.mutationResponseTypeNames.update] ?? {} ); - const updateOp = new UpdateOperation({ target: entity }); - const projectionFields = responseFields + + const projectionOperations = responseFields .filter((f) => f.name === entity.plural) .map((field) => { const readOP = this.queryASTFactory.operationsFactory.createReadOperation({ @@ -48,10 +98,616 @@ export class UpdateFactory { resolveTree: field, context, }) as ReadOperation; - return readOP; + + const fieldOperation = new OperationField({ + operation: readOP, + alias: field.alias, + }); + return fieldOperation; + }); + + const topLevelMutation = new TopLevelUpdateMutationOperation({ + updateOperations, + projectionOperations, + }); + return topLevelMutation; + } + + private hydrateUpdateOperation({ + target, + relationship, + input, + update, + callbackBucket, + context, + whereArgs, + }: { + target: ConcreteEntityAdapter; + relationship?: RelationshipAdapter; + input: Record; + update: UpdateOperation; + callbackBucket: CallbackBucket; + context: Neo4jGraphQLTranslationContext; + whereArgs: { + node: Record; + edge?: Record; + }; + }) { + const isNested = Boolean(relationship); + // TODO: there is no need to get always the autogenerated field as these are static fields and can be cached + // TODO: Some of these should not be added in update (e.g. id) + // [target, relationship].forEach((t) => { + // if (!t) { + // return; + // } + // const autoGeneratedFields = getAutogeneratedFields(t); + + // autoGeneratedFields.forEach((field) => { + // update.addField(field); + // }); + // }); + + this.addEntityAuthorization({ entity: target, context, operation: update }); + asArray(input).forEach((inputItem) => { + const targetInput = this.getInputNode(inputItem, isNested); + raiseAttributeAmbiguityForUpdate(Object.keys(targetInput), target); + raiseAttributeAmbiguityForUpdate(Object.keys(this.getInputEdge(inputItem)), relationship); + + const filters = this.queryASTFactory.filterFactory.createConnectionPredicates({ + rel: relationship, + entity: target, + where: whereArgs, + }); + // const filters = this.queryASTFactory.filterFactory.createNodeFilters(target, whereArgs.node); + update.addFilters(...filters); + for (const key of Object.keys(targetInput)) { + const { fieldName, operator } = parseMutationField(key); + const nestedRelationship = target.relationships.get(fieldName); + const attribute = target.attributes.get(fieldName); + if (!attribute && !nestedRelationship) { + throw new Error(`Transpile Error: Input field ${key} not found in entity ${target.name}`); + } + if (attribute) { + if (operator) { + const value = targetInput[key]; + if (attribute.typeHelper.isRequired() && value === null && operator === "SET") { + throw new Error(`Cannot set non-nullable field ${target.name}.${attribute.name} to null`); + } + const paramInputField = this.getInputFieldDeprecated("node", operator, attribute, value); + update.addField(paramInputField); + + this.addAttributeAuthorization({ + attribute, + context, + update, + entity: target, + }); + } else { + const operations = Object.keys(targetInput[fieldName]); + if (operations.length > 1) { + const conflictingOperations = operations.map((op) => `[[${op}]]`); + throw new GraphQLError( + `Conflicting modification of field ${fieldName}: ${conflictingOperations.join(", ")} on type ${target.name}` + ); + } + for (const op of Object.keys(targetInput[fieldName])) { + const value = targetInput[fieldName][op]; + if (attribute.typeHelper.isRequired() && value === null && op === "set") { + throw new Error( + `Cannot set non-nullable field ${target.name}.${attribute.name} to null` + ); + } + const paramInputField = this.getInputField("node", op, attribute, value); + update.addField(paramInputField); + + this.addAttributeAuthorization({ + attribute, + context, + update, + entity: target, + }); + } + } + } else if (nestedRelationship) { + const nestedEntity = nestedRelationship.target; + const operationInput = targetInput[key] ?? {}; + + const entityAndNodeInput: Array< + [ConcreteEntityAdapter | InterfaceEntityAdapter, Record] + > = []; + + if (isUnionEntity(nestedEntity)) { + Object.entries(operationInput).forEach(([entityTypename, input]) => { + const concreteNestedEntity = nestedEntity.concreteEntities.find( + (e) => e.name === entityTypename + ); + if (!concreteNestedEntity) { + throw new Error("Concrete entity not found in create, please contact support"); + } + + entityAndNodeInput.push([concreteNestedEntity, input as any]); + }); + } else { + entityAndNodeInput.push([nestedEntity, operationInput]); + } + + entityAndNodeInput.forEach(([nestedEntity, operations]) => { + operations.forEach((operationInput: Record) => { + const nestedUpdateInput = operationInput.update; + if (nestedUpdateInput) { + asArray(nestedUpdateInput).forEach((nestedUpdateInputItem) => { + this.createNestedUpdateOperation({ + nestedEntity, + nestedRelationship, + nestedUpdateInputItem, + context, + callbackBucket, + operation: update, + key, + }); + }); + } + const nestedCreateInput = operationInput.create; + if (nestedCreateInput) { + asArray(nestedCreateInput).forEach((nestedCreateInputItem) => { + let edgeField = nestedCreateInputItem.edge ?? {}; + + // This is to parse the create input for a declareRelationship + // We are checking the relationship target, because for nestedRelationship is + // already disambiguated into concrete entity + if (relationship?.target && !isConcreteEntity(relationship?.target)) { + if (nestedRelationship.propertiesTypeName) { + edgeField = edgeField[nestedRelationship.propertiesTypeName] ?? {}; + } + } + + const concreteNestedCreateInput = { + node: nestedCreateInputItem.node ?? {}, + edge: edgeField, + }; + + this.queryASTFactory.operationsFactory.createNestedCreateOperation({ + targetEntity: nestedEntity, + relationship: nestedRelationship, + input: concreteNestedCreateInput, + context, + callbackBucket, + key, + operation: update, + }); + }); + } + const nestedConnectInput = operationInput.connect; + if (nestedConnectInput) { + asArray(nestedConnectInput).forEach((nestedConnectInputItem) => { + const nestedConnectOperation = + this.queryASTFactory.operationsFactory.createConnectOperation( + nestedEntity, + nestedRelationship, + nestedConnectInputItem, + context, + callbackBucket + ); + + const mutationOperationField = new MutationOperationField( + nestedConnectOperation, + key + ); + update.addField(mutationOperationField); + }); + } + const nestedDeleteInput = operationInput.delete; + if (nestedDeleteInput) { + asArray(nestedDeleteInput).forEach((nestedDeleteInputItem) => { + const nestedDeleteOperations = + this.queryASTFactory.operationsFactory.createNestedDeleteOperationsForUpdate( + nestedDeleteInputItem, + nestedRelationship, + context, + nestedEntity + ); + for (const nestedDeleteOperation of nestedDeleteOperations) { + const mutationOperationField = new MutationOperationField( + nestedDeleteOperation, + key + ); + update.addField(mutationOperationField); + } + }); + } + const nestedDisconnectInput = operationInput.disconnect; + if (nestedDisconnectInput) { + asArray(nestedDisconnectInput).forEach((nestedDisconnectInputItem) => { + const nestedDisconnectOperation = + this.queryASTFactory.operationsFactory.createDisconnectOperation( + nestedEntity, + nestedRelationship, + nestedDisconnectInputItem, + context, + callbackBucket + ); + + const mutationOperationField = new MutationOperationField( + nestedDisconnectOperation, + key + ); + update.addField(mutationOperationField); + }); + } + }); + }); + } + } + + if (relationship) { + const targetInputEdge = this.getInputEdge(inputItem); + for (const key of Object.keys(targetInputEdge)) { + const { fieldName, operator } = parseMutationField(key); + const attribute = relationship.attributes.get(fieldName); + if (attribute) { + if (operator) { + const paramInputField = this.getInputFieldDeprecated( + "relationship", + operator, + attribute, + targetInputEdge[key] + ); + update.addField(paramInputField); + + this.addAttributeAuthorization({ + attribute, + context, + update, + entity: target, + }); + } else { + const operations = Object.keys(targetInputEdge[fieldName]); + if (operations.length > 1) { + const conflictingOperations = operations.map((op) => `[[${op}]]`); + throw new GraphQLError( + `Conflicting modification of field ${fieldName}: ${conflictingOperations.join(", ")} on relationship ${target.name}.${relationship.name}` + ); + } + for (const op of operations) { + const paramInputField = this.getInputField( + "relationship", + op, + attribute, + targetInputEdge[fieldName][op] + ); + update.addField(paramInputField); + + this.addAttributeAuthorization({ + attribute, + context, + update, + entity: target, + }); + } + } + } else if (key === relationship.propertiesTypeName) { + const edgeInput = targetInputEdge[key]; // ActedIn: {..} + for (const k of Object.keys(edgeInput)) { + const { fieldName, operator } = parseMutationField(k); + const attribute = relationship.attributes.get(fieldName); + if (attribute) { + if (operator) { + const paramInputField = this.getInputFieldDeprecated( + "relationship", + operator, + attribute, + edgeInput[k] + ); + update.addField(paramInputField); + + this.addAttributeAuthorization({ + attribute, + context, + update, + entity: target, + }); + } else { + for (const op of Object.keys(edgeInput[k][fieldName])) { + const paramInputField = this.getInputField( + "relationship", + op, + attribute, + edgeInput[fieldName][op] + ); + update.addField(paramInputField); + + this.addAttributeAuthorization({ + attribute, + context, + update, + entity: target, + }); + } + } + } + } + } + } + if (Object.keys(targetInputEdge).length > 0) { + this.addPopulatedByFieldToUpdate({ + entity: target, + update, + input: targetInputEdge, + callbackBucket, + relationship, + }); + } + } + this.addPopulatedByFieldToUpdate({ + entity: target, + update, + input: targetInput, + callbackBucket, + }); + }); + } + + private addPopulatedByFieldToUpdate({ + entity, + update, + input, + callbackBucket, + relationship, + }: { + entity: ConcreteEntityAdapter; + update: UpdateOperation; + input: Record; + callbackBucket: CallbackBucket; + relationship?: RelationshipAdapter; + }) { + entity.getPopulatedByFields("UPDATE").forEach((attribute) => { + const attachedTo = "node"; + // the param value it's irrelevant as it will be overwritten by the callback function + const callbackParam = new Cypher.Param(""); + const field = new ParamInputField({ + attribute, + attachedTo, + inputValue: callbackParam, + }); + update.addField(field); + + const callbackFunctionName = attribute.annotations.populatedBy?.callback; + if (!callbackFunctionName) { + throw new Error(`PopulatedBy callback not found for attribute ${attribute.name}`); + } + + const callbackParent = relationship ? input.node : input; + + callbackBucket.addCallback({ + functionName: callbackFunctionName, + param: callbackParam, + parent: callbackParent, + type: attribute.type, }); + }); - updateOp.addProjectionOperations(projectionFields); - return updateOp; + if (relationship) { + relationship.getPopulatedByFields("UPDATE").forEach((attribute) => { + const attachedTo = "relationship"; + // the param value it's irrelevant as it will be overwritten by the callback function + const relCallbackParam = new Cypher.Param(""); + const relField = new ParamInputField({ + attribute, + attachedTo, + inputValue: relCallbackParam, + }); + update.addField(relField); + + const callbackFunctionName = attribute.annotations.populatedBy?.callback; + if (!callbackFunctionName) { + throw new Error(`PopulatedBy callback not found for attribute ${attribute.name}`); + } + + callbackBucket.addCallback({ + functionName: callbackFunctionName, + param: relCallbackParam, + parent: input, + type: attribute.type, + }); + }); + } + } + + private addEntityAuthorization({ + entity, + context, + operation, + }: { + entity: ConcreteEntityAdapter; + context: Neo4jGraphQLTranslationContext; + operation: UpdateOperation; + }): void { + const authFilters = this.queryASTFactory.authorizationFactory.createAuthValidateRule({ + entity, + authAnnotation: entity.annotations.authorization, + when: "AFTER", + operations: ["UPDATE"], + context, + }); + if (authFilters) { + operation.addAuthFilters(authFilters); + } + } + + private addAttributeAuthorization({ + attribute, + context, + update, + entity, + conditionForEvaluation, + }: { + attribute: AttributeAdapter; + context: Neo4jGraphQLTranslationContext; + update: UpdateOperation; + entity: ConcreteEntityAdapter; + conditionForEvaluation?: Cypher.Predicate; + }): void { + const attributeAuthorization = this.queryASTFactory.authorizationFactory.createAuthValidateRule({ + entity, + when: "AFTER", + authAnnotation: attribute.annotations.authorization, + conditionForEvaluation, + operations: ["UPDATE"], + context, + }); + if (attributeAuthorization) { + update.addAuthFilters(attributeAuthorization); + } + } + + private getInputNode(inputItem: Record, isNested: boolean): Record { + if (isNested) { + return inputItem.node ?? {}; + } + return inputItem; + } + + private getInputEdge(inputItem: Record): Record { + return inputItem.edge ?? {}; + } + + private getInputFieldDeprecated( + attachedTo: "node" | "relationship", + operator: MutationOperator | undefined, + attribute: AttributeAdapter, + value: unknown + ): InputField { + switch (operator) { + case "SET": + return new ParamInputField({ + attachedTo, + attribute, + inputValue: value, + }); + case "INCREMENT": + case "DECREMENT": + case "ADD": + case "SUBTRACT": + case "DIVIDE": + case "MULTIPLY": + return new MathInputField({ + attachedTo, + attribute, + inputValue: value, + operation: operator.toLowerCase() as any, + }); + case "PUSH": + return new PushInputField({ + attachedTo, + attribute, + inputValue: value, + }); + case "POP": + return new PopInputField({ + attachedTo, + attribute, + inputValue: value, + }); + default: + throw new Error(`Unsupported update operator ${operator} on field ${attribute.name} `); + } + } + private getInputField( + attachedTo: "node" | "relationship", + operator: string | undefined, + attribute: AttributeAdapter, + value: unknown + ): InputField { + switch (operator) { + case "set": + return new ParamInputField({ + attachedTo: "node", + attribute, + inputValue: value, + }); + case "increment": + case "decrement": + case "add": + case "subtract": + case "divide": + case "multiply": + return new MathInputField({ + attachedTo, + attribute, + inputValue: value, + operation: operator, + }); + case "push": + return new PushInputField({ + attachedTo, + attribute, + inputValue: value, + }); + case "pop": + return new PopInputField({ + attachedTo, + attribute, + inputValue: value, + }); + default: + throw new Error(`Unsupported update operator ${operator} on field ${attribute.name} `); + } + } + + private createNestedUpdateOperation({ + nestedEntity, + nestedRelationship, + nestedUpdateInputItem, + context, + callbackBucket, + operation, + key, + }: { + nestedEntity: ConcreteEntityAdapter | InterfaceEntityAdapter; + nestedRelationship: RelationshipAdapter; + nestedUpdateInputItem: Record; + context: Neo4jGraphQLTranslationContext; + callbackBucket: CallbackBucket; + operation: UpdateOperation; + key: string; + }) { + asArray(nestedUpdateInputItem).forEach((input) => { + const edgeFields = input.edge ?? {}; + const nodeInputFields = input.node ?? {}; + + const entityAndNodeInput: Array<[ConcreteEntityAdapter, Record]> = []; + + if (isConcreteEntity(nestedEntity)) { + entityAndNodeInput.push([nestedEntity, nodeInputFields]); + } else { + nestedEntity.concreteEntities.forEach((concreteEntity) => { + entityAndNodeInput.push([concreteEntity, nodeInputFields]); + }); + } + + entityAndNodeInput.forEach(([concreteEntity, nodeInputFields]) => { + const nestedUpdateOperation = new UpdateOperation({ + target: concreteEntity, + relationship: nestedRelationship, + selectionPattern: new RelationshipSelectionPattern({ + relationship: nestedRelationship, + targetOverride: concreteEntity, + }), + }); + + this.hydrateUpdateOperation({ + target: concreteEntity, + relationship: nestedRelationship, + input: { node: nodeInputFields, edge: edgeFields }, + update: nestedUpdateOperation, + callbackBucket, + context, + whereArgs: input.where, + }); + + const mutationOperationField = new MutationOperationField(nestedUpdateOperation, key); + operation.addField(mutationOperationField); + }); + }); } } diff --git a/packages/graphql/src/translate/queryAST/utils/callback-bucket.ts b/packages/graphql/src/translate/queryAST/utils/callback-bucket.ts index 01ecb8c69a..0812ff8256 100644 --- a/packages/graphql/src/translate/queryAST/utils/callback-bucket.ts +++ b/packages/graphql/src/translate/queryAST/utils/callback-bucket.ts @@ -79,7 +79,13 @@ export class CallbackBucket { const callbackFunction = callbacksList[cb.functionName]; if (callbackFunction) { const paramValue = await callbackFunction(cb.parent, {}, this.context); - cb.param.value = this.parseCallbackResult(paramValue, cb.type); + if (paramValue === undefined) { + cb.param.value = undefined; + } else if (paramValue === null) { + cb.param.value = null; + } else { + cb.param.value = this.parseCallbackResult(paramValue, cb.type); + } } }) ); diff --git a/packages/graphql/src/translate/queryAST/utils/raise-attribute-ambiguity.ts b/packages/graphql/src/translate/queryAST/utils/raise-attribute-ambiguity.ts index 571a7891bf..3e1c3a42ff 100644 --- a/packages/graphql/src/translate/queryAST/utils/raise-attribute-ambiguity.ts +++ b/packages/graphql/src/translate/queryAST/utils/raise-attribute-ambiguity.ts @@ -20,6 +20,7 @@ import { Neo4jGraphQLError } from "../../../classes"; import type { ConcreteEntityAdapter } from "../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import type { RelationshipAdapter } from "../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import { findConflictingAttributes } from "../../../utils/find-conflicting-properties"; import { isConcreteEntity } from "./is-concrete-entity"; // Schema Model version of findConflictingProperties @@ -51,3 +52,22 @@ export function raiseAttributeAmbiguity( hash[dbName] = property; }); } + +// Schema Model version of assertNonAmbiguousUpdate +export function raiseAttributeAmbiguityForUpdate( + properties: Array, + entityOrRel?: ConcreteEntityAdapter | RelationshipAdapter +): void { + if (!entityOrRel) { + return; + } + + const conflictingAttributes = findConflictingAttributes(properties, entityOrRel); + if (conflictingAttributes.size > 0) { + const conflictingAttributesString = Array.from(conflictingAttributes).map((attribute) => `[[${attribute}]]`); + //This will only throw on the first conflicting attribute through + throw new Neo4jGraphQLError( + `Conflicting modification of ${conflictingAttributesString.join(", ")} on type ${entityOrRel.name}` + ); + } +} diff --git a/packages/graphql/src/translate/translate-update.ts b/packages/graphql/src/translate/translate-update.ts index 1dec53b0a9..074766a646 100644 --- a/packages/graphql/src/translate/translate-update.ts +++ b/packages/graphql/src/translate/translate-update.ts @@ -38,6 +38,8 @@ import { CallbackBucket } from "./queryAST/utils/callback-bucket"; import { translateTopLevelMatch } from "./translate-top-level-match"; import { buildClause } from "./utils/build-clause"; import { getAuthorizationStatements } from "./utils/get-authorization-statements"; +import type { EntityAdapter } from "../schema-model/entity/EntityAdapter"; +import type { ResolveTree } from "graphql-parse-resolve-info"; const debug = Debug(DEBUG_TRANSLATE); @@ -473,3 +475,69 @@ function isFollowedByASubquery(clause): boolean { } return false; } + +async function translateUsingQueryAST({ + context, + entityAdapter, + resolveTree, + varName, +}: { + context: Neo4jGraphQLTranslationContext; + entityAdapter: EntityAdapter; + resolveTree: ResolveTree; + varName: string; +}): Promise { + const operationsTreeFactory = new QueryASTFactory(context.schemaModel); + + if (!entityAdapter) { + throw new Error("Entity not found"); + } + + const callbackBucket = new CallbackBucket(context); + + const operationsTree = operationsTreeFactory.createMutationAST({ + resolveTree, + entityAdapter, + context, + varName, + callbackBucket, + }); + + // const queryASTEnv = new QueryASTEnv(); + + // const queryASTContext = new QueryASTContext({ + // target: new Cypher.NamedNode(varName), + // env: queryASTEnv, + // neo4jGraphQLContext: context, + // returnVariable: new Cypher.NamedVariable("data"), + // shouldCollect: true, + // shouldDistinct: true, + // }); + + debug(operationsTree.print()); + await callbackBucket.resolveCallbacks(); + + const clause = operationsTree.build(context, varName); + return buildClause(clause, { context }); + + // return buildClause(Cypher.utils.concat(...operationsTree.transpile(queryASTContext).clauses), { context }); +} + +export async function translateUpdate2({ + context, + node, +}: { + context: Neo4jGraphQLTranslationContext; + node: Node; +}): Promise<{ cypher: string; params: Record }> { + const { resolveTree } = context; + const entityAdapter = context.schemaModel.getConcreteEntityAdapter(node.name); + if (!entityAdapter) { + throw new Error(`Transpilation error: ${node.name} is not a concrete entity`); + } + + const varName = "this"; + const result = await translateUsingQueryAST({ context, entityAdapter, resolveTree, varName }); + + return result; +} diff --git a/packages/graphql/src/utils/find-conflicting-properties.ts b/packages/graphql/src/utils/find-conflicting-properties.ts index f96f34f5e2..6af7bac200 100644 --- a/packages/graphql/src/utils/find-conflicting-properties.ts +++ b/packages/graphql/src/utils/find-conflicting-properties.ts @@ -18,10 +18,14 @@ */ import type { GraphElement } from "../classes"; +import type { ConcreteEntityAdapter } from "../schema-model/entity/model-adapters/ConcreteEntityAdapter"; +import type { RelationshipAdapter } from "../schema-model/relationship/model-adapters/RelationshipAdapter"; import { parseMutationField } from "../translate/queryAST/factory/parsers/parse-mutation-field"; import mapToDbProperty from "./map-to-db-property"; -/* returns conflicting mutation input properties */ +/** returns conflicting mutation input properties + * @deprecated + */ export function findConflictingProperties({ graphElement, input, @@ -55,3 +59,30 @@ export function findConflictingProperties({ return acc; }, []); } + +export function findConflictingAttributes( + fields: string[], + entityOrRel: ConcreteEntityAdapter | RelationshipAdapter +): Set { + const fieldsByDbName = new Map(); + + for (const rawField of fields) { + const { fieldName } = parseMutationField(rawField); + const dbName = entityOrRel.findAttribute(fieldName)?.databaseName; + if (dbName) { + const duplicateFields = fieldsByDbName.get(dbName) ?? []; + duplicateFields.push(rawField); + fieldsByDbName.set(dbName, duplicateFields); + } + } + + const conflictingAttributes = new Set(); + for (const dedupedProps of fieldsByDbName.values()) { + if (dedupedProps.length > 1) { + for (const fieldName of dedupedProps) { + conflictingAttributes.add(fieldName); + } + } + } + return conflictingAttributes; +} diff --git a/packages/graphql/tests/integration/array-methods/array-push.int.test.ts b/packages/graphql/tests/integration/array-methods/array-push.int.test.ts index 3763e6e966..39ffcf019e 100644 --- a/packages/graphql/tests/integration/array-methods/array-push.int.test.ts +++ b/packages/graphql/tests/integration/array-methods/array-push.int.test.ts @@ -40,184 +40,219 @@ describe("array-push", () => { const expectedLocalDateTime = expect.stringContaining(localDateTime); test.each([ - { description: "a single Int element", inputType: "Int", inputValue: 100, expectedOutputValue: [100] }, + { + description: "a single Int element", + inputType: "Int", + inputValue: 100, + expectedOutputValue: [1, 100], + initialArray: [1], + }, { description: "a single Int element in an array", inputType: "Int", inputValue: `[${100}]`, - expectedOutputValue: [100], + expectedOutputValue: [1, 100], + initialArray: [1], }, { description: "multiple Int elements", inputType: "Int", inputValue: `[${100}, ${100}]`, - expectedOutputValue: [100, 100], + expectedOutputValue: [1, 100, 100], + initialArray: [1], }, { description: "a single Float element", inputType: "Float", inputValue: 0.123456, - expectedOutputValue: [0.123456], + expectedOutputValue: [1.1, 0.123456], + initialArray: [1.1], }, { description: "a single Float element in an array", inputType: "Float", inputValue: `[${0.123456}]`, - expectedOutputValue: [0.123456], + expectedOutputValue: [1.1, 0.123456], + initialArray: [1.1], }, { description: "multiple Float elements", inputType: "Float", inputValue: `[${0.123456}, ${0.123456}]`, - expectedOutputValue: [0.123456, 0.123456], + expectedOutputValue: [1.1, 0.123456, 0.123456], + initialArray: [1.1], }, { description: "a single String element", inputType: "String", inputValue: `"tag"`, - expectedOutputValue: ["tag"], + expectedOutputValue: ["first", "tag"], + initialArray: ["first"], }, { description: "a single String element in an array", inputType: "String", inputValue: `["tag"]`, - expectedOutputValue: ["tag"], + expectedOutputValue: ["first", "tag"], + initialArray: ["first"], }, { description: "multiple String elements", inputType: "String", inputValue: `["tag1", "tag2"]`, - expectedOutputValue: ["tag1", "tag2"], + expectedOutputValue: ["first", "tag1", "tag2"], + initialArray: ["first"], }, { description: "a single Boolean element", inputType: "Boolean", inputValue: true, - expectedOutputValue: [true], + expectedOutputValue: [true, true], + initialArray: [true], }, { description: "a single Boolean element in an array", inputType: "Boolean", inputValue: `[${true}]`, - expectedOutputValue: [true], + expectedOutputValue: [true, true], + initialArray: [true], }, { description: "multiple Boolean elements", inputType: "Boolean", inputValue: `[${false}, ${true}]`, - expectedOutputValue: [false, true], + expectedOutputValue: [true, false, true], + initialArray: [true], }, { description: "a single Duration element", inputType: "Duration", inputValue: `"P2Y"`, expectedOutputValue: ["P24M0DT0S"], + initialArray: [], }, { description: "a single Duration element in an array", inputType: "Duration", inputValue: `["P2Y"]`, expectedOutputValue: ["P24M0DT0S"], + initialArray: [], }, { description: "multiple Duration elements", inputType: "Duration", inputValue: `["P2Y", "P2Y"]`, expectedOutputValue: ["P24M0DT0S", "P24M0DT0S"], + initialArray: [], }, { description: "a single Date element", inputType: "Date", inputValue: `"${date}"`, expectedOutputValue: [expectedDateOutput], + initialArray: [], }, { description: "a single Date element in an array", inputType: "Date", inputValue: `["${date}"]`, expectedOutputValue: [expectedDateOutput], + initialArray: [], }, { description: "multiple Date elements", inputType: "Date", inputValue: `["${date}", "${date}"]`, expectedOutputValue: [expectedDateOutput, expectedDateOutput], + initialArray: [], }, { description: "a single Time element", inputType: "Time", inputValue: `"${time}"`, expectedOutputValue: [expectedTimeOutput], + initialArray: [], }, { description: "a single Time element in an array", inputType: "Time", inputValue: `["${time}"]`, expectedOutputValue: [expectedTimeOutput], + initialArray: [], }, { description: "multiple Time elements", inputType: "Time", inputValue: `["${time}", "${time}"]`, expectedOutputValue: [expectedTimeOutput, expectedTimeOutput], + initialArray: [], }, { description: "a single LocalTime element", inputType: "LocalTime", inputValue: `"${localTime}"`, expectedOutputValue: [expectedLocalTime], + initialArray: [], }, { description: "a single LocalTime element in an array", inputType: "LocalTime", inputValue: `["${localTime}"]`, expectedOutputValue: [expectedLocalTime], + initialArray: [], }, { description: "multiple LocalTime elements", inputType: "LocalTime", inputValue: `["${localTime}", "${localTime}"]`, expectedOutputValue: [expectedLocalTime, expectedLocalTime], + initialArray: [], }, { description: "a single DateTime element", inputType: "DateTime", inputValue: `"${date}"`, expectedOutputValue: [date], + initialArray: [], }, { description: "a single DateTime element in an array", inputType: "DateTime", inputValue: `["${date}"]`, expectedOutputValue: [date], + initialArray: [], }, { description: "multiple DateTime elements", inputType: "DateTime", inputValue: `["${date}", "${date}"]`, expectedOutputValue: [date, date], + initialArray: [], }, { description: "a single LocalDateTime element", inputType: "LocalDateTime", inputValue: `"${localDateTime}"`, expectedOutputValue: [expectedLocalDateTime], + initialArray: [], }, { description: "a single LocalDateTime element in an array", inputType: "LocalDateTime", inputValue: `["${localDateTime}"]`, expectedOutputValue: [expectedLocalDateTime], + initialArray: [], }, { description: "multiple LocalDateTime elements", inputType: "LocalDateTime", inputValue: `["${localDateTime}", "${localDateTime}"]`, expectedOutputValue: [expectedLocalDateTime, expectedLocalDateTime], + initialArray: [], }, ] as const)( "should push $description on to an existing array", - async ({ inputType, inputValue, expectedOutputValue }) => { + async ({ initialArray, inputType, inputValue, expectedOutputValue }) => { const typeMovie = testHelper.createUniqueType("Movie"); const typeDefs = gql` @@ -245,10 +280,10 @@ describe("array-push", () => { `; const cypher = ` - CREATE (m:${typeMovie} {title:$movieTitle, tags: []}) + CREATE (m:${typeMovie} {title:$movieTitle, tags: $initialArray}) `; - await testHelper.executeCypher(cypher, { movieTitle }); + await testHelper.executeCypher(cypher, { movieTitle, initialArray }); const gqlResult = await testHelper.executeGraphQL(update); diff --git a/packages/graphql/tests/integration/directives/alias/nodes.int.test.ts b/packages/graphql/tests/integration/directives/alias/nodes.int.test.ts index 93403f9dbc..9ca47d197c 100644 --- a/packages/graphql/tests/integration/directives/alias/nodes.int.test.ts +++ b/packages/graphql/tests/integration/directives/alias/nodes.int.test.ts @@ -335,7 +335,9 @@ describe("@alias directive", () => { expect(gqlResult.errors).toBeDefined(); expect(gqlResult.errors).toHaveLength(1); expect(gqlResult.errors).toEqual([ - new GraphQLError(`Conflicting modification of [[name_SET]], [[nameAgain_SET]] on type ${typeDirector.name}`), + new GraphQLError( + `Conflicting modification of [[name_SET]], [[nameAgain_SET]] on type ${typeDirector.name}` + ), ]); expect(gqlResult?.data?.[typeDirector.operations.update]?.[typeDirector.plural]).toBeUndefined(); @@ -389,7 +391,9 @@ describe("@alias directive", () => { expect(gqlResult.errors).toBeDefined(); expect(gqlResult.errors).toHaveLength(1); expect(gqlResult.errors).toEqual([ - new GraphQLError(`Conflicting modification of [[name_SET]], [[nameAgain_SET]] on type ${typeDirector.name}`), + new GraphQLError( + `Conflicting modification of [[name_SET]], [[nameAgain_SET]] on type ${typeDirector.name}` + ), ]); expect(gqlResult?.data?.[typeDirector.operations.update]?.[typeDirector.plural]).toBeUndefined(); @@ -594,7 +598,9 @@ describe("@alias directive", () => { expect(gqlResult.errors).toBeDefined(); expect(gqlResult.errors).toHaveLength(1); expect(gqlResult.errors).toEqual([ - new GraphQLError(`Conflicting modification of [[name_SET]], [[nameAgain_SET]] on type ${typeDirector.name}`), + new GraphQLError( + `Conflicting modification of [[name_SET]], [[nameAgain_SET]] on type ${typeDirector.name}` + ), ]); expect(gqlResult?.data?.[typeDirector.operations.update]?.[typeDirector.plural]).toBeUndefined(); diff --git a/packages/graphql/tests/integration/directives/populatedBy/populatedBy-node-properties.int.test.ts b/packages/graphql/tests/integration/directives/populatedBy/populatedBy-node-properties.int.test.ts index 079038aa92..01a8a41d0b 100644 --- a/packages/graphql/tests/integration/directives/populatedBy/populatedBy-node-properties.int.test.ts +++ b/packages/graphql/tests/integration/directives/populatedBy/populatedBy-node-properties.int.test.ts @@ -1811,7 +1811,7 @@ describe("@populatedBy directive - Node properties", () => { [testMovie.plural]: [ { id: movieId, - callback: `${date.toISOString().split("T")[1]?.split("Z")[0]}Z`, + callback: `${date.toISOString().split("T")[1]?.split("Z")[0]}000000Z`, }, ], }, diff --git a/packages/graphql/tests/integration/directives/populatedBy/populatedBy-relationship-properties.int.test.ts b/packages/graphql/tests/integration/directives/populatedBy/populatedBy-relationship-properties.int.test.ts index c0b527d7ce..e3147fa337 100644 --- a/packages/graphql/tests/integration/directives/populatedBy/populatedBy-relationship-properties.int.test.ts +++ b/packages/graphql/tests/integration/directives/populatedBy/populatedBy-relationship-properties.int.test.ts @@ -2441,7 +2441,6 @@ describe("@populatedBy directive - Relationship properties", () => { type: "Time", callback: () => Promise.resolve(`${date.toISOString().split("T")[1]}`), expectedValue: `${date.toISOString().split("T")[1]?.split("Z")[0]}000000Z`, - expectedValueTemp: `${date.toISOString().split("T")[1]?.split("Z")[0]}Z`, // TODO: this is a due to a bug with custom input objects, only used for Update until this is moved to QueryAST }, { description: "@populatedBy - LocalDateTime", @@ -3208,7 +3207,6 @@ describe("@populatedBy directive - Relationship properties", () => { const testMovie = testHelper.createUniqueType("Movie"); const testGenre = testHelper.createUniqueType("Genre"); const callback = (parent) => `${parent.title_SET}-slug`; - const typeDefs = /* GraphQL */ ` type ${testMovie.name} @node { id: ID @@ -3286,7 +3284,6 @@ describe("@populatedBy directive - Relationship properties", () => { `); const result = await testHelper.executeGraphQL(mutation); - expect(result.errors).toBeUndefined(); expect(result.data as any).toMatchObject({ [testMovie.operations.update]: { diff --git a/packages/graphql/tests/integration/interfaces/relationships/declare-relationship/interface-simple.int.test.ts b/packages/graphql/tests/integration/interfaces/relationships/declare-relationship/interface-simple.int.test.ts index 962b13135a..cc617c70c9 100644 --- a/packages/graphql/tests/integration/interfaces/relationships/declare-relationship/interface-simple.int.test.ts +++ b/packages/graphql/tests/integration/interfaces/relationships/declare-relationship/interface-simple.int.test.ts @@ -1130,7 +1130,6 @@ describe("interface with declared relationships", () => { const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[Actor.operations.update] as Record)?.[Actor.plural]).toIncludeSameMembers( [ { diff --git a/packages/graphql/tests/integration/math.int.test.ts b/packages/graphql/tests/integration/math.int.test.ts index 70df9ee16c..6f9d88cd7a 100644 --- a/packages/graphql/tests/integration/math.int.test.ts +++ b/packages/graphql/tests/integration/math.int.test.ts @@ -493,7 +493,7 @@ describe("Mathematical operations tests", () => { expect(gqlResult.errors).toBeDefined(); expect( (gqlResult.errors as GraphQLError[]).some((el) => - el.message.includes(`Cannot _INCREMENT ${increment} to Nan`) + el.message.includes(`Cannot increment ${increment} to Nan`) ) ).toBeTruthy(); const storedValue = await testHelper.executeCypher( @@ -669,9 +669,10 @@ describe("Mathematical operations tests", () => { expect(gqlResult.errors).toBeDefined(); - const relationshipType = `${movie.name}ActorsRelationship`; expect(gqlResult.errors).toEqual([ - new GraphQLError(`Conflicting modification of field pay: [[set]], [[add]] on type ${relationshipType}`), + new GraphQLError( + `Conflicting modification of field pay: [[set]], [[add]] on relationship ${movie.name}.actedIn` + ), ]); const storedValue = await testHelper.executeCypher( ` diff --git a/packages/graphql/tests/integration/subscriptions/update/top-level-where.int.test.ts b/packages/graphql/tests/integration/subscriptions/update/top-level-where.int.test.ts index f56937d2e1..84f52246b5 100644 --- a/packages/graphql/tests/integration/subscriptions/update/top-level-where.int.test.ts +++ b/packages/graphql/tests/integration/subscriptions/update/top-level-where.int.test.ts @@ -120,7 +120,7 @@ describe("Delete using top level aggregate where - subscriptions enabled", () => }); }); - test("Top-level OR", async () => { + test.only("Top-level OR", async () => { if (!cdcEnabled) { console.log("CDC NOT AVAILABLE - SKIPPING"); return; diff --git a/packages/graphql/tests/integration/update.int.test.ts b/packages/graphql/tests/integration/update.int.test.ts index 87c6e3ba26..31060698b7 100644 --- a/packages/graphql/tests/integration/update.int.test.ts +++ b/packages/graphql/tests/integration/update.int.test.ts @@ -362,17 +362,11 @@ describe("update", () => { await testHelper.initNeo4jGraphQL({ typeDefs }); - const movieId = generate({ - charset: "alphabetic", - }); + const movieId = "movieId"; - const initialName = generate({ - charset: "alphabetic", - }); + const initialName = "Original Name"; - const updatedName = generate({ - charset: "alphabetic", - }); + const updatedName = "New Fancy Name"; const query = /* GraphQL */ ` mutation($movieId: ID, $initialName: String, $updatedName: String) { diff --git a/yarn.lock b/yarn.lock index fc2fb89676..2b37ee2a4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2737,10 +2737,10 @@ __metadata: languageName: node linkType: hard -"@neo4j/cypher-builder@npm:2.8.1": - version: 2.8.1 - resolution: "@neo4j/cypher-builder@npm:2.8.1" - checksum: 10c0/9924277b222ff1610836565145ec3a82e5294883b57580ed4d1bfd1b37b02414d46c75195796853d280243ed26ce576c151099766d66bacae37337d4f9fadcb6 +"@neo4j/cypher-builder@npm:2.10.0": + version: 2.10.0 + resolution: "@neo4j/cypher-builder@npm:2.10.0" + checksum: 10c0/7d115ae0e33fe7427d08c75edc070d20d2ee15abed8974efa5a9ec8a8f6d84d8360cc0f58f4bb5095761e7d075366c6828dbb6636237e41709b462925baa526d languageName: node linkType: hard @@ -2762,7 +2762,7 @@ __metadata: "@graphql-tools/resolvers-composition": "npm:^7.0.0" "@graphql-tools/schema": "npm:^10.0.0" "@graphql-tools/utils": "npm:10.9.1" - "@neo4j/cypher-builder": "npm:2.8.1" + "@neo4j/cypher-builder": "npm:2.10.0" "@types/deep-equal": "npm:1.0.4" "@types/is-uuid": "npm:1.0.2" "@types/jest": "npm:30.0.0"