diff --git a/.changeset/friendly-pigs-wait.md b/.changeset/friendly-pigs-wait.md new file mode 100644 index 0000000000..a088586463 --- /dev/null +++ b/.changeset/friendly-pigs-wait.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graphql": patch +--- + +Fix Cypher when filtering by aggregations over different relationship properites types diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index 98aecca30c..68b4849628 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -684,6 +684,14 @@ export class FilterFactory { } if (fieldName === "edge") { + if (isInterfaceEntity(relationship.target)) { + return Object.entries(value).flatMap(([k, v]) => { + if (k === relationship.propertiesTypeName) { + return this.createAggregationNodeFilters(v as Record, relationship); + } + return []; + }); + } return this.createAggregationNodeFilters(value as Record, relationship); } diff --git a/packages/graphql/tests/integration/aggregations/where/edge/string.int.test.ts b/packages/graphql/tests/integration/aggregations/where/edge/string.int.test.ts index 044c4c1b30..bcd1393807 100644 --- a/packages/graphql/tests/integration/aggregations/where/edge/string.int.test.ts +++ b/packages/graphql/tests/integration/aggregations/where/edge/string.int.test.ts @@ -946,3 +946,112 @@ describe("aggregations-where-edge-string interface relationships of concrete typ }); }); }); + +describe("aggregations-where-edge-string interface relationships of interface types", () => { + let testHelper: TestHelper; + + let Movie: UniqueType; + let Series: UniqueType; + let Production: UniqueType; + let Actor: UniqueType; + let Cameo: UniqueType; + let Person: UniqueType; + + beforeEach(async () => { + testHelper = new TestHelper(); + + Movie = testHelper.createUniqueType("Movie"); + Series = testHelper.createUniqueType("Series"); + Production = testHelper.createUniqueType("Production"); + Actor = testHelper.createUniqueType("Actor"); + Cameo = testHelper.createUniqueType("Cameo"); + Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + interface ${Production} { + title: String + } + + type ${Movie} implements ${Production} @node { + title: String + } + + type ${Series} implements ${Production} @node { + title: String + } + + interface ${Person} { + name: String + productions: [${Production}!]! @declareRelationship + } + + type ${Actor} implements ${Person} @node { + name: String + productions: [${Production}!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type ${Cameo} implements ${Person} @node { + name: String + productions: [${Production}!]! @relationship(type: "APPEARED_IN", direction: OUT, properties: "AppearedIn") + } + + type ActedIn @relationshipProperties { + role: String + } + + type AppearedIn @relationshipProperties { + role: String + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("should return nodes aggregated across different relationship properties types", async () => { + await testHelper.executeCypher( + ` + CREATE (a:${Actor} { name: "A" })-[:ACTED_IN { role: "definitely too long" }]->(g:${Movie} { title: "G" }) + CREATE (a)-[:ACTED_IN { role: "extremely long" }]->(g) + CREATE (b:${Actor} { name: "B" })-[:ACTED_IN { role: "a" }]->(h:${Series} { title: "H" }) + CREATE (b)-[:ACTED_IN { role: "b" }]->(h) + CREATE (c:${Actor} { name: "C" }) + CREATE (d:${Cameo} { name: "D" })-[:APPEARED_IN { role: "too long" }]->(i:${Movie} { title: "I" }) + CREATE (d)-[:APPEARED_IN { role: "also too long" }]->(i) + CREATE (e:${Cameo} { name: "E" })-[:APPEARED_IN { role: "s" }]->(j:${Series} { title: "J" }) + CREATE (e)-[:APPEARED_IN { role: "very long" }]->(j) + CREATE (f:${Cameo} { name: "F" }) + ` + ); + + const query = /* GraphQL */ ` + query People { + ${Person.plural}( + where: { + productionsAggregate: { + edge: { + AppearedIn: { role: { shortestLength: { lt: 3 } } } + ActedIn: { role: { averageLength: { lt: 5 } } } + } + } + } + ) { + name + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any)[Person.plural]).toIncludeSameMembers([{ name: "E" }, { name: "B" }]); + }); +}); diff --git a/packages/graphql/tests/tck/aggregations/where/edge/interface-relationship.test.ts b/packages/graphql/tests/tck/aggregations/where/edge/interface-relationship.test.ts new file mode 100644 index 0000000000..0961b14cbe --- /dev/null +++ b/packages/graphql/tests/tck/aggregations/where/edge/interface-relationship.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Neo4jGraphQL } from "../../../../../src"; +import { formatCypher, formatParams, translateQuery } from "../../../utils/tck-test-utils"; + +describe("Cypher Aggregations where edge with String", () => { + let typeDefs: string; + let neoSchema: Neo4jGraphQL; + + beforeAll(() => { + typeDefs = /* GraphQL */ ` + interface Production { + title: String + } + + type Movie implements Production @node { + title: String + } + + type Series implements Production @node { + title: String + } + + interface Person { + name: String + productions: [Production!]! @declareRelationship + } + + type Actor implements Person @node { + name: String + productions: [Production!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Cameo implements Person @node { + name: String + productions: [Production!]! @relationship(type: "APPEARED_IN", direction: OUT, properties: "AppearedIn") + } + + type ActedIn @relationshipProperties { + role: String + } + + type AppearedIn @relationshipProperties { + role: String + } + `; + + neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + }); + + test("should count number of interface relationships", async () => { + const query = /* GraphQL */ ` + query ActorsAggregate { + actors(where: { productionsAggregate: { count: { lt: 3 } } }) { + name + } + } + `; + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Actor) + CALL { + WITH this + MATCH (this)-[this0:ACTED_IN]->(this1) + WHERE (this1:Movie OR this1:Series) + RETURN count(this1) < $param0 AS var2 + } + WITH * + WHERE var2 = true + RETURN this { .name } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"low\\": 3, + \\"high\\": 0 + } + }" + `); + }); + + test("should generate Cypher to aggregate over multiple relationship properties types", async () => { + const query = /* GraphQL */ ` + query People { + people( + where: { + productionsAggregate: { + edge: { + AppearedIn: { role: { shortestLength: { lt: 3 } } } + ActedIn: { role: { averageLength: { lt: 5 } } } + } + } + } + ) { + name + } + } + `; + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL { + MATCH (this0:Actor) + CALL { + WITH this0 + MATCH (this0)-[this1:ACTED_IN]->(this2) + WHERE (this2:Movie OR this2:Series) + RETURN avg(size(this1.role)) < $param0 AS var3 + } + WITH * + WHERE var3 = true + WITH this0 { .name, __resolveType: \\"Actor\\", __id: id(this0) } AS this0 + RETURN this0 AS this + UNION + MATCH (this4:Cameo) + CALL { + WITH this4 + MATCH (this4)-[this5:APPEARED_IN]->(this6) + WHERE (this6:Movie OR this6:Series) + RETURN min(size(this5.role)) < $param1 AS var7 + } + WITH * + WHERE var7 = true + WITH this4 { .name, __resolveType: \\"Cameo\\", __id: id(this4) } AS this4 + RETURN this4 AS this + } + WITH this + RETURN this AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": 5, + \\"param1\\": { + \\"low\\": 3, + \\"high\\": 0 + } + }" + `); + }); +});