From 748b25d1ca1c96068f3b7534b297c95573a854c8 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Wed, 10 Dec 2025 09:59:51 +0000 Subject: [PATCH 01/10] WIP support for cypher directive in relationship properties --- .../custom-rules/directives/cypher.ts | 27 ++++- .../ast/operations/ConnectionReadOperation.ts | 35 +++++- .../ast/selection/CustomCypherSelection.ts | 14 ++- .../queryAST/factory/FieldFactory.ts | 16 ++- .../queryAST/factory/OperationFactory.ts | 1 + .../factory/Operations/CustomCypherFactory.ts | 5 +- .../factory/SortAndPaginationFactory.ts | 1 + .../cypher-in-relationship-props.int.test.ts | 107 ++++++++++++++++++ 8 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 packages/graphql/tests/integration/directives/cypher/cypher-in-relationship-props.int.test.ts diff --git a/packages/graphql/src/schema/validation/custom-rules/directives/cypher.ts b/packages/graphql/src/schema/validation/custom-rules/directives/cypher.ts index 5c1ea7c88d..1b49cf2a3b 100644 --- a/packages/graphql/src/schema/validation/custom-rules/directives/cypher.ts +++ b/packages/graphql/src/schema/validation/custom-rules/directives/cypher.ts @@ -17,11 +17,12 @@ * limitations under the License. */ -import type { ASTVisitor, FieldDefinitionNode } from "graphql"; +import type { ASTNode, ASTVisitor, FieldDefinitionNode } from "graphql"; import { cypherDirective } from "../../../../graphql/directives"; -import type { Neo4jValidationContext } from "../../Neo4jValidationContext"; +import type { Neo4jValidationContext, TypeMapWithExtensions } from "../../Neo4jValidationContext"; import { assertValid, createGraphQLError, DocumentValidationError } from "../utils/document-validation-error"; import { fieldIsInNodeType } from "../utils/location-helpers/is-in-node-type"; +import { fieldIsInRelationshipPropertiesType } from "../utils/location-helpers/is-in-relationship-properties-type"; import { fieldIsInRootType } from "../utils/location-helpers/is-in-root-type"; import { fieldIsInSubscriptionType } from "../utils/location-helpers/is-in-subscription-type"; import { getPathToNode } from "../utils/path-parser"; @@ -39,10 +40,8 @@ export function validateCypherDirective(context: Neo4jValidationContext): ASTVis ) { return; } - const isValidLocation = - (fieldIsInNodeType({ path, ancestors, typeMapWithExtensions }) || - fieldIsInRootType({ path, ancestors, typeMapWithExtensions })) && - !fieldIsInSubscriptionType({ path, ancestors, typeMapWithExtensions }); + + const isValidLocation = isCypherLocationValid({ path, ancestors, typeMapWithExtensions }); const { isValid, errorMsg } = assertValid(() => { if (!isValidLocation) { @@ -66,3 +65,19 @@ export function validateCypherDirective(context: Neo4jValidationContext): ASTVis }, }; } + +function isCypherLocationValid(directiveLocationData: { + path: readonly (string | number)[]; + ancestors: readonly (ASTNode | readonly ASTNode[])[]; + typeMapWithExtensions: TypeMapWithExtensions; +}): boolean { + if (fieldIsInSubscriptionType(directiveLocationData)) { + return false; + } + + return ( + fieldIsInNodeType(directiveLocationData) || + fieldIsInRootType(directiveLocationData) || + fieldIsInRelationshipPropertiesType(directiveLocationData) + ); +} diff --git a/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts index fe292dc683..464241d6ed 100644 --- a/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts +++ b/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts @@ -474,7 +474,27 @@ export class ConnectionReadOperation extends Operation { {} ); - const preAndPostFields = this.nodeFields.reduce>( + const preAndPostFields = [...this.nodeFields].reduce>( + (acc, nodeField) => { + if ( + nodeField instanceof OperationField && + nodeField.isCypherField() && + nodeField.operation instanceof CypherAttributeOperation + ) { + const cypherFieldName = nodeField.operation.cypherAttributeField.name; + if (cypherSortFieldsFlagMap[cypherFieldName]) { + acc.Pre.push(nodeField); + return acc; + } + } + + acc.Post.push(nodeField); + return acc; + }, + { Pre: [], Post: [] } + ); + + const preAndPostEdgeFields = [...this.edgeFields].reduce>( (acc, nodeField) => { if ( nodeField instanceof OperationField && @@ -495,11 +515,20 @@ export class ConnectionReadOperation extends Operation { ); const preNodeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostFields.Pre, [context.target]); const postNodeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostFields.Post, [context.target]); + + let preEdgeSubqueries: Cypher.Clause[] = []; + let postEdgeSubqueries: Cypher.Clause[] = []; + if (context.relationship) { + preEdgeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostEdgeFields.Pre, [context.relationship]); + postEdgeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostEdgeFields.Post, [ + context.relationship, + ]); + } const sortSubqueries = wrapSubqueriesInCypherCalls(context, sortNodeFields, [context.target]); return { - prePaginationSubqueries: [...sortSubqueries, ...preNodeSubqueries], - postPaginationSubqueries: postNodeSubqueries, + prePaginationSubqueries: [...sortSubqueries, ...preNodeSubqueries, ...preEdgeSubqueries], + postPaginationSubqueries: [...postNodeSubqueries, ...postEdgeSubqueries], }; } } diff --git a/packages/graphql/src/translate/queryAST/ast/selection/CustomCypherSelection.ts b/packages/graphql/src/translate/queryAST/ast/selection/CustomCypherSelection.ts index d4315953be..765bafa476 100644 --- a/packages/graphql/src/translate/queryAST/ast/selection/CustomCypherSelection.ts +++ b/packages/graphql/src/translate/queryAST/ast/selection/CustomCypherSelection.ts @@ -33,15 +33,21 @@ export class CustomCypherSelection extends EntitySelection { private rawArguments: Record; private cypherAnnotation: CypherAnnotation; private isNested: boolean; + private targetRelationship: boolean; + /** + * @param targetRelationship - Should this selector use the relationship variable of the context as "this" target in the Cypher? (use it for edge props) + */ constructor({ operationField, rawArguments = {}, isNested, + targetRelationship = false, }: { operationField: AttributeAdapter; rawArguments: Record; isNested: boolean; + targetRelationship?: boolean; }) { super(); this.operationField = operationField; @@ -51,6 +57,7 @@ export class CustomCypherSelection extends EntitySelection { throw new Error("Missing Cypher Annotation on Cypher field"); } this.cypherAnnotation = this.operationField.annotations.cypher; + this.targetRelationship = targetRelationship; } public apply(context: QueryASTContext): { @@ -80,10 +87,11 @@ export class CustomCypherSelection extends EntitySelection { let statementSubquery: Cypher.Call; - if (this.isNested && context.target) { - const aliasTargetToPublicTarget = new Cypher.With([context.target, CYPHER_TARGET_VARIABLE]); + const nestedTarget = this.targetRelationship ? context.relationship : context.target; + if (this.isNested && nestedTarget) { + const aliasTargetToPublicTarget = new Cypher.With([nestedTarget, CYPHER_TARGET_VARIABLE]); statementSubquery = new Cypher.Call(Cypher.utils.concat(aliasTargetToPublicTarget, statementCypherQuery), [ - context.target, + nestedTarget, ]); } else { statementSubquery = new Cypher.Call(statementCypherQuery); diff --git a/packages/graphql/src/translate/queryAST/factory/FieldFactory.ts b/packages/graphql/src/translate/queryAST/factory/FieldFactory.ts index acdb55bf20..951288e863 100644 --- a/packages/graphql/src/translate/queryAST/factory/FieldFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FieldFactory.ts @@ -24,7 +24,7 @@ import type { AttributeAdapter } from "../../../schema-model/attribute/model-ada import type { EntityAdapter } from "../../../schema-model/entity/EntityAdapter"; 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 { RelationshipAdapter } from "../../../schema-model/relationship/model-adapters/RelationshipAdapter"; import { getEntityAdapter } from "../../../schema-model/utils/get-entity-adapter"; import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context"; import { deepMerge } from "../../../utils/deep-merge"; @@ -239,7 +239,9 @@ export class FieldFactory { const cypherAnnotation = attribute.annotations.cypher; if (cypherAnnotation) { + console.log("In cypher annotaton"); return this.createCypherAttributeField({ + entity, field, attribute, context, @@ -258,11 +260,13 @@ export class FieldFactory { } private createCypherAttributeField({ + entity, field, attribute, context, cypherAnnotation, }: { + entity: ConcreteEntityAdapter | RelationshipAdapter; attribute: AttributeAdapter; field: ResolveTree; context: Neo4jGraphQLTranslationContext; @@ -278,9 +282,10 @@ export class FieldFactory { // move the user specified arguments in a different object as they should be treated as arguments of a Cypher Field const cypherArguments = { ...field.args }; field.args = {}; - + const isEdge = entity instanceof RelationshipAdapter; if (rawFields) { if (attribute.typeHelper.isObject()) { + // NOTE: This entity is the cypher result, not the target node const concreteEntity = this.queryASTFactory.schemaModel.getConcreteEntityAdapter(typeName); return this.createCypherOperationField({ @@ -289,6 +294,7 @@ export class FieldFactory { context, cypherAttributeField: attribute, cypherArguments, + isEdge, }); } else if (attribute.typeHelper.isAbstract()) { const entity = this.queryASTFactory.schemaModel.getEntity(typeName); @@ -300,6 +306,7 @@ export class FieldFactory { context, cypherAttributeField: attribute, cypherArguments, + isEdge, }); } } @@ -309,6 +316,7 @@ export class FieldFactory { context, cypherAttributeField: attribute, cypherArguments, + isEdge, }); } @@ -353,19 +361,23 @@ export class FieldFactory { context, cypherAttributeField, cypherArguments, + isEdge, }: { target?: EntityAdapter; field: ResolveTree; context: Neo4jGraphQLTranslationContext; cypherAttributeField: AttributeAdapter; cypherArguments?: Record; + isEdge: boolean; }): OperationField { + console.log("Create Cypher Opeation Field", target); const cypherOp = this.queryASTFactory.operationsFactory.createCustomCypherOperation({ resolveTree: field, context, entity: target, cypherAttributeField, cypherArguments, + isEdge, }); return new OperationField({ diff --git a/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts b/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts index 1d8cb90d58..791144167f 100644 --- a/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts @@ -397,6 +397,7 @@ export class OperationsFactory { entity?: EntityAdapter; cypherAttributeField: AttributeAdapter; cypherArguments?: Record; + isEdge: boolean; }): CypherEntityOperation | CompositeCypherOperation | CypherAttributeOperation { return this.customCypherFactory.createCustomCypherOperation(arg); } diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts index 08897ab1b7..66141aaa0f 100644 --- a/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts @@ -44,17 +44,20 @@ export class CustomCypherFactory { entity, cypherAttributeField, cypherArguments = {}, + isEdge, }: { resolveTree?: ResolveTree; context: Neo4jGraphQLTranslationContext; - entity?: EntityAdapter; + entity?: EntityAdapter; // This is the target type in the cypher response, if it is a node cypherAttributeField: AttributeAdapter; cypherArguments?: Record; + isEdge: boolean; }): CypherEntityOperation | CompositeCypherOperation | CypherAttributeOperation { const selection = new CustomCypherSelection({ operationField: cypherAttributeField, rawArguments: cypherArguments, isNested: true, + targetRelationship: isEdge, }); if (!entity) { return new CypherAttributeOperation(selection, cypherAttributeField, true); diff --git a/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts b/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts index 186687c724..b53931b431 100644 --- a/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts @@ -153,6 +153,7 @@ export class SortAndPaginationFactory { const cypherOperation = this.queryASTFactory.operationsFactory.createCustomCypherOperation({ context, cypherAttributeField: attribute, + isEdge: entity instanceof RelationshipAdapter, }); if (!(cypherOperation instanceof CypherAttributeOperation)) { throw new Error("Transpile error: sorting is supported only for @cypher scalar properties"); diff --git a/packages/graphql/tests/integration/directives/cypher/cypher-in-relationship-props.int.test.ts b/packages/graphql/tests/integration/directives/cypher/cypher-in-relationship-props.int.test.ts new file mode 100644 index 0000000000..a433155149 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/cypher-in-relationship-props.int.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../utils/graphql-types"; +import { TestHelper } from "../../../utils/tests-helper"; + +describe("cypher directive in relationship properties", () => { + const testHelper = new TestHelper(); + + let Movie: UniqueType; + let Actor: UniqueType; + + beforeEach(async () => { + Movie = testHelper.createUniqueType("Movie"); + Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String! + actors: [${Person}!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + type ${Person} @node { + name: String! + actedIn: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type ActedIn @relationshipProperties { + screenTime: Int + @cypher( + statement: """ + RETURN 10 as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("should query custom query and return relationship properties", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection { + edges { + properties { + screenTime + } + node { + name + } + } + } + } + } + `; + + await testHelper.executeCypher(`CREATE(:Movie {title: "Da Matrix"})<-[:ACTED_IN]-(:Person {name: "Keanu"})`); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "Da Matrix", + actorsConnection: { + edges: [ + { + properties: { + screenTime: 10, + }, + node: { + name: "Keanu", + }, + }, + ], + }, + }, + ], + }); + }); +}); From 3ea0d4bca1c6ecc0ee9b83991470a3cf19834920 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Wed, 10 Dec 2025 11:02:28 +0000 Subject: [PATCH 02/10] filter on cypher fields in relationship properties --- .../ast/operations/ConnectionReadOperation.ts | 4 +- .../ast/selection/CustomCypherSelection.ts | 10 +- .../queryAST/factory/FieldFactory.ts | 4 +- .../queryAST/factory/FilterFactory.ts | 4 + .../factory/Operations/CustomCypherFactory.ts | 2 +- .../cypher-in-relationship-props.int.test.ts | 107 ---------- .../cypher-in-relationship-props.int.test.ts | 201 ++++++++++++++++++ 7 files changed, 214 insertions(+), 118 deletions(-) delete mode 100644 packages/graphql/tests/integration/directives/cypher/cypher-in-relationship-props.int.test.ts create mode 100644 packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts diff --git a/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts index 464241d6ed..8399fb4688 100644 --- a/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts +++ b/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts @@ -170,11 +170,11 @@ export class ConnectionReadOperation extends Operation { } const authFilterSubqueries = this.getAuthFilterSubqueries(nestedContext).map((sq) => { - return new Cypher.Call(sq, [nestedContext.target]); + return new Cypher.Call(sq, filterTruthy([nestedContext.target, nestedContext.relationship])); }); const normalFilterSubqueries = this.getFilterSubqueries(nestedContext).map((sq) => { - return new Cypher.Call(sq, [nestedContext.target]); + return new Cypher.Call(sq, filterTruthy([nestedContext.target, nestedContext.relationship])); }); const filtersSubqueries = [...authFilterSubqueries, ...normalFilterSubqueries]; diff --git a/packages/graphql/src/translate/queryAST/ast/selection/CustomCypherSelection.ts b/packages/graphql/src/translate/queryAST/ast/selection/CustomCypherSelection.ts index 765bafa476..c84d85f6b7 100644 --- a/packages/graphql/src/translate/queryAST/ast/selection/CustomCypherSelection.ts +++ b/packages/graphql/src/translate/queryAST/ast/selection/CustomCypherSelection.ts @@ -33,7 +33,7 @@ export class CustomCypherSelection extends EntitySelection { private rawArguments: Record; private cypherAnnotation: CypherAnnotation; private isNested: boolean; - private targetRelationship: boolean; + private attachedTo: "node" | "relationship"; /** * @param targetRelationship - Should this selector use the relationship variable of the context as "this" target in the Cypher? (use it for edge props) @@ -42,12 +42,12 @@ export class CustomCypherSelection extends EntitySelection { operationField, rawArguments = {}, isNested, - targetRelationship = false, + attachedTo = "node", }: { operationField: AttributeAdapter; rawArguments: Record; isNested: boolean; - targetRelationship?: boolean; + attachedTo?: "node" | "relationship"; }) { super(); this.operationField = operationField; @@ -57,7 +57,7 @@ export class CustomCypherSelection extends EntitySelection { throw new Error("Missing Cypher Annotation on Cypher field"); } this.cypherAnnotation = this.operationField.annotations.cypher; - this.targetRelationship = targetRelationship; + this.attachedTo = attachedTo; } public apply(context: QueryASTContext): { @@ -87,7 +87,7 @@ export class CustomCypherSelection extends EntitySelection { let statementSubquery: Cypher.Call; - const nestedTarget = this.targetRelationship ? context.relationship : context.target; + const nestedTarget = this.attachedTo === "relationship" ? context.relationship : context.target; if (this.isNested && nestedTarget) { const aliasTargetToPublicTarget = new Cypher.With([nestedTarget, CYPHER_TARGET_VARIABLE]); statementSubquery = new Cypher.Call(Cypher.utils.concat(aliasTargetToPublicTarget, statementCypherQuery), [ diff --git a/packages/graphql/src/translate/queryAST/factory/FieldFactory.ts b/packages/graphql/src/translate/queryAST/factory/FieldFactory.ts index 951288e863..1b2e0bedb5 100644 --- a/packages/graphql/src/translate/queryAST/factory/FieldFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FieldFactory.ts @@ -239,7 +239,6 @@ export class FieldFactory { const cypherAnnotation = attribute.annotations.cypher; if (cypherAnnotation) { - console.log("In cypher annotaton"); return this.createCypherAttributeField({ entity, field, @@ -285,7 +284,7 @@ export class FieldFactory { const isEdge = entity instanceof RelationshipAdapter; if (rawFields) { if (attribute.typeHelper.isObject()) { - // NOTE: This entity is the cypher result, not the target node + // NOTE: This entity is the cypher result type (if an entity), not the target node. Naming may be confusing const concreteEntity = this.queryASTFactory.schemaModel.getConcreteEntityAdapter(typeName); return this.createCypherOperationField({ @@ -370,7 +369,6 @@ export class FieldFactory { cypherArguments?: Record; isEdge: boolean; }): OperationField { - console.log("Create Cypher Opeation Field", target); const cypherOp = this.queryASTFactory.operationsFactory.createCustomCypherOperation({ resolveTree: field, context, diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index ee0b6df684..787eefcc79 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -181,16 +181,19 @@ export class FilterFactory { comparisonValue, operator, caseInsensitive, + attachedTo, }: { attribute: AttributeAdapter; comparisonValue: GraphQLWhereArg; operator: FilterOperator | undefined; caseInsensitive?: boolean; + attachedTo?: "node" | "relationship"; }): Filter | Filter[] { const selection = new CustomCypherSelection({ operationField: attribute, rawArguments: {}, isNested: true, + attachedTo, }); if (attribute.annotations.cypher?.targetEntity) { @@ -255,6 +258,7 @@ export class FilterFactory { comparisonValue, operator, caseInsensitive, + attachedTo, }); } // Implicit _EQ filters are removed but the argument "operator" can still be undefined in some cases, for instance: diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts index 66141aaa0f..1e5755f333 100644 --- a/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts @@ -57,7 +57,7 @@ export class CustomCypherFactory { operationField: cypherAttributeField, rawArguments: cypherArguments, isNested: true, - targetRelationship: isEdge, + attachedTo: "relationship", }); if (!entity) { return new CypherAttributeOperation(selection, cypherAttributeField, true); diff --git a/packages/graphql/tests/integration/directives/cypher/cypher-in-relationship-props.int.test.ts b/packages/graphql/tests/integration/directives/cypher/cypher-in-relationship-props.int.test.ts deleted file mode 100644 index a433155149..0000000000 --- a/packages/graphql/tests/integration/directives/cypher/cypher-in-relationship-props.int.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) "Neo4j" - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { UniqueType } from "../../../utils/graphql-types"; -import { TestHelper } from "../../../utils/tests-helper"; - -describe("cypher directive in relationship properties", () => { - const testHelper = new TestHelper(); - - let Movie: UniqueType; - let Actor: UniqueType; - - beforeEach(async () => { - Movie = testHelper.createUniqueType("Movie"); - Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = /* GraphQL */ ` - type ${Movie} @node { - title: String! - actors: [${Person}!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") - } - - type ${Person} @node { - name: String! - actedIn: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") - } - - type ActedIn @relationshipProperties { - screenTime: Int - @cypher( - statement: """ - RETURN 10 as c - """ - columnName: "c" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - }); - - afterEach(async () => { - await testHelper.close(); - }); - - test("should query custom query and return relationship properties", async () => { - const source = /* GraphQL */ ` - query { - ${Movie.plural} { - title - actorsConnection { - edges { - properties { - screenTime - } - node { - name - } - } - } - } - } - `; - - await testHelper.executeCypher(`CREATE(:Movie {title: "Da Matrix"})<-[:ACTED_IN]-(:Person {name: "Keanu"})`); - - const gqlResult = await testHelper.executeGraphQL(source); - - expect(gqlResult.errors).toBeFalsy(); - - expect(gqlResult.data).toEqual({ - [Movie.plural]: [ - { - title: "Da Matrix", - actorsConnection: { - edges: [ - { - properties: { - screenTime: 10, - }, - node: { - name: "Keanu", - }, - }, - ], - }, - }, - ], - }); - }); -}); diff --git a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts new file mode 100644 index 0000000000..7fda7e86e1 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../../utils/graphql-types"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive in relationship properties", () => { + const testHelper = new TestHelper(); + + let Movie: UniqueType; + let Actor: UniqueType; + + beforeEach(async () => { + Movie = testHelper.createUniqueType("Movie"); + Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String! + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + type ${Actor} @node { + name: String! + actedIn: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type ActedIn @relationshipProperties { + screenTimeHours: Float + @cypher( + statement: """ + RETURN this.screenTimeMinutes / 60 AS c + """ + columnName: "c" + ) + screenTimeMinutes: Int + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("should query custom query and return relationship properties", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection { + edges { + properties { + screenTimeHours + } + node { + name + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(:${Movie} {title: "The Matrix"})<-[:ACTED_IN {screenTimeMinutes: 120}]-(:${Actor} {name: "Keanu"})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + properties: { + screenTimeHours: 2.0, + }, + node: { + name: "Keanu", + }, + }, + ], + }, + }, + ], + }); + }); + + test("filter by relationship @cypher property without projection", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection(where: {edge: {screenTimeHours: {gt: 1.0}}}) { + edges { + node { + name + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(m:${Movie} {title: "The Matrix"})<-[:ACTED_IN {screenTimeMinutes: 120}]-(:${Actor} {name: "Main actor"}) + CREATE(m)<-[:ACTED_IN {screenTimeMinutes: 80}]-(:${Actor} {name: "Second actor"})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + node: { + name: "Main actor", + }, + }, + ], + }, + }, + ], + }); + }); + test("filter by relationship @cypher property with projection", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection(where: {edge: {screenTimeHours: {gt: 1.0}}}) { + edges { + node { + name + } + properties { + screenTimeHours + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(m:${Movie} {title: "The Matrix"})<-[:ACTED_IN {screenTimeMinutes: 120}]-(:${Actor} {name: "Main actor"}) + CREATE(m)<-[:ACTED_IN {screenTimeMinutes: 80}]-(:${Actor} {name: "Second actor"})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + node: { + name: "Main actor", + }, + properties: { + screenTimeHours: 2.0, + }, + }, + ], + }, + }, + ], + }); + }); +}); From 6c326ad2c00e1c1e8f8c0d17ac6991c84f7b4167 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Wed, 10 Dec 2025 11:23:04 +0000 Subject: [PATCH 03/10] WIP tests on cypher directive --- .../queryAST/ast/sort/CypherPropertySort.ts | 2 +- .../factory/Operations/CustomCypherFactory.ts | 2 +- .../directives/cypher/cyher-sort.int.test.ts | 150 +++++++++++++++++ .../cypher}/cypher-params.int.test.ts | 4 +- .../cypher-in-relationship-props.int.test.ts | 1 + .../cypher-in-relationship-sort.int.test.ts | 158 ++++++++++++++++++ 6 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 packages/graphql/tests/integration/directives/cypher/cyher-sort.int.test.ts rename packages/graphql/tests/integration/{ => directives/cypher}/cypher-params.int.test.ts (97%) create mode 100644 packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-sort.int.test.ts diff --git a/packages/graphql/src/translate/queryAST/ast/sort/CypherPropertySort.ts b/packages/graphql/src/translate/queryAST/ast/sort/CypherPropertySort.ts index 112e44edb5..e3da091bb6 100644 --- a/packages/graphql/src/translate/queryAST/ast/sort/CypherPropertySort.ts +++ b/packages/graphql/src/translate/queryAST/ast/sort/CypherPropertySort.ts @@ -46,7 +46,7 @@ export class CypherPropertySort extends Sort { } public getChildren(): QueryASTNode[] { - return []; + return [this.cypherOperation]; } public print(): string { diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts index 1e5755f333..8d871699f6 100644 --- a/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/Operations/CustomCypherFactory.ts @@ -57,7 +57,7 @@ export class CustomCypherFactory { operationField: cypherAttributeField, rawArguments: cypherArguments, isNested: true, - attachedTo: "relationship", + attachedTo: isEdge ? "relationship" : "node", }); if (!entity) { return new CypherAttributeOperation(selection, cypherAttributeField, true); diff --git a/packages/graphql/tests/integration/directives/cypher/cyher-sort.int.test.ts b/packages/graphql/tests/integration/directives/cypher/cyher-sort.int.test.ts new file mode 100644 index 0000000000..02d3d644ef --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/cyher-sort.int.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../utils/graphql-types"; +import { TestHelper } from "../../../utils/tests-helper"; + +describe("cypher directive sort", () => { + const testHelper = new TestHelper(); + + let Movie: UniqueType; + let Actor: UniqueType; + + beforeEach(async () => { + Movie = testHelper.createUniqueType("Movie"); + Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String! + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String! + actedIn: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + ranking: Int! @cypher(statement: """ + RETURN this.rank as ranking + """, columnName: "ranking") + } + + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("order nested relationship by relationship properties DESC", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection(sort: {node: {ranking: DESC}}) { + edges { + node { + name + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(m:${Movie} {title: "The Matrix"})<-[:ACTED_IN]-(:${Actor} {name: "Main actor", rank: 1}) + CREATE(m)<-[:ACTED_IN]-(:${Actor} {name: "Second actor", rank: 2})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + node: { + name: "Second actor", + }, + }, + { + node: { + name: "Main actor", + }, + }, + ], + }, + }, + ], + }); + }); + test("order nested relationship by relationship properties ASC", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection(sort: {node: {ranking: ASC}}) { + edges { + node { + name + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(m:${Movie} {title: "The Matrix"})<-[:ACTED_IN]-(:${Actor} {name: "Main actor", rank: 1}) + CREATE(m)<-[:ACTED_IN]-(:${Actor} {name: "Second actor", rank: 2})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + node: { + name: "Main actor", + }, + }, + { + node: { + name: "Second actor", + }, + }, + ], + }, + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/cypher-params.int.test.ts b/packages/graphql/tests/integration/directives/cypher/cypher-params.int.test.ts similarity index 97% rename from packages/graphql/tests/integration/cypher-params.int.test.ts rename to packages/graphql/tests/integration/directives/cypher/cypher-params.int.test.ts index 639f86ffdc..3b06d68c22 100644 --- a/packages/graphql/tests/integration/cypher-params.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/cypher-params.int.test.ts @@ -18,8 +18,8 @@ */ import { generate } from "randomstring"; -import type { UniqueType } from "../utils/graphql-types"; -import { TestHelper } from "../utils/tests-helper"; +import type { UniqueType } from "../../../utils/graphql-types"; +import { TestHelper } from "../../../utils/tests-helper"; describe("cypherParams", () => { const testHelper = new TestHelper(); diff --git a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts index 7fda7e86e1..2dd6f81497 100644 --- a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts @@ -150,6 +150,7 @@ describe("cypher directive in relationship properties", () => { ], }); }); + test("filter by relationship @cypher property with projection", async () => { const source = /* GraphQL */ ` query { diff --git a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-sort.int.test.ts b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-sort.int.test.ts new file mode 100644 index 0000000000..378413b44e --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-sort.int.test.ts @@ -0,0 +1,158 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../../utils/graphql-types"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive in relationship properties", () => { + const testHelper = new TestHelper(); + + let Movie: UniqueType; + let Actor: UniqueType; + + beforeEach(async () => { + Movie = testHelper.createUniqueType("Movie"); + Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String! + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + type ${Actor} @node { + name: String! + actedIn: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type ActedIn @relationshipProperties { + screenTimeHours: Float + @cypher( + statement: """ + RETURN this.screenTimeMinutes / 60 AS c + """ + columnName: "c" + ) + screenTimeMinutes: Int + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("order nested relationship by relationship properties DESC", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection(sort: {edge: {screenTimeHours: DESC}}) { + edges { + node { + name + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(m:${Movie} {title: "The Matrix"})<-[:ACTED_IN {screenTimeMinutes: 120}]-(:${Actor} {name: "Main actor"}) + CREATE(m)<-[:ACTED_IN {screenTimeMinutes: 80}]-(:${Actor} {name: "Second actor"})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + node: { + name: "Main actor", + }, + }, + { + node: { + name: "Second actor", + }, + }, + ], + }, + }, + ], + }); + }); + + test.only("order nested relationship by relationship properties ASC", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection(sort: {edge: {screenTimeHours: ASC}}) { + edges { + node { + name + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(m:${Movie} {title: "The Matrix"})<-[:ACTED_IN {screenTimeMinutes: 120}]-(:${Actor} {name: "Main actor"}) + CREATE(m)<-[:ACTED_IN {screenTimeMinutes: 80}]-(:${Actor} {name: "Second actor"})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + node: { + name: "Second actor", + }, + }, + { + node: { + name: "Main actor", + }, + }, + ], + }, + }, + ], + }); + }); +}); From 60953fed1e0cd669003ec130ccb485f4bb4f3a16 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Wed, 10 Dec 2025 13:35:02 +0000 Subject: [PATCH 04/10] Fix sorting for relationship properties with cypher directive --- .../ast/operations/ConnectionReadOperation.ts | 35 ++++++++++++++----- .../factory/SortAndPaginationFactory.ts | 4 +-- .../cypher-in-relationship-sort.int.test.ts | 2 +- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts index 8399fb4688..eed5ce748a 100644 --- a/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts +++ b/packages/graphql/src/translate/queryAST/ast/operations/ConnectionReadOperation.ts @@ -460,6 +460,7 @@ export class ConnectionReadOperation extends Operation { throw new Error("No parent node found!"); } const sortNodeFields = this.sortFields.flatMap((sf) => sf.node); + const sortEdgeFields = this.sortFields.flatMap((sf) => sf.edge); /** * cypherSortFieldsFlagMap is a Record that holds the name of the sort field as key * and a boolean flag defined as true when the field is a `@cypher` field. @@ -473,6 +474,15 @@ export class ConnectionReadOperation extends Operation { }, {} ); + const cypherSortFieldsEdgeFlagMap = sortEdgeFields.reduce>( + (sortFieldsFlagMap, sortField) => { + if (sortField instanceof CypherPropertySort) { + sortFieldsFlagMap[sortField.getFieldName()] = true; + } + return sortFieldsFlagMap; + }, + {} + ); const preAndPostFields = [...this.nodeFields].reduce>( (acc, nodeField) => { @@ -495,20 +505,20 @@ export class ConnectionReadOperation extends Operation { ); const preAndPostEdgeFields = [...this.edgeFields].reduce>( - (acc, nodeField) => { + (acc, edgeField) => { if ( - nodeField instanceof OperationField && - nodeField.isCypherField() && - nodeField.operation instanceof CypherAttributeOperation + edgeField instanceof OperationField && + edgeField.isCypherField() && + edgeField.operation instanceof CypherAttributeOperation ) { - const cypherFieldName = nodeField.operation.cypherAttributeField.name; - if (cypherSortFieldsFlagMap[cypherFieldName]) { - acc.Pre.push(nodeField); + const cypherFieldName = edgeField.operation.cypherAttributeField.name; + if (cypherSortFieldsEdgeFlagMap[cypherFieldName]) { + acc.Pre.push(edgeField); return acc; } } - acc.Post.push(nodeField); + acc.Post.push(edgeField); return acc; }, { Pre: [], Post: [] } @@ -518,16 +528,23 @@ export class ConnectionReadOperation extends Operation { let preEdgeSubqueries: Cypher.Clause[] = []; let postEdgeSubqueries: Cypher.Clause[] = []; + let sortEdgeSubqueries: Cypher.Clause[] = []; if (context.relationship) { preEdgeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostEdgeFields.Pre, [context.relationship]); postEdgeSubqueries = wrapSubqueriesInCypherCalls(context, preAndPostEdgeFields.Post, [ context.relationship, ]); + sortEdgeSubqueries = wrapSubqueriesInCypherCalls(context, sortEdgeFields, [context.relationship]); } const sortSubqueries = wrapSubqueriesInCypherCalls(context, sortNodeFields, [context.target]); return { - prePaginationSubqueries: [...sortSubqueries, ...preNodeSubqueries, ...preEdgeSubqueries], + prePaginationSubqueries: [ + ...sortSubqueries, + ...sortEdgeSubqueries, + ...preNodeSubqueries, + ...preEdgeSubqueries, + ], postPaginationSubqueries: [...postNodeSubqueries, ...postEdgeSubqueries], }; } diff --git a/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts b/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts index b53931b431..88af7e3310 100644 --- a/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts @@ -34,7 +34,6 @@ import { CypherPropertySort } from "../ast/sort/CypherPropertySort"; import { PropertySort } from "../ast/sort/PropertySort"; import { ScoreSort } from "../ast/sort/ScoreSort"; import type { Sort } from "../ast/sort/Sort"; -import { isConcreteEntity } from "../utils/is-concrete-entity"; import { isRelationshipEntity } from "../utils/is-relationship-entity"; import { isUnionEntity } from "../utils/is-union-entity"; import type { QueryASTFactory } from "./QueryASTFactory"; @@ -149,7 +148,8 @@ export class SortAndPaginationFactory { if (!attribute) { throw new Error(`no filter attribute ${fieldName}`); } - if (attribute.annotations.cypher && isConcreteEntity(entity)) { + + if (attribute.annotations.cypher) { const cypherOperation = this.queryASTFactory.operationsFactory.createCustomCypherOperation({ context, cypherAttributeField: attribute, diff --git a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-sort.int.test.ts b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-sort.int.test.ts index 378413b44e..9107da7e56 100644 --- a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-sort.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-sort.int.test.ts @@ -108,7 +108,7 @@ describe("cypher directive in relationship properties", () => { }); }); - test.only("order nested relationship by relationship properties ASC", async () => { + test("order nested relationship by relationship properties ASC", async () => { const source = /* GraphQL */ ` query { ${Movie.plural} { From cf0a101026d7c3dbce26bc87734186856e2e3248 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Thu, 11 Dec 2025 15:47:19 +0000 Subject: [PATCH 05/10] Fix cypher filter on top level --- .../queryAST/ast/filters/ConnectionFilter.ts | 4 +- .../cypher-in-relationship-props.int.test.ts | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/graphql/src/translate/queryAST/ast/filters/ConnectionFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/ConnectionFilter.ts index d6b9e6eff0..bea570a959 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/ConnectionFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/ConnectionFilter.ts @@ -21,6 +21,7 @@ import Cypher from "@neo4j/cypher-builder"; import type { ConcreteEntityAdapter } from "../../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import type { InterfaceEntityAdapter } from "../../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; import type { RelationshipAdapter } from "../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import { filterTruthy } from "../../../../utils/utils"; import { hasTarget } from "../../utils/context-has-target"; import { getEntityLabels } from "../../utils/create-node-from-entity"; import { isConcreteEntity } from "../../utils/is-concrete-entity"; @@ -206,7 +207,7 @@ export class ConnectionFilter extends Filter { const subqueries = this.innerFilters.flatMap((f) => { const nestedSubqueries = f .getSubqueries(queryASTContext) - .map((sq) => new Cypher.Call(sq, [queryASTContext.target])); + .map((sq) => new Cypher.Call(sq, filterTruthy([queryASTContext.target, queryASTContext.relationship]))); const selection = f.getSelection(queryASTContext); const predicate = f.getPredicate(queryASTContext); const clauses = [...selection, ...nestedSubqueries]; @@ -217,7 +218,6 @@ export class ConnectionFilter extends Filter { return clauses; }); - if (subqueries.length === 0) return []; // Hack logic to change predicates logic const comparisonValue = this.operator === "NONE" ? Cypher.false : Cypher.true; diff --git a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts index 2dd6f81497..7e0848f665 100644 --- a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-props.int.test.ts @@ -199,4 +199,53 @@ describe("cypher directive in relationship properties", () => { ], }); }); + + test("filter nested relationship by @cypher property with projection", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural}(where: {actorsConnection: {some: {edge: {screenTimeHours: {gt: 1.5}}}}}) { + title + actorsConnection { + edges { + node { + name + } + properties { + screenTimeHours + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(m:${Movie} {title: "The Matrix"})<-[:ACTED_IN {screenTimeMinutes: 120}]-(:${Actor} {name: "Main actor"}) + CREATE(:${Movie} {title: "The Matrix Reloaded"})<-[:ACTED_IN {screenTimeMinutes: 80}]-(:${Actor} {name: "Second actor"})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + node: { + name: "Main actor", + }, + properties: { + screenTimeHours: 2.0, + }, + }, + ], + }, + }, + ], + }); + }); }); From 196e1211b3a55fa5a5226a783707d90d330802d1 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Wed, 17 Dec 2025 12:03:33 +0100 Subject: [PATCH 06/10] Fix cypher in relationship interfaces --- .../validation/validate-document.test.ts | 85 ------------ .../composite/CompositeConnectionPartial.ts | 8 ++ ...pher-in-relationship-interface.int.test.ts | 121 ++++++++++++++++++ 3 files changed, 129 insertions(+), 85 deletions(-) create mode 100644 packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-interface.int.test.ts diff --git a/packages/graphql/src/schema/validation/validate-document.test.ts b/packages/graphql/src/schema/validation/validate-document.test.ts index f70f3f635d..47c3ef3d97 100644 --- a/packages/graphql/src/schema/validation/validate-document.test.ts +++ b/packages/graphql/src/schema/validation/validate-document.test.ts @@ -5240,46 +5240,6 @@ describe("validation 2.0", () => { expect(errors[0]).toHaveProperty("path", ["ActedIn", "actors", "@relationship"]); }); - test("should throw error if @cypher is used on relationship property", () => { - const relationshipProperties = gql` - type ActedIn @relationshipProperties { - id: ID @cypher(statement: "RETURN id(this) as id", columnName: "id") - roles: [String!] - } - `; - const doc = gql` - ${relationshipProperties} - type Movie @node { - actors: [Actor!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") - } - - type Actor @node { - name: String - } - `; - - const enums = [] as EnumTypeDefinitionNode[]; - const interfaces = [] as InterfaceTypeDefinitionNode[]; - const unions = [] as UnionTypeDefinitionNode[]; - const objects = relationshipProperties.definitions as ObjectTypeDefinitionNode[]; - const executeValidate = () => - validateDocument({ - document: doc, - additionalDefinitions: { enums, interfaces, unions, objects }, - features: {}, - }); - - const errors = getError(executeValidate); - - expect(errors).toHaveLength(1); - expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError); - expect(errors[0]).toHaveProperty( - "message", - 'Directive "cypher" must be in a type with "@node" or on root types: Query, and Mutation' - ); - expect(errors[0]).toHaveProperty("path", ["ActedIn", "id", "@cypher"]); - }); - test("@relationshipProperties reserved field name", () => { const relationshipProperties = gql` type HasPost @relationshipProperties { @@ -5318,51 +5278,6 @@ describe("validation 2.0", () => { ); expect(errors[0]).toHaveProperty("path", ["HasPost", "cursor"]); }); - - test("@cypher forbidden on @relationshipProperties field", () => { - const relationshipProperties = gql` - type HasPost @relationshipProperties { - review: Float - @cypher( - statement: """ - WITH 2 as x RETURN x - """ - columnName: "x" - ) - } - `; - const doc = gql` - ${relationshipProperties} - type User @node { - name: String - posts: [Post!]! @relationship(type: "HAS_POST", direction: OUT, properties: "HasPost") - } - type Post @node { - title: String - } - `; - - const enums = [] as EnumTypeDefinitionNode[]; - const interfaces = [] as InterfaceTypeDefinitionNode[]; - const unions = [] as UnionTypeDefinitionNode[]; - const objects = relationshipProperties.definitions as ObjectTypeDefinitionNode[]; - const executeValidate = () => - validateDocument({ - document: doc, - additionalDefinitions: { enums, interfaces, unions, objects }, - features: {}, - }); - - const errors = getError(executeValidate); - - expect(errors).toHaveLength(1); - expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError); - expect(errors[0]).toHaveProperty( - "message", - 'Directive "cypher" must be in a type with "@node" or on root types: Query, and Mutation' - ); - expect(errors[0]).toHaveProperty("path", ["HasPost", "review", "@cypher"]); - }); }); describe("valid", () => { diff --git a/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeConnectionPartial.ts b/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeConnectionPartial.ts index 2f0216d46d..3152a3a8e8 100644 --- a/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeConnectionPartial.ts +++ b/packages/graphql/src/translate/queryAST/ast/operations/composite/CompositeConnectionPartial.ts @@ -57,6 +57,13 @@ export class CompositeConnectionPartial extends ConnectionReadOperation { const nodeProjectionSubqueries = wrapSubqueriesInCypherCalls(nestedContext, this.nodeFields, [ nestedContext.target, ]); + + let edgeProjectionSubqueries: Array = []; + if (nestedContext.relationship) { + edgeProjectionSubqueries = wrapSubqueriesInCypherCalls(nestedContext, this.edgeFields, [ + nestedContext.relationship, + ]); + } const nodeProjectionMap = new Cypher.Map(); // This bit is different than normal connection ops @@ -131,6 +138,7 @@ export class CompositeConnectionPartial extends ConnectionReadOperation { withWhere, ...validations, ...nodeProjectionSubqueries, + ...edgeProjectionSubqueries, projectionClauses ); diff --git a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-interface.int.test.ts b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-interface.int.test.ts new file mode 100644 index 0000000000..c237f43b66 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-interface.int.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../../utils/graphql-types"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive in relationship properties with interfaces", () => { + const testHelper = new TestHelper(); + + let Movie: UniqueType; + let Actor: UniqueType; + let Director: UniqueType; + + beforeEach(async () => { + Movie = testHelper.createUniqueType("Movie"); + Actor = testHelper.createUniqueType("Actor"); + Director = testHelper.createUniqueType("Director"); + + const typeDefs = /* GraphQL */ ` + interface Production { + title: String! + actors: [Person!]! @declareRelationship + } + + type ${Movie} implements Production @node { + title: String! + actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + interface Person { + name: String! + } + + type ${Actor} implements Person @node { + name: String! + actedIn: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type ${Director} implements Person @node { + name: String! + } + + type ActedIn @relationshipProperties { + screenTimeHours: Float + @cypher( + statement: """ + RETURN this.screenTimeMinutes / 60 AS c + """ + columnName: "c" + ) + screenTimeMinutes: Int + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("custom properties on a interface relationship", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection { + edges { + properties { + ... on ActedIn { + screenTimeHours + } + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(:${Movie} {title: "The Matrix"})<-[:ACTED_IN {screenTimeMinutes: 120}]-(:${Actor} {name: "Keanu"})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + properties: { + screenTimeHours: 2.0, + }, + }, + ], + }, + }, + ], + }); + }); +}); From f680c5202cb9953aa5c9c53d9c3dbe9918b3ad97 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Thu, 18 Dec 2025 09:59:28 +0100 Subject: [PATCH 07/10] Add tests for unions --- ...pher-in-relationship-interface.int.test.ts | 7 + .../cypher-in-relationship-union.int.test.ts | 123 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-union.int.test.ts diff --git a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-interface.int.test.ts b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-interface.int.test.ts index c237f43b66..efa9e7c0a9 100644 --- a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-interface.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-interface.int.test.ts @@ -26,11 +26,13 @@ describe("cypher directive in relationship properties with interfaces", () => { let Movie: UniqueType; let Actor: UniqueType; let Director: UniqueType; + let Series: UniqueType; beforeEach(async () => { Movie = testHelper.createUniqueType("Movie"); Actor = testHelper.createUniqueType("Actor"); Director = testHelper.createUniqueType("Director"); + Series = testHelper.createUniqueType("Series"); const typeDefs = /* GraphQL */ ` interface Production { @@ -43,6 +45,11 @@ describe("cypher directive in relationship properties with interfaces", () => { actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") } + type ${Series} implements Production @node { + title: String! + actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + interface Person { name: String! } diff --git a/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-union.int.test.ts b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-union.int.test.ts new file mode 100644 index 0000000000..a23254b778 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/relationship-properties/cypher-in-relationship-union.int.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../../utils/graphql-types"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive in relationship properties with unions", () => { + const testHelper = new TestHelper(); + + let Movie: UniqueType; + let Actor: UniqueType; + let Director: UniqueType; + let Series: UniqueType; + + beforeEach(async () => { + Movie = testHelper.createUniqueType("Movie"); + Actor = testHelper.createUniqueType("Actor"); + Director = testHelper.createUniqueType("Director"); + Series = testHelper.createUniqueType("Series"); + + const typeDefs = /* GraphQL */ ` + union Production = ${Movie} | ${Series} + + type ${Movie} @node { + title: String! + actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + type ${Series} @node { + title: String! + actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + union Person = ${Actor} | ${Director} + + type ${Actor} @node { + name: String! + actedIn: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type ${Director} @node { + name: String! + } + + type ActedIn @relationshipProperties { + screenTimeHours: Float + @cypher( + statement: """ + RETURN this.screenTimeMinutes / 60 AS c + """ + columnName: "c" + ) + screenTimeMinutes: Int + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("custom properties on a union relationship", async () => { + const source = /* GraphQL */ ` + query { + ${Movie.plural} { + title + actorsConnection { + edges { + properties { + ... on ActedIn { + screenTimeHours + } + } + } + } + } + } + `; + + await testHelper.executeCypher( + `CREATE(:${Movie} {title: "The Matrix"})<-[:ACTED_IN {screenTimeMinutes: 120}]-(:${Actor} {name: "Keanu"})` + ); + + const gqlResult = await testHelper.executeGraphQL(source); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actorsConnection: { + edges: [ + { + properties: { + screenTimeHours: 2.0, + }, + }, + ], + }, + }, + ], + }); + }); +}); From 9a0f7dbec62f8b66e5fc4b5a82733a4ae4084921 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Thu, 18 Dec 2025 12:53:55 +0100 Subject: [PATCH 08/10] Update tck tests --- .../graphql/tests/tck/issues/2670.test.ts | 34 +++++++++---------- .../graphql/tests/tck/issues/2803.test.ts | 6 ++-- .../graphql/tests/tck/issues/6005.test.ts | 2 +- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/graphql/tests/tck/issues/2670.test.ts b/packages/graphql/tests/tck/issues/2670.test.ts index de3210fc3f..1c1616fff2 100644 --- a/packages/graphql/tests/tck/issues/2670.test.ts +++ b/packages/graphql/tests/tck/issues/2670.test.ts @@ -67,7 +67,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) = $param0 AS var4 } @@ -106,7 +106,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) < $param0 AS var4 } @@ -145,7 +145,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) > $param0 AS var4 } @@ -190,7 +190,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN min(size(this3.title)) = $param0 AS var4 } @@ -235,7 +235,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN avg(size(this3.title)) = $param0 AS var4 } @@ -277,7 +277,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN max(this2.intValue) < $param0 AS var4 } @@ -322,7 +322,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN min(this2.intValue) = $param0 AS var4 } @@ -361,7 +361,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) = $param0 AS var4 } @@ -400,7 +400,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) = $param0 AS var4 } @@ -492,7 +492,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) = $param0 AS var4 } @@ -542,11 +542,11 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) = $param0 AS var4 } - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this5:IN_GENRE]-(this6:Series) RETURN min(size(this6.name)) = $param1 AS var7 } @@ -600,11 +600,11 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) = $param0 AS var4 } - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this5:IN_GENRE]-(this6:Series) RETURN min(size(this6.name)) = $param1 AS var7 } @@ -658,11 +658,11 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) = $param0 AS var4 } - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this5:IN_GENRE]-(this6:Series) RETURN min(size(this6.name)) = $param1 AS var7 } @@ -710,7 +710,7 @@ describe("https://github.com/neo4j/graphql/issues/2670", () => { MATCH (this:Movie) CALL (this) { MATCH (this)-[this0:IN_GENRE]->(this1:Genre) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:IN_GENRE]-(this3:Movie) RETURN count(this3) = $param0 AS var4 } diff --git a/packages/graphql/tests/tck/issues/2803.test.ts b/packages/graphql/tests/tck/issues/2803.test.ts index 309b207ba1..53af13a171 100644 --- a/packages/graphql/tests/tck/issues/2803.test.ts +++ b/packages/graphql/tests/tck/issues/2803.test.ts @@ -347,7 +347,7 @@ describe("https://github.com/neo4j/graphql/issues/2803", () => { `); }); - test("should find movies aggregate within double nested connections", async () => { + test.only("should find movies aggregate within double nested connections", async () => { const query = /* GraphQL */ ` { actors( @@ -371,7 +371,7 @@ describe("https://github.com/neo4j/graphql/issues/2803", () => { MATCH (this:Actor) CALL (this) { MATCH (this)-[this0:ACTED_IN]->(this1:Movie) - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:ACTED_IN]-(this3:Actor) CALL (this3) { MATCH (this3)-[this4:ACTED_IN]->(this5:Movie) @@ -381,7 +381,7 @@ describe("https://github.com/neo4j/graphql/issues/2803", () => { WHERE var6 = true RETURN count(this3) > 0 AS var7 } - CALL (this1) { + CALL (this1, this0) { MATCH (this1)<-[this2:ACTED_IN]-(this3:Actor) CALL (this3) { MATCH (this3)-[this8:ACTED_IN]->(this9:Movie) diff --git a/packages/graphql/tests/tck/issues/6005.test.ts b/packages/graphql/tests/tck/issues/6005.test.ts index c0e2ab81b2..e163c2c021 100644 --- a/packages/graphql/tests/tck/issues/6005.test.ts +++ b/packages/graphql/tests/tck/issues/6005.test.ts @@ -196,7 +196,7 @@ describe("https://github.com/neo4j/graphql/issues/6005", () => { WITH edge.node AS this0 CALL (this0) { MATCH (this0)-[this1:ACTED_IN]->(this2:Movie) - CALL (this2) { + CALL (this2, this1) { MATCH (this2)<-[this3:ACTED_IN]-(this4:Actor) WITH DISTINCT this4 RETURN count(this4) = $param0 AS var5 From 43fcdbd5a429884c2233703ffeec720a7b507cb0 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Thu, 18 Dec 2025 13:22:11 +0100 Subject: [PATCH 09/10] Remove only in test --- packages/graphql/tests/tck/issues/2803.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graphql/tests/tck/issues/2803.test.ts b/packages/graphql/tests/tck/issues/2803.test.ts index 53af13a171..842d9f17e2 100644 --- a/packages/graphql/tests/tck/issues/2803.test.ts +++ b/packages/graphql/tests/tck/issues/2803.test.ts @@ -347,7 +347,7 @@ describe("https://github.com/neo4j/graphql/issues/2803", () => { `); }); - test.only("should find movies aggregate within double nested connections", async () => { + test("should find movies aggregate within double nested connections", async () => { const query = /* GraphQL */ ` { actors( From 9fb31f69143c1ba73c053564b803721b6eac1234 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Thu, 18 Dec 2025 13:26:30 +0100 Subject: [PATCH 10/10] Add changeset --- .changeset/large-adults-glow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/large-adults-glow.md diff --git a/.changeset/large-adults-glow.md b/.changeset/large-adults-glow.md new file mode 100644 index 0000000000..90c612a75f --- /dev/null +++ b/.changeset/large-adults-glow.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graphql": patch +--- + +Add support for `@cypher` directive in relationship properties