From 8652eea3be067ed5b60e80364d98da6cbc04882d Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 28 Aug 2024 13:36:42 +0200 Subject: [PATCH 01/16] refactor: append int.test to cypher test filename --- .../directives/cypher/{cypher.ts => cypher.int.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/graphql/tests/integration/directives/cypher/{cypher.ts => cypher.int.test.ts} (100%) diff --git a/packages/graphql/tests/integration/directives/cypher/cypher.ts b/packages/graphql/tests/integration/directives/cypher/cypher.int.test.ts similarity index 100% rename from packages/graphql/tests/integration/directives/cypher/cypher.ts rename to packages/graphql/tests/integration/directives/cypher/cypher.int.test.ts From 783e99cfa4a59ebe40d95fd2c3b8f31b5672f115 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 28 Aug 2024 16:36:25 +0200 Subject: [PATCH 02/16] feat: enable schema where filters on cypher scalar fields --- .../attribute/model-adapters/AttributeAdapter.ts | 3 +-- packages/graphql/tests/schema/directives/cypher.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts b/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts index 341786940f..126b379da1 100644 --- a/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts +++ b/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts @@ -135,8 +135,7 @@ export class AttributeAdapter { return ( (this.typeHelper.isEnum() || this.typeHelper.isSpatial() || this.typeHelper.isScalar()) && this.isFilterable() && - !this.isCustomResolvable() && - !this.isCypher() + !this.isCustomResolvable() ); } diff --git a/packages/graphql/tests/schema/directives/cypher.test.ts b/packages/graphql/tests/schema/directives/cypher.test.ts index aa9b3168d5..236c522751 100644 --- a/packages/graphql/tests/schema/directives/cypher.test.ts +++ b/packages/graphql/tests/schema/directives/cypher.test.ts @@ -357,6 +357,14 @@ describe("Cypher", () => { name_NOT_IN: [String] @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") name_NOT_STARTS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") name_STARTS_WITH: String + totalScreenTime: Int + totalScreenTime_GT: Int + totalScreenTime_GTE: Int + totalScreenTime_IN: [Int!] + totalScreenTime_LT: Int + totalScreenTime_LTE: Int + totalScreenTime_NOT: Int @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + totalScreenTime_NOT_IN: [Int!] @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") } type ActorsConnection { From 7edb4d8c3a0b5795e3e04ac968b4608c442bf4bc Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 10 Sep 2024 11:15:42 +0200 Subject: [PATCH 03/16] test: add integration tests for custom cypher filtering --- .../cypher/cypher-filtering.int.test.ts | 1092 +++++++++++++++++ 1 file changed, 1092 insertions(+) create mode 100644 packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts diff --git a/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts b/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts new file mode 100644 index 0000000000..e1ccb84557 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts @@ -0,0 +1,1092 @@ +/* + * 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 { createBearerToken } from "../../../utils/create-bearer-token"; +import type { UniqueType } from "../../../utils/graphql-types"; +import { TestHelper } from "../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + let CustomType: UniqueType; + + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + beforeEach(() => { + CustomType = testHelper.createUniqueType("CustomType"); + }); + + test.each([ + { + title: "Int cypher field: exact match", + filter: `special_count: 1`, + }, + { + title: "Int cypher field: GT", + filter: `special_count_GT: 0`, + }, + { + title: "Int cypher field: GTE", + filter: `special_count_GTE: 1`, + }, + { + title: "Int cypher field: LT", + filter: `special_count_LT: 2`, + }, + { + title: "Int cypher field: LTE", + filter: `special_count_LTE: 2`, + }, + { + title: "Int cypher field: IN", + filter: `special_count_IN: [1, 2, 3]`, + }, + ] as const)("$title", async ({ filter }) => { + const typeDefs = ` + type ${CustomType} { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { ${filter} }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_count: 1, + }, + ], + }); + }); + + test.each([ + { + title: "String cypher field: exact match", + filter: `special_word: "test"`, + }, + { + title: "String cypher field: CONTAINS", + filter: `special_word_CONTAINS: "es"`, + }, + { + title: "String cypher field: ENDS_WITH", + filter: `special_word_ENDS_WITH: "est"`, + }, + { + title: "String cypher field: STARTS_WITH", + filter: `special_word_STARTS_WITH: "tes"`, + }, + { + title: "String cypher field: IN", + filter: `special_word_IN: ["test", "test2"]`, + }, + ] as const)("$title", async ({ filter }) => { + const typeDefs = ` + type ${CustomType} { + title: String + special_word: String + @cypher( + statement: """ + RETURN "test" as s + """ + columnName: "s" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { ${filter} }) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Int cypher field AND String title field", async () => { + const typeDefs = ` + type ${CustomType} { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + UNWIND [ + {title: 'CustomType One' }, + {title: 'CustomType Two' }, + {title: 'CustomType Three' } + ] AS CustomTypeData + CREATE (m:${CustomType}) + SET m = CustomTypeData; + `, + {} + ); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1, title: "CustomType One" }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_count: 3, + }, + ], + }); + }); + + test("unmatched Int cypher field AND String title field", async () => { + const typeDefs = ` + type ${CustomType} { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + UNWIND [ + {title: 'CustomType One' }, + {title: 'CustomType Two' }, + {title: 'CustomType Three' } + ] AS CustomTypeData + CREATE (m:${CustomType}) + SET m = CustomTypeData; + `, + {} + ); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1, title: "CustomType Unknown" }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [], + }); + }); + + test("Int cypher field, selecting String title field", async () => { + const typeDefs = ` + type ${CustomType} { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1 }) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Point cypher field", async () => { + const typeDefs = ` + type ${CustomType} { + title: String + special_location: Point + @cypher( + statement: """ + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_location_DISTANCE: { + point: { latitude: 1, longitude: 1 } + distance: 0 + } + } + ) { + title + special_location { + latitude + longitude + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_location: { + latitude: 1, + longitude: 1, + }, + title: "test", + }, + ], + }); + }); + + test("CartesianPoint cypher field", async () => { + const typeDefs = ` + type ${CustomType} { + title: String + special_location: CartesianPoint + @cypher( + statement: """ + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_location_DISTANCE: { + point: { x: 1, y: 1, z: 2 } + distance: 1 + } + } + ) { + title + special_location { + x + y + z + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_location: { + x: 1, + y: 1, + z: 1, + }, + title: "test", + }, + ], + }); + }); + + test("DateTime cypher field", async () => { + const typeDefs = ` + type ${CustomType} { + title: String + special_time: DateTime + @cypher( + statement: """ + RETURN datetime("2024-09-03T15:30:00Z") AS t + """ + columnName: "t" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_time_GT: "2024-09-02T00:00:00Z" + } + ) { + special_time + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_time: "2024-09-03T15:30:00.000Z", + title: "test", + }, + ], + }); + }); + + test("With relationship filter (non-Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}( + where: { + custom_field: "hello world!" + actors_SOME: { + name: "Keanu Reeves" + } + } + ) { + custom_field + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + custom_field: "hello world!", + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("In a nested filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Actor.plural} { + name + movies(where: { custom_field: "hello world!"}) { + title + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Actor.plural]: [ + { + name: "Keanu Reeves", + movies: [ + { + title: "The Matrix", + }, + ], + }, + ], + }); + }); + + test("With a nested filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors(where: { name: "Keanu Reeves" }) { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With authorization (custom Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const token = createBearerToken("secret", { title: "The Matrix" }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + custom_field + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + custom_field: "hello world!", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With authorization (not custom Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const token = createBearerToken("secret", { title: "The Matrix" }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With sorting", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + }); + + await testHelper.executeCypher( + ` + CREATE (m1:${Movie} { title: "The Matrix" }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded" }) + CREATE (a1:${Actor} { name: "Keanu Reeves" }) + CREATE (a2:${Actor} { name: "Jada Pinkett Smith" }) + CREATE (a1)-[:ACTED_IN]->(m1) + CREATE (a1)-[:ACTED_IN]->(m2) + CREATE (a2)-[:ACTED_IN]->(m2) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}( + where: { custom_field: "hello world!" } + options: { sort: [{ title: DESC }] } + ) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix Reloaded", + actors: expect.toIncludeSameMembers([ + { + name: "Keanu Reeves", + }, + { + name: "Jada Pinkett Smith", + }, + ]), + }, + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("Connect filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + mutation { + ${Movie.operations.create}( + input: [ + { + title: "The Matrix Reloaded" + actors: { + connect: [ + { + where: { + node: { + name: "Keanu Reeves", + custom_field: "hello world!" + } + } + } + ] + create: [ + { + node: { + name: "Jada Pinkett Smith" + } + } + ] + } + } + ] + ) { + ${Movie.plural} { + title + actors { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data?.[Movie.operations.create]?.[Movie.plural]).toIncludeSameMembers([ + { + title: "The Matrix Reloaded", + actors: expect.toIncludeSameMembers([ + { + name: "Keanu Reeves", + }, + { + name: "Jada Pinkett Smith", + }, + ]), + }, + ]); + }); + + test("With two cypher fields", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + another_custom_field: Int + @cypher( + statement: """ + RETURN 100 AS i + """ + columnName: "i" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!", another_custom_field_GT: 50 }) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With two cypher fields, one nested", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors(where: { another_custom_field: "goodbye!" name: "Keanu Reeves" }) { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); +}); From 4c6a773789ab1d1153f05764b1c2a677dfe3e78e Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 10 Sep 2024 11:16:04 +0200 Subject: [PATCH 04/16] refactor: remove unnecessary beforeAll --- .../tck/directives/cypher/cypher.test.ts | 156 +++++++++--------- 1 file changed, 75 insertions(+), 81 deletions(-) diff --git a/packages/graphql/tests/tck/directives/cypher/cypher.test.ts b/packages/graphql/tests/tck/directives/cypher/cypher.test.ts index f7270cdd6c..624e6fe455 100644 --- a/packages/graphql/tests/tck/directives/cypher/cypher.test.ts +++ b/packages/graphql/tests/tck/directives/cypher/cypher.test.ts @@ -21,87 +21,81 @@ import { Neo4jGraphQL } from "../../../../src"; import { formatCypher, formatParams, translateQuery } from "../../utils/tck-test-utils"; describe("Cypher directive", () => { - let typeDefs: string; - let neoSchema: Neo4jGraphQL; - - beforeAll(() => { - typeDefs = /* GraphQL */ ` - type Actor @node { - name: String - year: Int - movies(title: String): [Movie] - @cypher( - statement: """ - MATCH (m:Movie {title: $title}) - RETURN m - """ - columnName: "m" - ) - tvShows(title: String): [Movie] - @cypher( - statement: """ - MATCH (t:TVShow {title: $title}) - RETURN t - """ - columnName: "t" - ) - - randomNumber: Int - @cypher( - statement: """ - RETURN rand() as res - """ - columnName: "res" - ) - } - - type TVShow @node { - id: ID - title: String - numSeasons: Int - actors: [Actor] - @cypher( - statement: """ - MATCH (a:Actor) - RETURN a - """ - columnName: "a" - ) - topActor: Actor - @cypher( - statement: """ - MATCH (a:Actor) - RETURN a - """ - columnName: "a" - ) - } - - type Movie @node { - id: ID - title: String - actors: [Actor] - @cypher( - statement: """ - MATCH (a:Actor) - RETURN a - """ - columnName: "a" - ) - topActor: Actor - @cypher( - statement: """ - MATCH (a:Actor) - RETURN a - """ - columnName: "a" - ) - } - `; - - neoSchema = new Neo4jGraphQL({ - typeDefs, - }); + const typeDefs = /* GraphQL */ ` + type Actor @node { + name: String + year: Int + movies(title: String): [Movie] + @cypher( + statement: """ + MATCH (m:Movie {title: $title}) + RETURN m + """ + columnName: "m" + ) + tvShows(title: String): [Movie] + @cypher( + statement: """ + MATCH (t:TVShow {title: $title}) + RETURN t + """ + columnName: "t" + ) + + randomNumber: Int + @cypher( + statement: """ + RETURN rand() as res + """ + columnName: "res" + ) + } + + type TVShow @node { + id: ID + title: String + numSeasons: Int + actors: [Actor] + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + columnName: "a" + ) + topActor: Actor + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + columnName: "a" + ) + } + + type Movie @node { + id: ID + title: String + actors: [Actor] + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + columnName: "a" + ) + topActor: Actor + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + columnName: "a" + ) + } + `; + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, }); test("Simple directive", async () => { From bb18c46e5e1908116a012e1f254b07676f66e9d2 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 10 Sep 2024 11:17:09 +0200 Subject: [PATCH 05/16] feat: make createPointOperation to be used in CypherFilter --- .../filters/property-filters/PointFilter.ts | 60 +-------------- .../queryAST/utils/create-point-operation.ts | 77 +++++++++++++++++++ 2 files changed, 79 insertions(+), 58 deletions(-) create mode 100644 packages/graphql/src/translate/queryAST/utils/create-point-operation.ts diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts index 529e267b40..e2bb89e042 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts @@ -18,72 +18,16 @@ */ import Cypher from "@neo4j/cypher-builder"; -import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; -import type { WhereOperator } from "../Filter"; +import { createPointOperation } from "../../../utils/create-point-operation"; import { PropertyFilter } from "./PropertyFilter"; export class PointFilter extends PropertyFilter { protected getOperation(prop: Cypher.Property): Cypher.ComparisonOp { - return this.createPointOperation({ + return createPointOperation({ operator: this.operator || "EQ", property: prop, param: new Cypher.Param(this.comparisonValue), attribute: this.attribute, }); } - - private createPointOperation({ - operator, - property, - param, - attribute, - }: { - operator: WhereOperator | "EQ"; - property: Cypher.Expr; - param: Cypher.Param; - attribute: AttributeAdapter; - }): Cypher.ComparisonOp { - const pointDistance = this.createPointDistanceExpression(property, param); - const distanceRef = param.property("distance"); - - switch (operator) { - case "LT": - return Cypher.lt(pointDistance, distanceRef); - case "LTE": - return Cypher.lte(pointDistance, distanceRef); - case "GT": - return Cypher.gt(pointDistance, distanceRef); - case "GTE": - return Cypher.gte(pointDistance, distanceRef); - case "DISTANCE": - return Cypher.eq(pointDistance, distanceRef); - case "EQ": { - if (attribute.typeHelper.isList()) { - const pointList = this.createPointListComprehension(param); - return Cypher.eq(property, pointList); - } - - return Cypher.eq(property, Cypher.point(param)); - } - case "IN": { - const pointList = this.createPointListComprehension(param); - return Cypher.in(property, pointList); - } - case "INCLUDES": - return Cypher.in(Cypher.point(param), property); - default: - throw new Error(`Invalid operator ${operator}`); - } - } - - private createPointListComprehension(param: Cypher.Param): Cypher.ListComprehension { - const comprehensionVar = new Cypher.Variable(); - const mapPoint = Cypher.point(comprehensionVar); - return new Cypher.ListComprehension(comprehensionVar, param).map(mapPoint); - } - - private createPointDistanceExpression(property: Cypher.Expr, param: Cypher.Param): Cypher.Function { - const nestedPointRef = param.property("point"); - return Cypher.point.distance(property, Cypher.point(nestedPointRef)); - } } diff --git a/packages/graphql/src/translate/queryAST/utils/create-point-operation.ts b/packages/graphql/src/translate/queryAST/utils/create-point-operation.ts new file mode 100644 index 0000000000..fae45986b3 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/utils/create-point-operation.ts @@ -0,0 +1,77 @@ +/* + * 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 { WhereOperator } from "../ast/filters/Filter"; + +export function createPointOperation({ + operator, + property, + param, + attribute, +}: { + operator: WhereOperator | "EQ"; + property: Cypher.Expr; + param: Cypher.Param; + attribute: AttributeAdapter; +}): Cypher.ComparisonOp { + const pointDistance = createPointDistanceExpression(property, param); + const distanceRef = param.property("distance"); + + switch (operator) { + case "LT": + return Cypher.lt(pointDistance, distanceRef); + case "LTE": + return Cypher.lte(pointDistance, distanceRef); + case "GT": + return Cypher.gt(pointDistance, distanceRef); + case "GTE": + return Cypher.gte(pointDistance, distanceRef); + case "DISTANCE": + return Cypher.eq(pointDistance, distanceRef); + case "EQ": { + if (attribute.typeHelper.isList()) { + const pointList = createPointListComprehension(param); + return Cypher.eq(property, pointList); + } + + return Cypher.eq(property, Cypher.point(param)); + } + case "IN": { + const pointList = createPointListComprehension(param); + return Cypher.in(property, pointList); + } + case "INCLUDES": + return Cypher.in(Cypher.point(param), property); + default: + throw new Error(`Invalid operator ${operator}`); + } +} + +function createPointListComprehension(param: Cypher.Param): Cypher.ListComprehension { + const comprehensionVar = new Cypher.Variable(); + const mapPoint = Cypher.point(comprehensionVar); + return new Cypher.ListComprehension(comprehensionVar, param).map(mapPoint); +} + +function createPointDistanceExpression(property: Cypher.Expr, param: Cypher.Param): Cypher.Function { + const nestedPointRef = param.property("point"); + return Cypher.point.distance(property, Cypher.point(nestedPointRef)); +} From 953336bc6775ff08053c868119bf73047ccb27a0 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 10 Sep 2024 11:17:26 +0200 Subject: [PATCH 06/16] feat: add CypherFilter --- .../filters/property-filters/CypherFilter.ts | 132 ++++++++++++++++++ .../queryAST/factory/FilterFactory.ts | 20 ++- 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts new file mode 100644 index 0000000000..e6909b8f66 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts @@ -0,0 +1,132 @@ +/* + * 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 { createComparisonOperation } from "../../../utils/create-comparison-operator"; +import { createPointOperation } from "../../../utils/create-point-operation"; +import type { QueryASTContext } from "../../QueryASTContext"; +import type { QueryASTNode } from "../../QueryASTNode"; +import type { CustomCypherSelection } from "../../selection/CustomCypherSelection"; +import type { FilterOperator } from "../Filter"; +import { Filter } from "../Filter"; + +/** A property which comparison has already been parsed into a Param */ +export class CypherFilter extends Filter { + private returnVariable: Cypher.Variable = new Cypher.Variable(); + private attribute: AttributeAdapter; + private selection: CustomCypherSelection; + private operator: FilterOperator; + protected comparisonValue: unknown; + + constructor({ + selection, + attribute, + operator, + comparisonValue, + }: { + selection: CustomCypherSelection; + attribute: AttributeAdapter; + operator: FilterOperator; + comparisonValue: unknown; + }) { + super(); + this.selection = selection; + this.attribute = attribute; + this.operator = operator; + this.comparisonValue = comparisonValue; + } + + public getChildren(): QueryASTNode[] { + return [this.selection]; + } + + public print(): string { + return `${super.print()} [${this.attribute.name}] <${this.operator}>`; + } + + public getSubqueries(context: QueryASTContext): Cypher.Clause[] { + const { selection: cypherSubquery, nestedContext } = this.selection.apply(context); + + const clause = Cypher.concat( + cypherSubquery, + new Cypher.Return([nestedContext.returnVariable, this.returnVariable]) + ); + + return [clause]; + } + + public getPredicate(_queryASTContext: QueryASTContext): Cypher.Predicate { + const operation = this.createBaseOperation({ + operator: this.operator, + property: this.returnVariable, + param: new Cypher.Param(this.comparisonValue), + }); + + // const scope = context.getTargetScope(); + // // by setting the return variable of this operation in the attribute scope, we can avoid duplicate the same cypher resolution for sorting and projection purposes + // scope.set(this.cypherAttributeField.name, context.returnVariable); + + return operation; + + // let retProj; + + // if (this.isNested && this.cypherAttributeField.typeHelper.isList()) { + // retProj = [Cypher.collect(nestedContext.returnVariable), context.returnVariable]; + // } else { + // retProj = [nestedContext.returnVariable, context.returnVariable]; + // } + // const ret = new Cypher.Return(retProj); + } + + private coalesceValueIfNeeded(expr: Cypher.Expr): Cypher.Expr { + if (this.attribute.annotations.coalesce) { + const value = this.attribute.annotations.coalesce.value; + const literal = new Cypher.Literal(value); + return Cypher.coalesce(expr, literal); + } + return expr; + } + + /** Returns the default operation for a given filter */ + private createBaseOperation({ + operator, + property, + param, + }: { + operator: FilterOperator; + property: Cypher.Expr; + param: Cypher.Expr; + }): Cypher.ComparisonOp { + const coalesceProperty = this.coalesceValueIfNeeded(property); + + // Check for point + + if (operator === "DISTANCE") { + return createPointOperation({ + operator, + property: coalesceProperty, + param: new Cypher.Param(this.comparisonValue), + attribute: this.attribute, + }); + } + + return createComparisonOperation({ operator, property: coalesceProperty, param }); + } +} diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index cc31845f00..0b10e8669e 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -37,10 +37,12 @@ import { AggregationDurationFilter } from "../ast/filters/aggregation/Aggregatio import { AggregationFilter } from "../ast/filters/aggregation/AggregationFilter"; import { AggregationPropertyFilter } from "../ast/filters/aggregation/AggregationPropertyFilter"; import { CountFilter } from "../ast/filters/aggregation/CountFilter"; +import { CypherFilter } from "../ast/filters/property-filters/CypherFilter"; import { DurationFilter } from "../ast/filters/property-filters/DurationFilter"; import { PointFilter } from "../ast/filters/property-filters/PointFilter"; import { PropertyFilter } from "../ast/filters/property-filters/PropertyFilter"; import { TypenameFilter } from "../ast/filters/property-filters/TypenameFilter"; +import { CustomCypherSelection } from "../ast/selection/CustomCypherSelection"; import { getConcreteEntities } from "../utils/get-concrete-entities"; import { isConcreteEntity } from "../utils/is-concrete-entity"; import { isInterfaceEntity } from "../utils/is-interface-entity"; @@ -180,8 +182,24 @@ export class FilterFactory { operator: WhereOperator | undefined; isNot: boolean; attachedTo?: "node" | "relationship"; - }): PropertyFilter { + }): PropertyFilter | CypherFilter { const filterOperator = operator || "EQ"; + + if (attribute.annotations.cypher) { + const selection = new CustomCypherSelection({ + operationField: attribute, + rawArguments: {}, + isNested: true, + }); + + return new CypherFilter({ + selection, + attribute, + comparisonValue, + operator: filterOperator, + }); + } + if (attribute.typeHelper.isDuration()) { return new DurationFilter({ attribute, From e6c2bd47ba115c856a9220c8ee49d7ec16cc1925 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 11 Sep 2024 10:05:14 +0200 Subject: [PATCH 07/16] test: add tck tests --- .../cypher/cypher-filtering.test.ts | 1235 +++++++++++++++++ 1 file changed, 1235 insertions(+) create mode 100644 packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts diff --git a/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts b/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts new file mode 100644 index 0000000000..b28df8c2ea --- /dev/null +++ b/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts @@ -0,0 +1,1235 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Neo4jGraphQL } from "../../../../src"; +import { createBearerToken } from "../../../utils/create-bearer-token"; +import { formatCypher, formatParams, translateQuery } from "../../utils/tck-test-utils"; + +describe("cypher directive filtering", () => { + test("Int cypher field AND String title field", async () => { + const typeDefs = ` + type Movie { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:Movie) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + const query = ` + query { + movies(where: { special_count_GTE: 1, title: "CustomType One" }) { + special_count + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (this.title = $param0 AND var1 >= $param1) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this2 + RETURN this2 AS var3 + } + RETURN this { special_count: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"CustomType One\\", + \\"param1\\": { + \\"low\\": 1, + \\"high\\": 0 + } + }" + `); + }); + + test("unmatched Int cypher field AND String title field", async () => { + const typeDefs = ` + type Movie { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:Movie) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + const query = ` + query { + movies(where: { special_count_GTE: 1, title: "CustomType Unknown" }) { + special_count + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (this.title = $param0 AND var1 >= $param1) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this2 + RETURN this2 AS var3 + } + RETURN this { special_count: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"CustomType Unknown\\", + \\"param1\\": { + \\"low\\": 1, + \\"high\\": 0 + } + }" + `); + }); + + test("Int cypher field, selecting String title field", async () => { + const typeDefs = ` + type Movie { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:Movie) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + const query = ` + query { + movies(where: { special_count_GTE: 1 }) { + title + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 >= $param0 + RETURN this { .title } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"low\\": 1, + \\"high\\": 0 + } + }" + `); + }); + + test("Point cypher field", async () => { + const typeDefs = ` + type Movie { + title: String + special_location: Point + @cypher( + statement: """ + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + const query = ` + query { + movies( + where: { + special_location_DISTANCE: { + point: { latitude: 1, longitude: 1 } + distance: 0 + } + } + ) { + title + special_location { + latitude + longitude + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + } + WITH l AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE point.distance(var1, point($param0.point)) = $param0.distance + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + } + WITH l AS this2 + RETURN this2 AS var3 + } + RETURN this { .title, special_location: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"point\\": { + \\"longitude\\": 1, + \\"latitude\\": 1 + }, + \\"distance\\": 0 + } + }" + `); + }); + + test("CartesianPoint cypher field", async () => { + const typeDefs = ` + type Movie { + title: String + special_location: CartesianPoint + @cypher( + statement: """ + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + const query = ` + query { + movies( + where: { + special_location_DISTANCE: { + point: { x: 1, y: 1, z: 2 } + distance: 1 + } + } + ) { + title + special_location { + x + y + z + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + } + WITH l AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE point.distance(var1, point($param0.point)) = $param0.distance + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + } + WITH l AS this2 + RETURN this2 AS var3 + } + RETURN this { .title, special_location: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"point\\": { + \\"x\\": 1, + \\"y\\": 1, + \\"z\\": 2 + }, + \\"distance\\": 1 + } + }" + `); + }); + + test("DateTime cypher field", async () => { + const typeDefs = ` + type Movie { + title: String + special_time: DateTime + @cypher( + statement: """ + RETURN datetime("2024-09-03T15:30:00Z") AS t + """ + columnName: "t" + ) + } + `; + + const query = ` + query { + movies( + where: { + special_time_GT: "2024-09-02T00:00:00Z" + } + ) { + special_time + title + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN datetime(\\"2024-09-03T15:30:00Z\\") AS t + } + WITH t AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 > $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN datetime(\\"2024-09-03T15:30:00Z\\") AS t + } + WITH t AS this2 + RETURN this2 AS var3 + } + RETURN this { .title, special_time: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"year\\": 2024, + \\"month\\": 9, + \\"day\\": 2, + \\"hour\\": 0, + \\"minute\\": 0, + \\"second\\": 0, + \\"nanosecond\\": 0, + \\"timeZoneOffsetSeconds\\": 0 + } + }" + `); + }); + + test("With relationship filter (non-Cypher field)", async () => { + const typeDefs = ` + type Movie { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies( + where: { + custom_field: "hello world!" + actors_SOME: { + name: "Keanu Reeves" + } + } + ) { + custom_field + title + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (var1 = $param0 AND EXISTS { + MATCH (this)<-[:ACTED_IN]-(this2:Actor) + WHERE this2.name = $param1 + }) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this3 + RETURN this3 AS var4 + } + CALL { + WITH this + MATCH (this)<-[this5:ACTED_IN]-(this6:Actor) + WITH this6 { .name } AS this6 + RETURN collect(this6) AS var7 + } + RETURN this { .title, custom_field: var4, actors: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"param1\\": \\"Keanu Reeves\\" + }" + `); + }); + + test("In a nested filter", async () => { + const typeDefs = ` + type Movie { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + actors { + name + movies(where: { custom_field: "hello world!"}) { + title + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Actor) + CALL { + WITH this + MATCH (this)-[this0:ACTED_IN]->(this1:Movie) + CALL { + WITH this1 + CALL { + WITH this1 + WITH this1 AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this2 + RETURN this2 AS var3 + } + WITH * + WHERE var3 = $param0 + WITH this1 { .title } AS this1 + RETURN collect(this1) AS var4 + } + RETURN this { .name, movies: var4 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\" + }" + `); + }); + + test("With a nested filter", async () => { + const typeDefs = ` + type Movie { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies(where: { custom_field: "hello world!" }) { + title + actors(where: { name: "Keanu Reeves" }) { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 = $param0 + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Actor) + WHERE this3.name = $param1 + WITH this3 { .name } AS this3 + RETURN collect(this3) AS var4 + } + RETURN this { .title, actors: var4 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"param1\\": \\"Keanu Reeves\\" + }" + `); + }); + + test("With authorization (custom Cypher field)", async () => { + const typeDefs = ` + type Movie { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const token = createBearerToken("secret", { title: "The Matrix" }); + + const query = ` + query { + movies(where: { custom_field: "hello world!" }) { + title + custom_field + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (var1 = $param0 AND ($isAuthenticated = true AND ($jwt.title IS NOT NULL AND this.title = $jwt.title))) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this2 + RETURN this2 AS var3 + } + CALL { + WITH this + MATCH (this)<-[this4:ACTED_IN]-(this5:Actor) + WITH this5 { .name } AS this5 + RETURN collect(this5) AS var6 + } + RETURN this { .title, custom_field: var3, actors: var6 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"title\\": \\"The Matrix\\" + } + }" + `); + }); + + test("With authorization (not custom Cypher field)", async () => { + const typeDefs = ` + type Movie { + title: String @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const token = createBearerToken("secret", { title: "The Matrix" }); + + const query = ` + query { + movies(where: { custom_field: "hello world!" }) { + title + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (var1 = $param0 AND ($isAuthenticated = true AND ($jwt.title IS NOT NULL AND this.title = $jwt.title))) + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Actor) + WITH this3 { .name } AS this3 + RETURN collect(this3) AS var4 + } + RETURN this { .title, actors: var4 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"title\\": \\"The Matrix\\" + } + }" + `); + }); + + test("With sorting", async () => { + const typeDefs = ` + type Movie { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies( + where: { custom_field: "hello world!" } + options: { sort: [{ title: DESC }] } + ) { + title + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 = $param0 + WITH * + ORDER BY this.title DESC + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Actor) + WITH this3 { .name } AS this3 + RETURN collect(this3) AS var4 + } + RETURN this { .title, actors: var4 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\" + }" + `); + }); + + test("Connect filter", async () => { + const typeDefs = ` + type Movie { + title: String + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + mutation { + createMovies( + input: [ + { + title: "The Matrix Reloaded" + actors: { + connect: [ + { + where: { + node: { + name: "Keanu Reeves", + custom_field: "hello world!" + } + } + } + ] + create: [ + { + node: { + name: "Jada Pinkett Smith" + } + } + ] + } + } + ] + ) { + movies { + title + actors { + name + } + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + WITH * + CREATE (this0_actors0_node:Actor) + SET this0_actors0_node.name = $this0_actors0_node_name + MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) + WITH * + CALL { + WITH this0 + OPTIONAL MATCH (this0_actors_connect0_node:Actor) + CALL { + WITH this0_actors_connect0_node + CALL { + WITH this0_actors_connect0_node + WITH this0_actors_connect0_node AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0_actors_connect0_node_this0 + RETURN this0_actors_connect0_node_this0 AS this0_actors_connect0_node_var1 + } + WITH *, CASE (this0_actors_connect0_node.name = $this0_actors_connect0_node_param0 AND this0_actors_connect0_node_var1 = $this0_actors_connect0_node_param1) + WHEN true THEN [this0_actors_connect0_node] + ELSE [NULL] + END AS aggregateWhereFiltervar0 + WITH *, aggregateWhereFiltervar0[0] AS this0_actors_connect0_node + CALL { + WITH * + WITH collect(this0_actors_connect0_node) as connectedNodes, collect(this0) as parentNodes + CALL { + WITH connectedNodes, parentNodes + UNWIND parentNodes as this0 + UNWIND connectedNodes as this0_actors_connect0_node + MERGE (this0)<-[:ACTED_IN]-(this0_actors_connect0_node) + } + } + WITH this0, this0_actors_connect0_node + RETURN count(*) AS connect_this0_actors_connect_Actor0 + } + RETURN this0 + } + CALL { + WITH this0 + CALL { + WITH this0 + MATCH (this0)<-[create_this0:ACTED_IN]-(create_this1:Actor) + WITH create_this1 { .name } AS create_this1 + RETURN collect(create_this1) AS create_var2 + } + RETURN this0 { .title, actors: create_var2 } AS create_var3 + } + RETURN [create_var3] AS data" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"this0_title\\": \\"The Matrix Reloaded\\", + \\"this0_actors0_node_name\\": \\"Jada Pinkett Smith\\", + \\"this0_actors_connect0_node_param0\\": \\"Keanu Reeves\\", + \\"this0_actors_connect0_node_param1\\": \\"hello world!\\", + \\"resolvedCallbacks\\": {} + }" + `); + }); + + test("With two cypher fields", async () => { + const typeDefs = ` + type Movie { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + another_custom_field: Int + @cypher( + statement: """ + RETURN 100 AS i + """ + columnName: "i" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies(where: { custom_field: "hello world!", another_custom_field_GT: 50 }) { + title + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN 100 AS i + } + WITH i AS this2 + RETURN this2 AS var3 + } + WITH * + WHERE (var1 = $param0 AND var3 > $param1) + CALL { + WITH this + MATCH (this)<-[this4:ACTED_IN]-(this5:Actor) + WITH this5 { .name } AS this5 + RETURN collect(this5) AS var6 + } + RETURN this { .title, actors: var6 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"param1\\": { + \\"low\\": 50, + \\"high\\": 0 + } + }" + `); + }); + + test("With two cypher fields, one nested", async () => { + const typeDefs = ` + type Movie { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies(where: { custom_field: "hello world!" }) { + title + actors(where: { another_custom_field: "goodbye!" name: "Keanu Reeves" }) { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 = $param0 + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Actor) + CALL { + WITH this3 + CALL { + WITH this3 + WITH this3 AS this + RETURN \\"goodbye!\\" AS s + } + WITH s AS this4 + RETURN this4 AS var5 + } + WITH * + WHERE (this3.name = $param1 AND var5 = $param2) + WITH this3 { .name } AS this3 + RETURN collect(this3) AS var6 + } + RETURN this { .title, actors: var6 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"param1\\": \\"Keanu Reeves\\", + \\"param2\\": \\"goodbye!\\" + }" + `); + }); +}); From 3a0a69fa47dc5ce4e8c60e35f5213ffde582ead8 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 11 Sep 2024 10:23:33 +0200 Subject: [PATCH 08/16] docs: add changeset --- .changeset/lovely-beans-grin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lovely-beans-grin.md diff --git a/.changeset/lovely-beans-grin.md b/.changeset/lovely-beans-grin.md new file mode 100644 index 0000000000..93356c1d4c --- /dev/null +++ b/.changeset/lovely-beans-grin.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graphql": minor +--- + +Add filtering on scalar custom cypher fields From 4fcdfe8d351947530307ab7e699d1296b71b96fb Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 11 Sep 2024 12:18:35 +0200 Subject: [PATCH 09/16] refactor: remove commented out code --- .../ast/filters/property-filters/CypherFilter.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts index e6909b8f66..b379db2ddd 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts @@ -79,20 +79,7 @@ export class CypherFilter extends Filter { param: new Cypher.Param(this.comparisonValue), }); - // const scope = context.getTargetScope(); - // // by setting the return variable of this operation in the attribute scope, we can avoid duplicate the same cypher resolution for sorting and projection purposes - // scope.set(this.cypherAttributeField.name, context.returnVariable); - return operation; - - // let retProj; - - // if (this.isNested && this.cypherAttributeField.typeHelper.isList()) { - // retProj = [Cypher.collect(nestedContext.returnVariable), context.returnVariable]; - // } else { - // retProj = [nestedContext.returnVariable, context.returnVariable]; - // } - // const ret = new Cypher.Return(retProj); } private coalesceValueIfNeeded(expr: Cypher.Expr): Cypher.Expr { @@ -116,8 +103,6 @@ export class CypherFilter extends Filter { }): Cypher.ComparisonOp { const coalesceProperty = this.coalesceValueIfNeeded(property); - // Check for point - if (operator === "DISTANCE") { return createPointOperation({ operator, From 2bc09416037a2f58614ab8bbf8d5e44a2ab0ed2d Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 11 Sep 2024 13:55:42 +0200 Subject: [PATCH 10/16] test: add `@node` to types in type definitions --- .../cypher/cypher-filtering.int.test.ts | 52 +++++++++---------- .../cypher/cypher-filtering.test.ts | 30 +++++------ 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts b/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts index e1ccb84557..1edcf85f6d 100644 --- a/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts @@ -61,7 +61,7 @@ describe("cypher directive filtering", () => { }, ] as const)("$title", async ({ filter }) => { const typeDefs = ` - type ${CustomType} { + type ${CustomType} @node { title: String special_count: Int @cypher( @@ -120,7 +120,7 @@ describe("cypher directive filtering", () => { }, ] as const)("$title", async ({ filter }) => { const typeDefs = ` - type ${CustomType} { + type ${CustomType} @node { title: String special_word: String @cypher( @@ -157,7 +157,7 @@ describe("cypher directive filtering", () => { test("Int cypher field AND String title field", async () => { const typeDefs = ` - type ${CustomType} { + type ${CustomType} @node { title: String special_count: Int @cypher( @@ -206,7 +206,7 @@ describe("cypher directive filtering", () => { test("unmatched Int cypher field AND String title field", async () => { const typeDefs = ` - type ${CustomType} { + type ${CustomType} @node { title: String special_count: Int @cypher( @@ -251,7 +251,7 @@ describe("cypher directive filtering", () => { test("Int cypher field, selecting String title field", async () => { const typeDefs = ` - type ${CustomType} { + type ${CustomType} @node { title: String special_count: Int @cypher( @@ -289,7 +289,7 @@ describe("cypher directive filtering", () => { test("Point cypher field", async () => { const typeDefs = ` - type ${CustomType} { + type ${CustomType} @node { title: String special_location: Point @cypher( @@ -341,7 +341,7 @@ describe("cypher directive filtering", () => { test("CartesianPoint cypher field", async () => { const typeDefs = ` - type ${CustomType} { + type ${CustomType} @node { title: String special_location: CartesianPoint @cypher( @@ -395,7 +395,7 @@ describe("cypher directive filtering", () => { test("DateTime cypher field", async () => { const typeDefs = ` - type ${CustomType} { + type ${CustomType} @node { title: String special_time: DateTime @cypher( @@ -441,7 +441,7 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String custom_field: String @cypher( @@ -453,7 +453,7 @@ describe("cypher directive filtering", () => { actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) } @@ -511,7 +511,7 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String custom_field: String @cypher( @@ -523,7 +523,7 @@ describe("cypher directive filtering", () => { actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) } @@ -572,7 +572,7 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String custom_field: String @cypher( @@ -584,7 +584,7 @@ describe("cypher directive filtering", () => { actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) } @@ -633,7 +633,7 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String custom_field: String @cypher( @@ -646,7 +646,7 @@ describe("cypher directive filtering", () => { actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) } @@ -707,7 +707,7 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) custom_field: String @cypher( @@ -719,7 +719,7 @@ describe("cypher directive filtering", () => { actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) } @@ -778,7 +778,7 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String custom_field: String @cypher( @@ -790,7 +790,7 @@ describe("cypher directive filtering", () => { actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) } @@ -860,12 +860,12 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String custom_field: String @cypher( @@ -952,7 +952,7 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String custom_field: String @cypher( @@ -971,7 +971,7 @@ describe("cypher directive filtering", () => { actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String another_custom_field: String @cypher( @@ -1027,7 +1027,7 @@ describe("cypher directive filtering", () => { const Actor = testHelper.createUniqueType("Actor"); const typeDefs = ` - type ${Movie} { + type ${Movie} @node { title: String custom_field: String @cypher( @@ -1039,7 +1039,7 @@ describe("cypher directive filtering", () => { actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) } - type ${Actor} { + type ${Actor} @node { name: String another_custom_field: String @cypher( diff --git a/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts b/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts index b28df8c2ea..7bd90e1f2d 100644 --- a/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts +++ b/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts @@ -24,7 +24,7 @@ import { formatCypher, formatParams, translateQuery } from "../../utils/tck-test describe("cypher directive filtering", () => { test("Int cypher field AND String title field", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String special_count: Int @cypher( @@ -93,7 +93,7 @@ describe("cypher directive filtering", () => { test("unmatched Int cypher field AND String title field", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String special_count: Int @cypher( @@ -162,7 +162,7 @@ describe("cypher directive filtering", () => { test("Int cypher field, selecting String title field", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String special_count: Int @cypher( @@ -219,7 +219,7 @@ describe("cypher directive filtering", () => { test("Point cypher field", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String special_location: Point @cypher( @@ -298,7 +298,7 @@ describe("cypher directive filtering", () => { test("CartesianPoint cypher field", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String special_location: CartesianPoint @cypher( @@ -379,7 +379,7 @@ describe("cypher directive filtering", () => { test("DateTime cypher field", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String special_time: DateTime @cypher( @@ -455,7 +455,7 @@ describe("cypher directive filtering", () => { test("With relationship filter (non-Cypher field)", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String custom_field: String @cypher( @@ -544,7 +544,7 @@ describe("cypher directive filtering", () => { test("In a nested filter", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String custom_field: String @cypher( @@ -611,7 +611,7 @@ describe("cypher directive filtering", () => { test("With a nested filter", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String custom_field: String @cypher( @@ -680,7 +680,7 @@ describe("cypher directive filtering", () => { test("With authorization (custom Cypher field)", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String custom_field: String @cypher( @@ -771,7 +771,7 @@ describe("cypher directive filtering", () => { test("With authorization (not custom Cypher field)", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) custom_field: String @cypher( @@ -850,7 +850,7 @@ describe("cypher directive filtering", () => { test("With sorting", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String custom_field: String @cypher( @@ -922,7 +922,7 @@ describe("cypher directive filtering", () => { test("Connect filter", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) } @@ -1052,7 +1052,7 @@ describe("cypher directive filtering", () => { test("With two cypher fields", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String custom_field: String @cypher( @@ -1147,7 +1147,7 @@ describe("cypher directive filtering", () => { test("With two cypher fields, one nested", async () => { const typeDefs = ` - type Movie { + type Movie @node { title: String custom_field: String @cypher( From 2987f81d00125d1702910d14e6b727c514f5940e Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 11 Sep 2024 13:58:25 +0200 Subject: [PATCH 11/16] test: add test for Duration filter --- .../cypher/cypher-filtering.int.test.ts | 41 ++++++++++++ .../cypher/cypher-filtering.test.ts | 66 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts b/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts index 1edcf85f6d..5a702ddc8a 100644 --- a/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts @@ -436,6 +436,47 @@ describe("cypher directive filtering", () => { }); }); + test("Duration cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration: "P14DT16H12M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + test("With relationship filter (non-Cypher field)", async () => { const Movie = testHelper.createUniqueType("Movie"); const Actor = testHelper.createUniqueType("Actor"); diff --git a/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts b/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts index 7bd90e1f2d..f68f67dfc8 100644 --- a/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts +++ b/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts @@ -453,6 +453,72 @@ describe("cypher directive filtering", () => { `); }); + test("Duration cypher field", async () => { + const typeDefs = ` + type Movie @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + const query = ` + query { + movies( + where: { + special_duration: "P14DT16H12M" + } + ) { + title + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN duration('P14DT16H12M') AS d + } + WITH d AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 = $param0 + RETURN this { .title } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"months\\": 0, + \\"days\\": 14, + \\"seconds\\": { + \\"low\\": 58320, + \\"high\\": 0 + }, + \\"nanoseconds\\": { + \\"low\\": 0, + \\"high\\": 0 + } + } + }" + `); + }); + test("With relationship filter (non-Cypher field)", async () => { const typeDefs = ` type Movie @node { From db632d2eb15e35ce45662bc1f785d7804ffe714c Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 11 Sep 2024 14:16:37 +0200 Subject: [PATCH 12/16] refactor: move tests into more granular files --- .../cypher/cypher-filtering.int.test.ts | 1133 ----------------- .../cypher-filtering-auth.int.test.ts | 174 +++ .../cypher-filtering-connect.int.test.ts | 120 ++ .../cypher-filtering-misc.int.test.ts | 363 ++++++ .../cypher-filtering-scalar.int.test.ts | 288 +++++ .../cypher-filtering-sorting.int.test.ts | 110 ++ .../cypher-filtering-spatial.int.test.ts | 141 ++ .../cypher-filtering-temporal.int.test.ts | 119 ++ 8 files changed, 1315 insertions(+), 1133 deletions(-) delete mode 100644 packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts create mode 100644 packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-auth.int.test.ts create mode 100644 packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-connect.int.test.ts create mode 100644 packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-misc.int.test.ts create mode 100644 packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-scalar.int.test.ts create mode 100644 packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-sorting.int.test.ts create mode 100644 packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-spatial.int.test.ts create mode 100644 packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts diff --git a/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts b/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts deleted file mode 100644 index 5a702ddc8a..0000000000 --- a/packages/graphql/tests/integration/directives/cypher/cypher-filtering.int.test.ts +++ /dev/null @@ -1,1133 +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 { createBearerToken } from "../../../utils/create-bearer-token"; -import type { UniqueType } from "../../../utils/graphql-types"; -import { TestHelper } from "../../../utils/tests-helper"; - -describe("cypher directive filtering", () => { - let CustomType: UniqueType; - - const testHelper = new TestHelper(); - - afterEach(async () => { - await testHelper.close(); - }); - - beforeEach(() => { - CustomType = testHelper.createUniqueType("CustomType"); - }); - - test.each([ - { - title: "Int cypher field: exact match", - filter: `special_count: 1`, - }, - { - title: "Int cypher field: GT", - filter: `special_count_GT: 0`, - }, - { - title: "Int cypher field: GTE", - filter: `special_count_GTE: 1`, - }, - { - title: "Int cypher field: LT", - filter: `special_count_LT: 2`, - }, - { - title: "Int cypher field: LTE", - filter: `special_count_LTE: 2`, - }, - { - title: "Int cypher field: IN", - filter: `special_count_IN: [1, 2, 3]`, - }, - ] as const)("$title", async ({ filter }) => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_count: Int - @cypher( - statement: """ - MATCH (m:${CustomType}) - RETURN count(m) as c - """ - columnName: "c" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); - - const query = ` - query { - ${CustomType.plural}(where: { ${filter} }) { - special_count - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [ - { - special_count: 1, - }, - ], - }); - }); - - test.each([ - { - title: "String cypher field: exact match", - filter: `special_word: "test"`, - }, - { - title: "String cypher field: CONTAINS", - filter: `special_word_CONTAINS: "es"`, - }, - { - title: "String cypher field: ENDS_WITH", - filter: `special_word_ENDS_WITH: "est"`, - }, - { - title: "String cypher field: STARTS_WITH", - filter: `special_word_STARTS_WITH: "tes"`, - }, - { - title: "String cypher field: IN", - filter: `special_word_IN: ["test", "test2"]`, - }, - ] as const)("$title", async ({ filter }) => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_word: String - @cypher( - statement: """ - RETURN "test" as s - """ - columnName: "s" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); - - const query = ` - query { - ${CustomType.plural}(where: { ${filter} }) { - title - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [ - { - title: "test", - }, - ], - }); - }); - - test("Int cypher field AND String title field", async () => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_count: Int - @cypher( - statement: """ - MATCH (m:${CustomType}) - RETURN count(m) as c - """ - columnName: "c" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher( - ` - UNWIND [ - {title: 'CustomType One' }, - {title: 'CustomType Two' }, - {title: 'CustomType Three' } - ] AS CustomTypeData - CREATE (m:${CustomType}) - SET m = CustomTypeData; - `, - {} - ); - - const query = ` - query { - ${CustomType.plural}(where: { special_count_GTE: 1, title: "CustomType One" }) { - special_count - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [ - { - special_count: 3, - }, - ], - }); - }); - - test("unmatched Int cypher field AND String title field", async () => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_count: Int - @cypher( - statement: """ - MATCH (m:${CustomType}) - RETURN count(m) as c - """ - columnName: "c" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher( - ` - UNWIND [ - {title: 'CustomType One' }, - {title: 'CustomType Two' }, - {title: 'CustomType Three' } - ] AS CustomTypeData - CREATE (m:${CustomType}) - SET m = CustomTypeData; - `, - {} - ); - - const query = ` - query { - ${CustomType.plural}(where: { special_count_GTE: 1, title: "CustomType Unknown" }) { - special_count - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [], - }); - }); - - test("Int cypher field, selecting String title field", async () => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_count: Int - @cypher( - statement: """ - MATCH (m:${CustomType}) - RETURN count(m) as c - """ - columnName: "c" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); - - const query = ` - query { - ${CustomType.plural}(where: { special_count_GTE: 1 }) { - title - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [ - { - title: "test", - }, - ], - }); - }); - - test("Point cypher field", async () => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_location: Point - @cypher( - statement: """ - RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l - """ - columnName: "l" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); - - const query = ` - query { - ${CustomType.plural}( - where: { - special_location_DISTANCE: { - point: { latitude: 1, longitude: 1 } - distance: 0 - } - } - ) { - title - special_location { - latitude - longitude - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [ - { - special_location: { - latitude: 1, - longitude: 1, - }, - title: "test", - }, - ], - }); - }); - - test("CartesianPoint cypher field", async () => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_location: CartesianPoint - @cypher( - statement: """ - RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l - """ - columnName: "l" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); - - const query = ` - query { - ${CustomType.plural}( - where: { - special_location_DISTANCE: { - point: { x: 1, y: 1, z: 2 } - distance: 1 - } - } - ) { - title - special_location { - x - y - z - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [ - { - special_location: { - x: 1, - y: 1, - z: 1, - }, - title: "test", - }, - ], - }); - }); - - test("DateTime cypher field", async () => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_time: DateTime - @cypher( - statement: """ - RETURN datetime("2024-09-03T15:30:00Z") AS t - """ - columnName: "t" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); - - const query = ` - query { - ${CustomType.plural}( - where: { - special_time_GT: "2024-09-02T00:00:00Z" - } - ) { - special_time - title - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [ - { - special_time: "2024-09-03T15:30:00.000Z", - title: "test", - }, - ], - }); - }); - - test("Duration cypher field", async () => { - const typeDefs = ` - type ${CustomType} @node { - title: String - special_duration: Duration - @cypher( - statement: """ - RETURN duration('P14DT16H12M') AS d - """ - columnName: "d" - ) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); - - const query = ` - query { - ${CustomType.plural}( - where: { - special_duration: "P14DT16H12M" - } - ) { - title - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [CustomType.plural]: [ - { - title: "test", - }, - ], - }); - }); - - test("With relationship filter (non-Cypher field)", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher( - ` - CREATE (m:${Movie} { title: "The Matrix" }) - CREATE (a:${Actor} { name: "Keanu Reeves" }) - CREATE (a)-[:ACTED_IN]->(m) - `, - {} - ); - - const query = ` - query { - ${Movie.plural}( - where: { - custom_field: "hello world!" - actors_SOME: { - name: "Keanu Reeves" - } - } - ) { - custom_field - title - actors { - name - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [Movie.plural]: [ - { - custom_field: "hello world!", - title: "The Matrix", - actors: [ - { - name: "Keanu Reeves", - }, - ], - }, - ], - }); - }); - - test("In a nested filter", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher( - ` - CREATE (m:${Movie} { title: "The Matrix" }) - CREATE (a:${Actor} { name: "Keanu Reeves" }) - CREATE (a)-[:ACTED_IN]->(m) - `, - {} - ); - - const query = ` - query { - ${Actor.plural} { - name - movies(where: { custom_field: "hello world!"}) { - title - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [Actor.plural]: [ - { - name: "Keanu Reeves", - movies: [ - { - title: "The Matrix", - }, - ], - }, - ], - }); - }); - - test("With a nested filter", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher( - ` - CREATE (m:${Movie} { title: "The Matrix" }) - CREATE (a:${Actor} { name: "Keanu Reeves" }) - CREATE (a)-[:ACTED_IN]->(m) - `, - {} - ); - - const query = ` - query { - ${Movie.plural}(where: { custom_field: "hello world!" }) { - title - actors(where: { name: "Keanu Reeves" }) { - name - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [Movie.plural]: [ - { - title: "The Matrix", - actors: [ - { - name: "Keanu Reeves", - }, - ], - }, - ], - }); - }); - - test("With authorization (custom Cypher field)", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ - typeDefs, - features: { - authorization: { - key: "secret", - }, - }, - }); - - const token = createBearerToken("secret", { title: "The Matrix" }); - - await testHelper.executeCypher( - ` - CREATE (m:${Movie} { title: "The Matrix" }) - CREATE (a:${Actor} { name: "Keanu Reeves" }) - CREATE (a)-[:ACTED_IN]->(m) - `, - {} - ); - - const query = ` - query { - ${Movie.plural}(where: { custom_field: "hello world!" }) { - title - custom_field - actors { - name - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQLWithToken(query, token); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [Movie.plural]: [ - { - title: "The Matrix", - custom_field: "hello world!", - actors: [ - { - name: "Keanu Reeves", - }, - ], - }, - ], - }); - }); - - test("With authorization (not custom Cypher field)", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ - typeDefs, - features: { - authorization: { - key: "secret", - }, - }, - }); - - const token = createBearerToken("secret", { title: "The Matrix" }); - - await testHelper.executeCypher( - ` - CREATE (m:${Movie} { title: "The Matrix" }) - CREATE (a:${Actor} { name: "Keanu Reeves" }) - CREATE (a)-[:ACTED_IN]->(m) - `, - {} - ); - - const query = ` - query { - ${Movie.plural}(where: { custom_field: "hello world!" }) { - title - actors { - name - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQLWithToken(query, token); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [Movie.plural]: [ - { - title: "The Matrix", - actors: [ - { - name: "Keanu Reeves", - }, - ], - }, - ], - }); - }); - - test("With sorting", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ - typeDefs, - }); - - await testHelper.executeCypher( - ` - CREATE (m1:${Movie} { title: "The Matrix" }) - CREATE (m2:${Movie} { title: "The Matrix Reloaded" }) - CREATE (a1:${Actor} { name: "Keanu Reeves" }) - CREATE (a2:${Actor} { name: "Jada Pinkett Smith" }) - CREATE (a1)-[:ACTED_IN]->(m1) - CREATE (a1)-[:ACTED_IN]->(m2) - CREATE (a2)-[:ACTED_IN]->(m2) - `, - {} - ); - - const query = ` - query { - ${Movie.plural}( - where: { custom_field: "hello world!" } - options: { sort: [{ title: DESC }] } - ) { - title - actors { - name - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [Movie.plural]: [ - { - title: "The Matrix Reloaded", - actors: expect.toIncludeSameMembers([ - { - name: "Keanu Reeves", - }, - { - name: "Jada Pinkett Smith", - }, - ]), - }, - { - title: "The Matrix", - actors: [ - { - name: "Keanu Reeves", - }, - ], - }, - ], - }); - }); - - test("Connect filter", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ - typeDefs, - }); - - await testHelper.executeCypher( - ` - CREATE (m:${Movie} { title: "The Matrix" }) - CREATE (a:${Actor} { name: "Keanu Reeves" }) - CREATE (a)-[:ACTED_IN]->(m) - `, - {} - ); - - const query = ` - mutation { - ${Movie.operations.create}( - input: [ - { - title: "The Matrix Reloaded" - actors: { - connect: [ - { - where: { - node: { - name: "Keanu Reeves", - custom_field: "hello world!" - } - } - } - ] - create: [ - { - node: { - name: "Jada Pinkett Smith" - } - } - ] - } - } - ] - ) { - ${Movie.plural} { - title - actors { - name - } - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data?.[Movie.operations.create]?.[Movie.plural]).toIncludeSameMembers([ - { - title: "The Matrix Reloaded", - actors: expect.toIncludeSameMembers([ - { - name: "Keanu Reeves", - }, - { - name: "Jada Pinkett Smith", - }, - ]), - }, - ]); - }); - - test("With two cypher fields", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - another_custom_field: Int - @cypher( - statement: """ - RETURN 100 AS i - """ - columnName: "i" - ) - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - another_custom_field: String - @cypher( - statement: """ - RETURN "goodbye!" AS s - """ - columnName: "s" - ) - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher( - ` - CREATE (m:${Movie} { title: "The Matrix" }) - CREATE (a:${Actor} { name: "Keanu Reeves" }) - CREATE (a)-[:ACTED_IN]->(m) - `, - {} - ); - - const query = ` - query { - ${Movie.plural}(where: { custom_field: "hello world!", another_custom_field_GT: 50 }) { - title - actors { - name - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [Movie.plural]: [ - { - title: "The Matrix", - actors: [ - { - name: "Keanu Reeves", - }, - ], - }, - ], - }); - }); - - test("With two cypher fields, one nested", async () => { - const Movie = testHelper.createUniqueType("Movie"); - const Actor = testHelper.createUniqueType("Actor"); - - const typeDefs = ` - type ${Movie} @node { - title: String - custom_field: String - @cypher( - statement: """ - RETURN "hello world!" AS s - """ - columnName: "s" - ) - actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) - } - - type ${Actor} @node { - name: String - another_custom_field: String - @cypher( - statement: """ - RETURN "goodbye!" AS s - """ - columnName: "s" - ) - movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) - } - `; - - await testHelper.initNeo4jGraphQL({ typeDefs }); - await testHelper.executeCypher( - ` - CREATE (m:${Movie} { title: "The Matrix" }) - CREATE (a:${Actor} { name: "Keanu Reeves" }) - CREATE (a)-[:ACTED_IN]->(m) - `, - {} - ); - - const query = ` - query { - ${Movie.plural}(where: { custom_field: "hello world!" }) { - title - actors(where: { another_custom_field: "goodbye!" name: "Keanu Reeves" }) { - name - } - } - } - `; - - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult?.data).toEqual({ - [Movie.plural]: [ - { - title: "The Matrix", - actors: [ - { - name: "Keanu Reeves", - }, - ], - }, - ], - }); - }); -}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-auth.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-auth.int.test.ts new file mode 100644 index 0000000000..497ea38d51 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-auth.int.test.ts @@ -0,0 +1,174 @@ +/* + * 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 { createBearerToken } from "../../../../utils/create-bearer-token"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("With authorization (custom Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const token = createBearerToken("secret", { title: "The Matrix" }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + custom_field + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + custom_field: "hello world!", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With authorization (not custom Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const token = createBearerToken("secret", { title: "The Matrix" }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-connect.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-connect.int.test.ts new file mode 100644 index 0000000000..d2be7e1b9d --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-connect.int.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("Connect filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + mutation { + ${Movie.operations.create}( + input: [ + { + title: "The Matrix Reloaded" + actors: { + connect: [ + { + where: { + node: { + name: "Keanu Reeves", + custom_field: "hello world!" + } + } + } + ] + create: [ + { + node: { + name: "Jada Pinkett Smith" + } + } + ] + } + } + ] + ) { + ${Movie.plural} { + title + actors { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data?.[Movie.operations.create]?.[Movie.plural]).toIncludeSameMembers([ + { + title: "The Matrix Reloaded", + actors: expect.toIncludeSameMembers([ + { + name: "Keanu Reeves", + }, + { + name: "Jada Pinkett Smith", + }, + ]), + }, + ]); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-misc.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-misc.int.test.ts new file mode 100644 index 0000000000..c6b833b06c --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-misc.int.test.ts @@ -0,0 +1,363 @@ +/* + * 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 { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("With relationship filter (non-Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}( + where: { + custom_field: "hello world!" + actors_SOME: { + name: "Keanu Reeves" + } + } + ) { + custom_field + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + custom_field: "hello world!", + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("In a nested filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Actor.plural} { + name + movies(where: { custom_field: "hello world!"}) { + title + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Actor.plural]: [ + { + name: "Keanu Reeves", + movies: [ + { + title: "The Matrix", + }, + ], + }, + ], + }); + }); + + test("With a nested filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors(where: { name: "Keanu Reeves" }) { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With two cypher fields", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + another_custom_field: Int + @cypher( + statement: """ + RETURN 100 AS i + """ + columnName: "i" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!", another_custom_field_GT: 50 }) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With two cypher fields, one nested", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors(where: { another_custom_field: "goodbye!" name: "Keanu Reeves" }) { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-scalar.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-scalar.int.test.ts new file mode 100644 index 0000000000..6ce947d625 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-scalar.int.test.ts @@ -0,0 +1,288 @@ +/* + * 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 filtering", () => { + let CustomType: UniqueType; + + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + beforeEach(() => { + CustomType = testHelper.createUniqueType("CustomType"); + }); + + test.each([ + { + title: "Int cypher field: exact match", + filter: `special_count: 1`, + }, + { + title: "Int cypher field: GT", + filter: `special_count_GT: 0`, + }, + { + title: "Int cypher field: GTE", + filter: `special_count_GTE: 1`, + }, + { + title: "Int cypher field: LT", + filter: `special_count_LT: 2`, + }, + { + title: "Int cypher field: LTE", + filter: `special_count_LTE: 2`, + }, + { + title: "Int cypher field: IN", + filter: `special_count_IN: [1, 2, 3]`, + }, + ] as const)("$title", async ({ filter }) => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { ${filter} }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_count: 1, + }, + ], + }); + }); + + test.each([ + { + title: "String cypher field: exact match", + filter: `special_word: "test"`, + }, + { + title: "String cypher field: CONTAINS", + filter: `special_word_CONTAINS: "es"`, + }, + { + title: "String cypher field: ENDS_WITH", + filter: `special_word_ENDS_WITH: "est"`, + }, + { + title: "String cypher field: STARTS_WITH", + filter: `special_word_STARTS_WITH: "tes"`, + }, + { + title: "String cypher field: IN", + filter: `special_word_IN: ["test", "test2"]`, + }, + ] as const)("$title", async ({ filter }) => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_word: String + @cypher( + statement: """ + RETURN "test" as s + """ + columnName: "s" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { ${filter} }) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Int cypher field AND String title field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + UNWIND [ + {title: 'CustomType One' }, + {title: 'CustomType Two' }, + {title: 'CustomType Three' } + ] AS CustomTypeData + CREATE (m:${CustomType}) + SET m = CustomTypeData; + `, + {} + ); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1, title: "CustomType One" }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_count: 3, + }, + ], + }); + }); + + test("unmatched Int cypher field AND String title field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + UNWIND [ + {title: 'CustomType One' }, + {title: 'CustomType Two' }, + {title: 'CustomType Three' } + ] AS CustomTypeData + CREATE (m:${CustomType}) + SET m = CustomTypeData; + `, + {} + ); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1, title: "CustomType Unknown" }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [], + }); + }); + + test("Int cypher field, selecting String title field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1 }) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-sorting.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-sorting.int.test.ts new file mode 100644 index 0000000000..d6729fb20d --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-sorting.int.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("With sorting", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + }); + + await testHelper.executeCypher( + ` + CREATE (m1:${Movie} { title: "The Matrix" }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded" }) + CREATE (a1:${Actor} { name: "Keanu Reeves" }) + CREATE (a2:${Actor} { name: "Jada Pinkett Smith" }) + CREATE (a1)-[:ACTED_IN]->(m1) + CREATE (a1)-[:ACTED_IN]->(m2) + CREATE (a2)-[:ACTED_IN]->(m2) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}( + where: { custom_field: "hello world!" } + options: { sort: [{ title: DESC }] } + ) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix Reloaded", + actors: expect.toIncludeSameMembers([ + { + name: "Keanu Reeves", + }, + { + name: "Jada Pinkett Smith", + }, + ]), + }, + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-spatial.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-spatial.int.test.ts new file mode 100644 index 0000000000..51a1900796 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-spatial.int.test.ts @@ -0,0 +1,141 @@ +/* + * 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 filtering", () => { + let CustomType: UniqueType; + + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + beforeEach(() => { + CustomType = testHelper.createUniqueType("CustomType"); + }); + + test("Point cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_location: Point + @cypher( + statement: """ + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_location_DISTANCE: { + point: { latitude: 1, longitude: 1 } + distance: 0 + } + } + ) { + title + special_location { + latitude + longitude + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_location: { + latitude: 1, + longitude: 1, + }, + title: "test", + }, + ], + }); + }); + + test("CartesianPoint cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_location: CartesianPoint + @cypher( + statement: """ + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_location_DISTANCE: { + point: { x: 1, y: 1, z: 2 } + distance: 1 + } + } + ) { + title + special_location { + x + y + z + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_location: { + x: 1, + y: 1, + z: 1, + }, + title: "test", + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts new file mode 100644 index 0000000000..505d35ff7e --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts @@ -0,0 +1,119 @@ +/* + * 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 filtering", () => { + let CustomType: UniqueType; + + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + beforeEach(() => { + CustomType = testHelper.createUniqueType("CustomType"); + }); + + test("DateTime cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_time: DateTime + @cypher( + statement: """ + RETURN datetime("2024-09-03T15:30:00Z") AS t + """ + columnName: "t" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_time_GT: "2024-09-02T00:00:00Z" + } + ) { + special_time + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_time: "2024-09-03T15:30:00.000Z", + title: "test", + }, + ], + }); + }); + + test("Duration cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration: "P14DT16H12M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); +}); From ad9119c4bc57573c3be088ad231acdc04e59c42f Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 12 Sep 2024 09:52:39 +0200 Subject: [PATCH 13/16] test: add more duration tests --- .../cypher-filtering-temporal.int.test.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts index 505d35ff7e..fe97cdcc13 100644 --- a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts @@ -116,4 +116,168 @@ describe("cypher directive filtering", () => { ], }); }); + + test("Duration cypher field LT", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration_LT: "P14DT16H13M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Duration cypher field LTE", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration_LTE: "P14DT16H12M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Duration cypher field GT", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration_GT: "P14DT16H11M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Duration cypher field GTE", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration_GTE: "P14DT16H12M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); }); From ac16c63b6453e3e53728e21984b970f57dc2c7e3 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 12 Sep 2024 11:43:00 +0200 Subject: [PATCH 14/16] feat: exclude deprecated filters on custom cypher fields --- packages/graphql/src/schema/get-where-fields.ts | 4 ++-- packages/graphql/tests/schema/directives/cypher.test.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/graphql/src/schema/get-where-fields.ts b/packages/graphql/src/schema/get-where-fields.ts index 927a9b47d1..0928263556 100644 --- a/packages/graphql/src/schema/get-where-fields.ts +++ b/packages/graphql/src/schema/get-where-fields.ts @@ -214,7 +214,7 @@ export function getWhereFieldsForAttributes({ directives: deprecatedDirectives, }; - if (shouldAddDeprecatedFields(features, "negationFilters")) { + if (shouldAddDeprecatedFields(features, "negationFilters") && !field.isCypher()) { result[`${field.name}_NOT`] = { type: field.getInputTypeNames().where.pretty, directives: deprecatedDirectives.length ? deprecatedDirectives : [DEPRECATE_NOT], @@ -247,7 +247,7 @@ export function getWhereFieldsForAttributes({ type: field.getFilterableInputTypeName(), directives: deprecatedDirectives, }; - if (shouldAddDeprecatedFields(features, "negationFilters")) { + if (shouldAddDeprecatedFields(features, "negationFilters") && !field.isCypher()) { result[`${field.name}_NOT_IN`] = { type: field.getFilterableInputTypeName(), directives: deprecatedDirectives.length ? deprecatedDirectives : [DEPRECATE_NOT], diff --git a/packages/graphql/tests/schema/directives/cypher.test.ts b/packages/graphql/tests/schema/directives/cypher.test.ts index 236c522751..2320d8ace7 100644 --- a/packages/graphql/tests/schema/directives/cypher.test.ts +++ b/packages/graphql/tests/schema/directives/cypher.test.ts @@ -363,8 +363,6 @@ describe("Cypher", () => { totalScreenTime_IN: [Int!] totalScreenTime_LT: Int totalScreenTime_LTE: Int - totalScreenTime_NOT: Int @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") - totalScreenTime_NOT_IN: [Int!] @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") } type ActorsConnection { From bd2404f21026cabba97afb053029cce42654ce98 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 12 Sep 2024 12:50:58 +0200 Subject: [PATCH 15/16] feat: add Duration type path to the CypherFilter --- .../filters/property-filters/CypherFilter.ts | 30 +++++++++------- .../property-filters/DurationFilter.ts | 34 ++++--------------- .../filters/property-filters/PointFilter.ts | 2 +- .../property-filters/PropertyFilter.ts | 20 ++++------- .../ast/filters/utils/coalesce-if-needed.ts | 11 ++++++ .../utils/create-duration-operation.ts | 23 +++++++++++++ .../filters}/utils/create-point-operation.ts | 4 +-- 7 files changed, 68 insertions(+), 56 deletions(-) create mode 100644 packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts create mode 100644 packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts rename packages/graphql/src/translate/queryAST/{ => ast/filters}/utils/create-point-operation.ts (94%) diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts index b379db2ddd..d8caf79e4f 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts @@ -20,12 +20,14 @@ import Cypher from "@neo4j/cypher-builder"; import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; import { createComparisonOperation } from "../../../utils/create-comparison-operator"; -import { createPointOperation } from "../../../utils/create-point-operation"; import type { QueryASTContext } from "../../QueryASTContext"; import type { QueryASTNode } from "../../QueryASTNode"; import type { CustomCypherSelection } from "../../selection/CustomCypherSelection"; import type { FilterOperator } from "../Filter"; import { Filter } from "../Filter"; +import { coalesceValueIfNeeded } from "../utils/coalesce-if-needed"; +import { createDurationOperation } from "../utils/create-duration-operation"; +import { createPointOperation } from "../utils/create-point-operation"; /** A property which comparison has already been parsed into a Param */ export class CypherFilter extends Filter { @@ -82,15 +84,6 @@ export class CypherFilter extends Filter { return operation; } - private coalesceValueIfNeeded(expr: Cypher.Expr): Cypher.Expr { - if (this.attribute.annotations.coalesce) { - const value = this.attribute.annotations.coalesce.value; - const literal = new Cypher.Literal(value); - return Cypher.coalesce(expr, literal); - } - return expr; - } - /** Returns the default operation for a given filter */ private createBaseOperation({ operator, @@ -101,9 +94,22 @@ export class CypherFilter extends Filter { property: Cypher.Expr; param: Cypher.Expr; }): Cypher.ComparisonOp { - const coalesceProperty = this.coalesceValueIfNeeded(property); + const coalesceProperty = coalesceValueIfNeeded(this.attribute, property); + + // This could be solved with specific a specific CypherDurationFilter but + // we need to use the return variable for the cypher subquery. + // To allow us to extend the DurationFilter class with a CypherDurationFilter class + // we would need to have a way to provide the return variable + // to the PropertyFilter's getPropertyRef method. + if (this.attribute.typeHelper.isDuration()) { + return createDurationOperation({ + operator, + property: coalesceProperty, + param: new Cypher.Param(this.comparisonValue), + }); + } - if (operator === "DISTANCE") { + if (this.attribute.typeHelper.isSpatial()) { return createPointOperation({ operator, property: coalesceProperty, diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/DurationFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/DurationFilter.ts index 3f3e19ed22..6ee83e0cd6 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/DurationFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/DurationFilter.ts @@ -18,38 +18,18 @@ */ import Cypher from "@neo4j/cypher-builder"; -import type { WhereOperator } from "../Filter"; +import { coalesceValueIfNeeded } from "../utils/coalesce-if-needed"; +import { createDurationOperation } from "../utils/create-duration-operation"; import { PropertyFilter } from "./PropertyFilter"; export class DurationFilter extends PropertyFilter { - protected getOperation(prop: Cypher.Property): Cypher.ComparisonOp { - // NOTE: this may not be needed - if (this.operator === "EQ") { - return Cypher.eq(prop, new Cypher.Param(this.comparisonValue)); - } - return this.createDurationOperation({ + protected getOperation(prop: Cypher.Expr): Cypher.ComparisonOp { + const coalesceProperty = coalesceValueIfNeeded(this.attribute, prop); + + return createDurationOperation({ operator: this.operator, - property: prop, + property: coalesceProperty, param: new Cypher.Param(this.comparisonValue), }); } - - private createDurationOperation({ - operator, - property, - param, - }: { - operator: WhereOperator | "EQ"; - property: Cypher.Expr; - param: Cypher.Expr; - }) { - const variable = Cypher.plus(Cypher.datetime(), param); - const propertyRef = Cypher.plus(Cypher.datetime(), property); - - return this.createBaseOperation({ - operator, - property: propertyRef, - param: variable, - }); - } } diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts index e2bb89e042..3c557711df 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts @@ -18,7 +18,7 @@ */ import Cypher from "@neo4j/cypher-builder"; -import { createPointOperation } from "../../../utils/create-point-operation"; +import { createPointOperation } from "../utils/create-point-operation"; import { PropertyFilter } from "./PropertyFilter"; export class PointFilter extends PropertyFilter { diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts index fbc82bf0de..8e4d5d5995 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts @@ -19,14 +19,15 @@ import Cypher from "@neo4j/cypher-builder"; import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; +import { InterfaceEntityAdapter } from "../../../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; +import type { RelationshipAdapter } from "../../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import { hasTarget } from "../../../utils/context-has-target"; import { createComparisonOperation } from "../../../utils/create-comparison-operator"; import type { QueryASTContext } from "../../QueryASTContext"; import type { QueryASTNode } from "../../QueryASTNode"; import type { FilterOperator } from "../Filter"; import { Filter } from "../Filter"; -import { hasTarget } from "../../../utils/context-has-target"; -import type { RelationshipAdapter } from "../../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; -import { InterfaceEntityAdapter } from "../../../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; +import { coalesceValueIfNeeded } from "../utils/coalesce-if-needed"; export class PropertyFilter extends Filter { protected attribute: AttributeAdapter; @@ -130,7 +131,7 @@ export class PropertyFilter extends Filter { /** Returns the operation for a given filter. * To be overridden by subclasses */ - protected getOperation(prop: Cypher.Property | Cypher.Case): Cypher.ComparisonOp { + protected getOperation(prop: Cypher.Expr): Cypher.ComparisonOp { return this.createBaseOperation({ operator: this.operator, property: prop, @@ -148,20 +149,11 @@ export class PropertyFilter extends Filter { property: Cypher.Expr; param: Cypher.Expr; }): Cypher.ComparisonOp { - const coalesceProperty = this.coalesceValueIfNeeded(property); + const coalesceProperty = coalesceValueIfNeeded(this.attribute, property); return createComparisonOperation({ operator, property: coalesceProperty, param }); } - protected coalesceValueIfNeeded(expr: Cypher.Expr): Cypher.Expr { - if (this.attribute.annotations.coalesce) { - const value = this.attribute.annotations.coalesce.value; - const literal = new Cypher.Literal(value); - return Cypher.coalesce(expr, literal); - } - return expr; - } - private getNullPredicate(propertyRef: Cypher.Property | Cypher.Case): Cypher.Predicate { if (this.isNot) { return Cypher.isNotNull(propertyRef); diff --git a/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts b/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts new file mode 100644 index 0000000000..1579d8ef5c --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts @@ -0,0 +1,11 @@ +import Cypher from "@neo4j/cypher-builder"; +import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; + +export function coalesceValueIfNeeded(attribute: AttributeAdapter, expr: Cypher.Expr): Cypher.Expr { + if (attribute.annotations.coalesce) { + const value = attribute.annotations.coalesce.value; + const literal = new Cypher.Literal(value); + return Cypher.coalesce(expr, literal); + } + return expr; +} diff --git a/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts new file mode 100644 index 0000000000..4cd68f0ca6 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts @@ -0,0 +1,23 @@ +import Cypher from "@neo4j/cypher-builder"; +import { createComparisonOperation } from "../../../utils/create-comparison-operator"; +import type { WhereOperator } from "../Filter"; + +export function createDurationOperation({ + operator, + property, + param, +}: { + operator: WhereOperator | "EQ"; + property: Cypher.Expr; + param: Cypher.Expr; +}): Cypher.ComparisonOp { + // NOTE: When we simply compare values, we don't need to prepend Cypher.datetime() + if (operator === "EQ") { + return Cypher.eq(property, param); + } + + const variable = Cypher.plus(Cypher.datetime(), param); + const propertyRef = Cypher.plus(Cypher.datetime(), property); + + return createComparisonOperation({ operator, property: propertyRef, param: variable }); +} diff --git a/packages/graphql/src/translate/queryAST/utils/create-point-operation.ts b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-point-operation.ts similarity index 94% rename from packages/graphql/src/translate/queryAST/utils/create-point-operation.ts rename to packages/graphql/src/translate/queryAST/ast/filters/utils/create-point-operation.ts index fae45986b3..12176431e4 100644 --- a/packages/graphql/src/translate/queryAST/utils/create-point-operation.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-point-operation.ts @@ -18,8 +18,8 @@ */ import Cypher from "@neo4j/cypher-builder"; -import type { AttributeAdapter } from "../../../schema-model/attribute/model-adapters/AttributeAdapter"; -import type { WhereOperator } from "../ast/filters/Filter"; +import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; +import type { WhereOperator } from "../Filter"; export function createPointOperation({ operator, From b53e5acb97faec44e92ffd262ff901bfa1d136b4 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 12 Sep 2024 12:56:06 +0200 Subject: [PATCH 16/16] docs: add license headers --- .../ast/filters/utils/coalesce-if-needed.ts | 19 +++++++++++++++++++ .../utils/create-duration-operation.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts b/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts index 1579d8ef5c..feef64401e 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts @@ -1,3 +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 Cypher from "@neo4j/cypher-builder"; import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; diff --git a/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts index 4cd68f0ca6..e713aeca23 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts @@ -1,3 +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 Cypher from "@neo4j/cypher-builder"; import { createComparisonOperation } from "../../../utils/create-comparison-operator"; import type { WhereOperator } from "../Filter";