From b73771a472dee8ff0fc9e36f7b57134d8d1676a0 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Fri, 11 Oct 2024 13:36:31 +0200 Subject: [PATCH 01/20] feat: set target entity in Cypher Annotation --- .../schema-model/Neo4jGraphQLSchemaModel.ts | 5 ++++ .../annotation/CypherAnnotation.ts | 2 ++ .../src/schema-model/generate-model.ts | 25 ++++++++++++++++--- .../annotations-parser/cypher-annotation.ts | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts b/packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts index 071d3bf45c..82e9a60315 100644 --- a/packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts +++ b/packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts @@ -69,6 +69,11 @@ export class Neo4jGraphQLSchemaModel { return concreteEntity ? new ConcreteEntityAdapter(concreteEntity) : undefined; } + public getConcreteEntity(name: string): ConcreteEntity | undefined { + const concreteEntity = this.concreteEntities.find((entity) => entity.name === name); + return concreteEntity; + } + public getEntitiesByLabels(labels: string[]): ConcreteEntity[] { return this.concreteEntities.filter((entity) => entity.matchLabels(labels)); } diff --git a/packages/graphql/src/schema-model/annotation/CypherAnnotation.ts b/packages/graphql/src/schema-model/annotation/CypherAnnotation.ts index d060f2dab4..faed83a74e 100644 --- a/packages/graphql/src/schema-model/annotation/CypherAnnotation.ts +++ b/packages/graphql/src/schema-model/annotation/CypherAnnotation.ts @@ -17,12 +17,14 @@ * limitations under the License. */ +import type { ConcreteEntity } from "../entity/ConcreteEntity"; import type { Annotation } from "./Annotation"; export class CypherAnnotation implements Annotation { readonly name = "cypher"; public statement: string; public columnName: string; + public targetEntity?: ConcreteEntity; constructor({ statement, columnName }: { statement: string; columnName: string }) { this.statement = statement; diff --git a/packages/graphql/src/schema-model/generate-model.ts b/packages/graphql/src/schema-model/generate-model.ts index b930863127..9fabefc6b8 100644 --- a/packages/graphql/src/schema-model/generate-model.ts +++ b/packages/graphql/src/schema-model/generate-model.ts @@ -25,7 +25,6 @@ import type { UnionTypeDefinitionNode, } from "graphql"; import { Neo4jGraphQLSchemaValidationError } from "../classes"; -import { SCHEMA_CONFIGURATION_OBJECT_DIRECTIVES } from "./library-directives"; import { declareRelationshipDirective, nodeDirective, @@ -33,15 +32,20 @@ import { relationshipDirective, } from "../graphql/directives"; import getFieldTypeMeta from "../schema/get-field-type-meta"; +import { getInnerTypeName } from "../schema/validation/custom-rules/utils/utils"; +import { isInArray } from "../utils/is-in-array"; import { filterTruthy } from "../utils/utils"; import type { Operations } from "./Neo4jGraphQLSchemaModel"; import { Neo4jGraphQLSchemaModel } from "./Neo4jGraphQLSchemaModel"; import { Operation } from "./Operation"; import type { Attribute } from "./attribute/Attribute"; +import { ObjectType } from "./attribute/AttributeType"; import type { CompositeEntity } from "./entity/CompositeEntity"; import { ConcreteEntity } from "./entity/ConcreteEntity"; +import type { Entity } from "./entity/Entity"; import { InterfaceEntity } from "./entity/InterfaceEntity"; import { UnionEntity } from "./entity/UnionEntity"; +import { SCHEMA_CONFIGURATION_OBJECT_DIRECTIVES } from "./library-directives"; import type { DefinitionCollection } from "./parser/definition-collection"; import { getDefinitionCollection } from "./parser/definition-collection"; import { parseAnnotations } from "./parser/parse-annotation"; @@ -50,10 +54,7 @@ import { parseAttribute, parseAttributeArguments } from "./parser/parse-attribut import { findDirective } from "./parser/utils"; import type { NestedOperation, QueryDirection, RelationshipDirection } from "./relationship/Relationship"; import { Relationship } from "./relationship/Relationship"; -import { isInArray } from "../utils/is-in-array"; import { RelationshipDeclaration } from "./relationship/RelationshipDeclaration"; -import type { Entity } from "./entity/Entity"; -import { getInnerTypeName } from "../schema/validation/custom-rules/utils/utils"; export function generateModel(document: DocumentNode): Neo4jGraphQLSchemaModel { const definitionCollection: DefinitionCollection = getDefinitionCollection(document); @@ -111,6 +112,9 @@ export function generateModel(document: DocumentNode): Neo4jGraphQLSchemaModel { annotations, }); definitionCollection.nodes.forEach((def) => hydrateRelationships(def, schema, definitionCollection)); + + hydrateCypherAnnotations(schema, concreteEntities); + definitionCollection.interfaceTypes.forEach((def) => hydrateRelationshipDeclarations(def, schema, definitionCollection) ); @@ -127,6 +131,18 @@ function addCompositeEntitiesToConcreteEntity(compositeEntities: CompositeEntity }); } +function hydrateCypherAnnotations(schema: Neo4jGraphQLSchemaModel, concreteEntities: ConcreteEntity[]) { + for (const concreteEntity of concreteEntities) { + for (const attributeField of concreteEntity.attributes.values()) { + if (attributeField.annotations.cypher) { + if (attributeField.type instanceof ObjectType) { + attributeField.annotations.cypher.targetEntity = schema.getConcreteEntity(attributeField.type.name); + } + } + } + } +} + function hydrateInterfacesToTypeNamesMap(definitionCollection: DefinitionCollection) { return definitionCollection.nodes.forEach((node) => { if (!node.interfaces) { @@ -261,6 +277,7 @@ function hydrateRelationships( } } } + function hydrateRelationshipDeclarations( definition: InterfaceTypeDefinitionNode, schema: Neo4jGraphQLSchemaModel, diff --git a/packages/graphql/src/schema-model/parser/annotations-parser/cypher-annotation.ts b/packages/graphql/src/schema-model/parser/annotations-parser/cypher-annotation.ts index d9bf153b56..4a1924eae7 100644 --- a/packages/graphql/src/schema-model/parser/annotations-parser/cypher-annotation.ts +++ b/packages/graphql/src/schema-model/parser/annotations-parser/cypher-annotation.ts @@ -18,9 +18,9 @@ */ import type { DirectiveNode } from "graphql"; import { Neo4jGraphQLSchemaValidationError } from "../../../classes"; +import { cypherDirective } from "../../../graphql/directives"; import { CypherAnnotation } from "../../annotation/CypherAnnotation"; import { parseArguments } from "../parse-arguments"; -import { cypherDirective } from "../../../graphql/directives"; export function parseCypherAnnotation(directive: DirectiveNode): CypherAnnotation { const { statement, columnName } = parseArguments(cypherDirective, directive); From 85a59a825fc223d1e0cd9ed1308f68064f7ab358 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Fri, 11 Oct 2024 13:36:57 +0200 Subject: [PATCH 02/20] refactor: rename to augmentRelationshipWhereInputType --- .../graphql/src/schema/generation/augment-where-input.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/graphql/src/schema/generation/augment-where-input.ts b/packages/graphql/src/schema/generation/augment-where-input.ts index fcb2b08a3f..ad825570cb 100644 --- a/packages/graphql/src/schema/generation/augment-where-input.ts +++ b/packages/graphql/src/schema/generation/augment-where-input.ts @@ -24,7 +24,7 @@ import type { RelationshipDeclarationAdapter } from "../../schema-model/relation import type { Neo4jFeaturesSettings } from "../../types"; import { shouldAddDeprecatedFields } from "./utils"; -function augmentWhereInputType({ +function augmentRelationshipWhereInputType({ whereType, fieldName, filters, @@ -101,7 +101,7 @@ export function augmentWhereInputTypeWithRelationshipFields( features: Neo4jFeaturesSettings | undefined ): InputTypeComposerFieldConfigMapDefinition { const filters = relationshipAdapter.listFiltersModel?.filters; - return augmentWhereInputType({ + return augmentRelationshipWhereInputType({ whereType: relationshipAdapter.target.operations.whereInputTypeName, fieldName: relationshipAdapter.name, filters, @@ -117,7 +117,7 @@ export function augmentWhereInputTypeWithConnectionFields( features: Neo4jFeaturesSettings | undefined ): InputTypeComposerFieldConfigMapDefinition { const filters = relationshipAdapter.listFiltersModel?.connectionFilters; - return augmentWhereInputType({ + return augmentRelationshipWhereInputType({ whereType: relationshipAdapter.operations.getConnectionWhereTypename(), fieldName: relationshipAdapter.operations.connectionFieldName, filters, From d12e584804442b9dbdf5971d1743394421a5bbfc Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Fri, 11 Oct 2024 13:42:55 +0200 Subject: [PATCH 03/20] feat: generate where field schema for cypher with target entity --- .../model-adapters/AttributeAdapter.ts | 5 ++- .../graphql/src/schema/get-where-fields.ts | 17 ++++++++ .../tests/schema/directives/cypher.test.ts | 42 +++++++++++++++++-- 3 files changed, 60 insertions(+), 4 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 126b379da1..41c6a42f60 100644 --- a/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts +++ b/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts @@ -133,7 +133,10 @@ export class AttributeAdapter { isWhereField(): boolean { return ( - (this.typeHelper.isEnum() || this.typeHelper.isSpatial() || this.typeHelper.isScalar()) && + (this.typeHelper.isEnum() || + this.typeHelper.isSpatial() || + this.typeHelper.isScalar() || + (this.isCypher() && Boolean(this.annotations.cypher?.targetEntity))) && this.isFilterable() && !this.isCustomResolvable() ); diff --git a/packages/graphql/src/schema/get-where-fields.ts b/packages/graphql/src/schema/get-where-fields.ts index bc0a1a4a3e..cde742cfce 100644 --- a/packages/graphql/src/schema/get-where-fields.ts +++ b/packages/graphql/src/schema/get-where-fields.ts @@ -21,6 +21,7 @@ import type { DirectiveNode } from "graphql"; import type { Directive } from "graphql-compose"; import { DEPRECATED } from "../constants"; import type { AttributeAdapter } from "../schema-model/attribute/model-adapters/AttributeAdapter"; +import { ConcreteEntityAdapter } from "../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import type { Neo4jFeaturesSettings } from "../types"; import { DEPRECATE_NOT } from "./constants"; import { shouldAddDeprecatedFields } from "./generation/utils"; @@ -69,6 +70,22 @@ export function getWhereFieldsForAttributes({ if (field.args.length > 0) { continue; } + + if (field.annotations.cypher.targetEntity) { + const targetEntityAdapter = new ConcreteEntityAdapter(field.annotations.cypher.targetEntity); + result[field.name] = { + type: targetEntityAdapter.operations.whereInputTypeName, + directives: deprecatedDirectives, + }; + + if (shouldAddDeprecatedFields(features, "negationFilters")) { + result[`${field.name}_NOT`] = { + type: targetEntityAdapter.operations.whereInputTypeName, + directives: deprecatedDirectives.length ? deprecatedDirectives : [DEPRECATE_NOT], + }; + } + continue; + } } result[field.name] = { diff --git a/packages/graphql/tests/schema/directives/cypher.test.ts b/packages/graphql/tests/schema/directives/cypher.test.ts index 2bb3e1d492..a724a710d5 100644 --- a/packages/graphql/tests/schema/directives/cypher.test.ts +++ b/packages/graphql/tests/schema/directives/cypher.test.ts @@ -772,8 +772,8 @@ describe("Cypher", () => { `); }); - test("Filters should not be generated on Relationship/Object custom cypher fields", async () => { - const typeDefs = gql` + test("Filters should be generated on Relationship/Object custom cypher fields", async () => { + const typeDefs = /* GraphQL */ ` type Movie { actors: [Actor] @cypher( @@ -783,6 +783,15 @@ describe("Cypher", () => { """ columnName: "actor" ) + actor: Actor + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(actor:Actor) + RETURN actor + LIMIT 1 + """ + columnName: "actor" + ) } type Actor { @@ -795,6 +804,15 @@ describe("Cypher", () => { """ columnName: "movie" ) + movie: Movie + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:Movie) + RETURN movie + LIMIT 1 + """ + columnName: "movie" + ) } `; const neoSchema = new Neo4jGraphQL({ typeDefs }); @@ -807,6 +825,7 @@ describe("Cypher", () => { } type Actor { + movie: Movie movies: [Movie] name: String } @@ -838,6 +857,7 @@ describe("Cypher", () => { Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. \\"\\"\\" input ActorSort { + movie: SortDirection name: SortDirection } @@ -849,6 +869,8 @@ describe("Cypher", () => { AND: [ActorWhere!] NOT: ActorWhere OR: [ActorWhere!] + movie: MovieWhere + movie_NOT: MovieWhere @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") name: String name_CONTAINS: String name_ENDS_WITH: String @@ -896,6 +918,7 @@ describe("Cypher", () => { } type Movie { + actor: Actor actors: [Actor] } @@ -918,6 +941,17 @@ describe("Cypher", () => { input MovieOptions { limit: Int offset: Int + \\"\\"\\" + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + \\"\\"\\" + sort: [MovieSort!] + } + + \\"\\"\\" + Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. + \\"\\"\\" + input MovieSort { + actor: SortDirection } input MovieUpdateInput { @@ -931,6 +965,8 @@ describe("Cypher", () => { AND: [MovieWhere!] NOT: MovieWhere OR: [MovieWhere!] + actor: ActorWhere + actor_NOT: ActorWhere @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") } type MoviesConnection { @@ -962,7 +998,7 @@ describe("Cypher", () => { actorsConnection(after: String, first: Int, sort: [ActorSort], where: ActorWhere): ActorsConnection! movies(options: MovieOptions, where: MovieWhere): [Movie!]! moviesAggregate(where: MovieWhere): MovieAggregateSelection! - moviesConnection(after: String, first: Int, where: MovieWhere): MoviesConnection! + moviesConnection(after: String, first: Int, sort: [MovieSort], where: MovieWhere): MoviesConnection! } \\"\\"\\"An enum for sorting in either ascending or descending order.\\"\\"\\" From 72987dddcf8eba8ceef579a057e1bfe259f0b9b1 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Fri, 11 Oct 2024 13:45:07 +0200 Subject: [PATCH 04/20] refactor: tidy up tests --- .../tests/schema/directives/cypher.test.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/graphql/tests/schema/directives/cypher.test.ts b/packages/graphql/tests/schema/directives/cypher.test.ts index a724a710d5..e2f183c0d4 100644 --- a/packages/graphql/tests/schema/directives/cypher.test.ts +++ b/packages/graphql/tests/schema/directives/cypher.test.ts @@ -18,18 +18,17 @@ */ import { printSchemaWithDirectives } from "@graphql-tools/utils"; -import { gql } from "graphql-tag"; import { lexicographicSortSchema } from "graphql/utilities"; import { Neo4jGraphQL } from "../../../src"; describe("Cypher", () => { test("Custom Directive Simple", async () => { - const typeDefs = gql` - type Actor { + const typeDefs = /* GraphQL */ ` + type Actor @node { name: String } - type Movie { + type Movie @node { id: ID custom_string: String @cypher(statement: "RETURN 'custom!' as c", columnName: "c") list_of_custom_strings: [String] @@ -633,8 +632,8 @@ describe("Cypher", () => { }); test("Filters should not be generated on custom cypher fields with arguments", async () => { - const typeDefs = gql` - type Movie { + const typeDefs = /* GraphQL */ ` + type Movie @node { custom_string_with_param(param: String): String @cypher(statement: "RETURN $param as c", columnName: "c") } @@ -772,9 +771,9 @@ describe("Cypher", () => { `); }); - test("Filters should be generated on Relationship/Object custom cypher fields", async () => { + test("Filters should be generated only on 1:1 Relationship/Object custom cypher fields", async () => { const typeDefs = /* GraphQL */ ` - type Movie { + type Movie @node { actors: [Actor] @cypher( statement: """ @@ -794,7 +793,7 @@ describe("Cypher", () => { ) } - type Actor { + type Actor @node { name: String movies: [Movie] @cypher( @@ -1038,8 +1037,8 @@ describe("Cypher", () => { }); test("Sort On Primitive Field", async () => { - const typeDefs = gql` - type Actor { + const typeDefs = /* GraphQL */ ` + type Actor @node { name: String totalScreenTime: Int! @cypher( @@ -1051,7 +1050,7 @@ describe("Cypher", () => { ) } - type Movie { + type Movie @node { id: ID actors(title: String): [Actor] @cypher( @@ -1300,8 +1299,8 @@ describe("Cypher", () => { }); test("Filters should not be generated on custom cypher fields for subscriptions", async () => { - const typeDefs = gql` - type Movie { + const typeDefs = /* GraphQL */ ` + type Movie @node { title: String custom_title: String @cypher(statement: "RETURN 'hello' as t", columnName: "t") } From 7fa94964d6c83c4a72943be590dd65f4d595bbf0 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 29 Oct 2024 13:24:15 +0100 Subject: [PATCH 05/20] feat: add isNullable to AttributeTypeHelper --- .../graphql/src/schema-model/attribute/AttributeTypeHelper.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/graphql/src/schema-model/attribute/AttributeTypeHelper.ts b/packages/graphql/src/schema-model/attribute/AttributeTypeHelper.ts index 8b56681082..33289b0e5b 100644 --- a/packages/graphql/src/schema-model/attribute/AttributeTypeHelper.ts +++ b/packages/graphql/src/schema-model/attribute/AttributeTypeHelper.ts @@ -177,6 +177,10 @@ export class AttributeTypeHelper { return this.type.isRequired; } + public isNullable(): boolean { + return !this.isRequired(); + } + public isGraphQLBuiltInScalar(options = this.assertionOptions): boolean { const type = this.getTypeForAssertion(options.includeLists); return type.name in GraphQLBuiltInScalarType; From 0ee0c236dd7f38873e5ef53091296b08065cdbb6 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 29 Oct 2024 13:24:28 +0100 Subject: [PATCH 06/20] test: add tests --- .../cypher-filtering-relationship.int.test.ts | 776 ++++++++++++ .../tests/schema/directives/cypher.test.ts | 658 +++++++++- .../cypher-filtering-relationship.test.ts | 1103 +++++++++++++++++ 3 files changed, 2536 insertions(+), 1 deletion(-) create mode 100644 packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts create mode 100644 packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts new file mode 100644 index 0000000000..f4d0ba2cc3 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts @@ -0,0 +1,776 @@ +/* + * 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 - Relationship", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("1 to 1 relationship with single property filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + actor: ${Actor}! + @cypher( + statement: """ + MATCH (this)<-[:ACTED_IN]-(actor:${Actor}) + RETURN actor + """ + columnName: "actor" + ) + } + + type ${Actor} @node { + name: String + movie: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded" }) + CREATE (m3:${Movie} { title: "The Matrix Revolutions" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a)-[:ACTED_IN]->(m3) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Movie.plural}( + where: { + actor: { + name: "Keanu Reeves" + } + } + ) { + title + actor { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: expect.toIncludeSameMembers([ + { + title: "The Matrix", + actor: { + name: "Keanu Reeves", + }, + }, + { + title: "The Matrix Reloaded", + actor: { + name: "Keanu Reeves", + }, + }, + { + title: "The Matrix Revolutions", + actor: { + name: "Keanu Reeves", + }, + }, + ]), + }); + }); + + test("1 to 1 relationship with null filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + released: Int + actor: ${Actor} + @cypher( + statement: """ + MATCH (this)<-[:ACTED_IN]-(actor:${Actor}) + RETURN actor + """ + columnName: "actor" + ) + } + + type ${Actor} @node { + name: String + movie: ${Movie} + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (m3:${Movie} { title: "The Matrix Revolutions", released: 2003 }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Movie.plural}( + where: { + released: 2003, + actor: null + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: expect.toIncludeSameMembers([ + { + title: "The Matrix Revolutions", + }, + ]), + }); + }); + + test("1 to 1 relationship with NOT null filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + released: Int + actor: ${Actor} + @cypher( + statement: """ + MATCH (this)<-[:ACTED_IN]-(actor:${Actor}) + RETURN actor + """ + columnName: "actor" + ) + } + + type ${Actor} @node { + name: String + movie: ${Movie} + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (m3:${Movie} { title: "The Matrix Revolutions", released: 2003 }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Movie.plural}( + where: { AND: [{ released_IN: [2003], actor: { NOT: null } }] } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: expect.toIncludeSameMembers([ + { + title: "The Matrix Reloaded", + }, + ]), + }); + }); + + test("1 to 1 relationship with auth filter PASS", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node @authorization(filter: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) { + title: String + released: Int + directed_by: ${Person}! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = testHelper.createBearerToken("secret", { custom_value: "Lilly Wachowski" }); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + CREATE (a5)-[:DIRECTED]->(m2) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Person.plural]: [ + { + ["directed"]: { + title: "The Matrix", + directed_by: { + name: "Lilly Wachowski", + }, + }, + }, + ], + }); + }); + + test("1 to 1 relationship with auth filter FAIL", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node @authorization(filter: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) { + title: String + released: Int + directed_by: ${Person}! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = testHelper.createBearerToken("secret", { custom_value: "Something Incorrect" }); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + CREATE (a5)-[:DIRECTED]->(m2) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeTruthy(); + }); + + test("1 to 1 relationship with auth validate PASS", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node @authorization(validate: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) { + title: String + released: Int + directed_by: ${Person}! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = testHelper.createBearerToken("secret", { custom_value: "Lilly Wachowski" }); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + CREATE (a5)-[:DIRECTED]->(m2) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Person.plural]: [ + { + ["directed"]: { + title: "The Matrix", + directed_by: { + name: "Lilly Wachowski", + }, + }, + }, + ], + }); + }); + + test("1 to 1 relationship with auth validate FAIL", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node @authorization(validate: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) { + title: String + released: Int + directed_by: ${Person}! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = testHelper.createBearerToken("secret", { custom_value: "Something Wrong" }); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + CREATE (a5)-[:DIRECTED]->(m2) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Forbidden"); + }); + + test("1 to 1 relationship with nested selection", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + released: Int + actors: [${Person}!]! @relationship(type: "ACTED_IN", direction: IN) + directed_by: ${Person}! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (m3:${Movie} { title: "The Matrix Revolutions", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a)-[:ACTED_IN]->(m3) + CREATE (a2:${Person} { name: "Jada Pinkett Smith" }) + CREATE (a2)-[:ACTED_IN]->(m2) + CREATE (a2)-[:ACTED_IN]->(m3) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + CREATE (a5)-[:DIRECTED]->(m2) + CREATE (a5)-[:DIRECTED]->(m3) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + actors { + name + movies { + directed_by { + name + } + title + } + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Person.plural]: [ + { + ["directed"]: { + title: "The Matrix", + directed_by: { + name: "Lilly Wachowski", + }, + actors: expect.toIncludeSameMembers([ + { + name: "Keanu Reeves", + movies: expect.toIncludeSameMembers([ + { + directed_by: { + name: "Lilly Wachowski", + }, + title: "The Matrix", + }, + { + directed_by: { + name: "Lilly Wachowski", + }, + title: "The Matrix Reloaded", + }, + { + directed_by: { + name: "Lilly Wachowski", + }, + title: "The Matrix Revolutions", + }, + ]), + }, + ]), + }, + }, + ], + }); + }); + + test("1 to 1 relationship with connection", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + released: Int + actors: [${Person}!]! @relationship(type: "ACTED_IN", direction: IN) + directed_by: ${Person}! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (m3:${Movie} { title: "The Matrix Revolutions", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a)-[:ACTED_IN]->(m3) + CREATE (a2:${Person} { name: "Jada Pinkett Smith" }) + CREATE (a2)-[:ACTED_IN]->(m2) + CREATE (a2)-[:ACTED_IN]->(m3) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + CREATE (a5)-[:DIRECTED]->(m2) + CREATE (a5)-[:DIRECTED]->(m3) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Movie.plural}(where: { directed_by: { name: "Lilly Wachowski"}, title_ENDS_WITH: "Matrix" }) { + actorsConnection { + totalCount + edges { + node { + name + } + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + ["actorsConnection"]: { + totalCount: 1, + edges: [ + { + node: { + name: "Keanu Reeves", + }, + }, + ], + }, + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/schema/directives/cypher.test.ts b/packages/graphql/tests/schema/directives/cypher.test.ts index e2f183c0d4..5bf7d1279a 100644 --- a/packages/graphql/tests/schema/directives/cypher.test.ts +++ b/packages/graphql/tests/schema/directives/cypher.test.ts @@ -771,9 +771,665 @@ describe("Cypher", () => { `); }); + test("Union: Filters should not be generated for Relationship/Object custom cypher fields", async () => { + const typeDefs = /* GraphQL */ ` + union Content = Blog | Post + + type Blog @node { + title: String + posts: [Post!]! + @cypher( + statement: """ + MATCH (this)-[:HAS_POST]->(post) + RETURN post + """ + columnName: "post" + ) + post: Post + @cypher( + statement: """ + MATCH (this)-[:HAS_POST]->(post) + RETURN post + LIMIT 1 + """ + columnName: "post" + ) + } + + type Post @node { + thing: String + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + const printedSchema = printSchemaWithDirectives(lexicographicSortSchema(await neoSchema.getSchema())); + + expect(printedSchema).toMatchInlineSnapshot(` + "schema { + query: Query + mutation: Mutation + } + + type Blog { + post: Post + posts: [Post!]! + title: String + } + + type BlogAggregateSelection { + count: Int! + title: StringAggregateSelection! + } + + input BlogCreateInput { + title: String + } + + type BlogEdge { + cursor: String! + node: Blog! + } + + input BlogOptions { + limit: Int + offset: Int + \\"\\"\\" + Specify one or more BlogSort objects to sort Blogs by. The sorts will be applied in the order in which they are arranged in the array. + \\"\\"\\" + sort: [BlogSort!] + } + + \\"\\"\\" + Fields to sort Blogs by. The order in which sorts are applied is not guaranteed when specifying many fields in one BlogSort object. + \\"\\"\\" + input BlogSort { + post: SortDirection + title: SortDirection + } + + input BlogUpdateInput { + title: String + } + + input BlogWhere { + AND: [BlogWhere!] + NOT: BlogWhere + OR: [BlogWhere!] + post: PostWhere + post_NOT: PostWhere @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + title: String + title_CONTAINS: String + title_ENDS_WITH: String + title_IN: [String] + title_NOT: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + title_NOT_CONTAINS: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + title_NOT_ENDS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + title_NOT_IN: [String] @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + title_NOT_STARTS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + title_STARTS_WITH: String + } + + type BlogsConnection { + edges: [BlogEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + + union Content = Blog | Post + + input ContentWhere { + Blog: BlogWhere + Post: PostWhere + } + + type CreateBlogsMutationResponse { + blogs: [Blog!]! + info: CreateInfo! + } + + \\"\\"\\" + Information about the number of nodes and relationships created during a create mutation + \\"\\"\\" + type CreateInfo { + bookmark: String @deprecated(reason: \\"This field has been deprecated because bookmarks are now handled by the driver.\\") + nodesCreated: Int! + relationshipsCreated: Int! + } + + type CreatePostsMutationResponse { + info: CreateInfo! + posts: [Post!]! + } + + type CreateUsersMutationResponse { + info: CreateInfo! + users: [User!]! + } + + \\"\\"\\" + Information about the number of nodes and relationships deleted during a delete mutation + \\"\\"\\" + type DeleteInfo { + bookmark: String @deprecated(reason: \\"This field has been deprecated because bookmarks are now handled by the driver.\\") + nodesDeleted: Int! + relationshipsDeleted: Int! + } + + type Mutation { + createBlogs(input: [BlogCreateInput!]!): CreateBlogsMutationResponse! + createPosts(input: [PostCreateInput!]!): CreatePostsMutationResponse! + createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! + deleteBlogs(where: BlogWhere): DeleteInfo! + deletePosts(where: PostWhere): DeleteInfo! + deleteUsers(where: UserWhere): DeleteInfo! + updateBlogs(update: BlogUpdateInput, where: BlogWhere): UpdateBlogsMutationResponse! + updatePosts(update: PostUpdateInput, where: PostWhere): UpdatePostsMutationResponse! + updateUsers(update: UserUpdateInput, where: UserWhere): UpdateUsersMutationResponse! + } + + \\"\\"\\"Pagination information (Relay)\\"\\"\\" + type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + } + + type Post { + content: String + } + + type PostAggregateSelection { + content: StringAggregateSelection! + count: Int! + } + + input PostCreateInput { + content: String + } + + type PostEdge { + cursor: String! + node: Post! + } + + input PostOptions { + limit: Int + offset: Int + \\"\\"\\" + Specify one or more PostSort objects to sort Posts by. The sorts will be applied in the order in which they are arranged in the array. + \\"\\"\\" + sort: [PostSort!] + } + + \\"\\"\\" + Fields to sort Posts by. The order in which sorts are applied is not guaranteed when specifying many fields in one PostSort object. + \\"\\"\\" + input PostSort { + content: SortDirection + } + + input PostUpdateInput { + content: String + } + + input PostWhere { + AND: [PostWhere!] + NOT: PostWhere + OR: [PostWhere!] + content: String + content_CONTAINS: String + content_ENDS_WITH: String + content_IN: [String] + content_NOT: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + content_NOT_CONTAINS: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + content_NOT_ENDS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + content_NOT_IN: [String] @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + content_NOT_STARTS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + content_STARTS_WITH: String + } + + type PostsConnection { + edges: [PostEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + + type Query { + blogs(options: BlogOptions, where: BlogWhere): [Blog!]! + blogsAggregate(where: BlogWhere): BlogAggregateSelection! + blogsConnection(after: String, first: Int, sort: [BlogSort], where: BlogWhere): BlogsConnection! + contents(options: QueryOptions, where: ContentWhere): [Content!]! + posts(options: PostOptions, where: PostWhere): [Post!]! + postsAggregate(where: PostWhere): PostAggregateSelection! + postsConnection(after: String, first: Int, sort: [PostSort], where: PostWhere): PostsConnection! + users(options: UserOptions, where: UserWhere): [User!]! + usersAggregate(where: UserWhere): UserAggregateSelection! + usersConnection(after: String, first: Int, sort: [UserSort], where: UserWhere): UsersConnection! + } + + \\"\\"\\"Input type for options that can be specified on a query operation.\\"\\"\\" + input QueryOptions { + limit: Int + offset: Int + } + + \\"\\"\\"An enum for sorting in either ascending or descending order.\\"\\"\\" + enum SortDirection { + \\"\\"\\"Sort by field values in ascending order.\\"\\"\\" + ASC + \\"\\"\\"Sort by field values in descending order.\\"\\"\\" + DESC + } + + type StringAggregateSelection { + longest: String + shortest: String + } + + type UpdateBlogsMutationResponse { + blogs: [Blog!]! + info: UpdateInfo! + } + + \\"\\"\\" + Information about the number of nodes and relationships created and deleted during an update mutation + \\"\\"\\" + type UpdateInfo { + bookmark: String @deprecated(reason: \\"This field has been deprecated because bookmarks are now handled by the driver.\\") + nodesCreated: Int! + nodesDeleted: Int! + relationshipsCreated: Int! + relationshipsDeleted: Int! + } + + type UpdatePostsMutationResponse { + info: UpdateInfo! + posts: [Post!]! + } + + type UpdateUsersMutationResponse { + info: UpdateInfo! + users: [User!]! + } + + type User { + content: Content + contents: [Content!]! + name: String + } + + type UserAggregateSelection { + count: Int! + name: StringAggregateSelection! + } + + input UserCreateInput { + name: String + } + + type UserEdge { + cursor: String! + node: User! + } + + input UserOptions { + limit: Int + offset: Int + \\"\\"\\" + Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array. + \\"\\"\\" + sort: [UserSort!] + } + + \\"\\"\\" + Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object. + \\"\\"\\" + input UserSort { + content: SortDirection + name: SortDirection + } + + input UserUpdateInput { + name: String + } + + input UserWhere { + AND: [UserWhere!] + NOT: UserWhere + OR: [UserWhere!] + name: String + name_CONTAINS: String + name_ENDS_WITH: String + name_IN: [String] + name_NOT: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + name_NOT_CONTAINS: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + name_NOT_ENDS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + 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 + } + + type UsersConnection { + edges: [UserEdge!]! + pageInfo: PageInfo! + totalCount: Int! + }" + `); + }); + + test("Interface: Filters should not be generated for Relationship/Object custom cypher fields", async () => { + const typeDefs = /* GraphQL */ ` + interface Production { + actor: Actor + actors: [Actor] + } + + type Movie implements Production @node { + actors: [Actor] + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(actor:Actor) + RETURN actor + """ + columnName: "actor" + ) + actor: Actor + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(actor:Actor) + RETURN actor + LIMIT 1 + """ + columnName: "actor" + ) + } + + type Actor @node { + name: String + movies: [Movie] + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + movie: Movie + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:Movie) + RETURN movie + LIMIT 1 + """ + columnName: "movie" + ) + } + `; + const neoSchema = new Neo4jGraphQL({ typeDefs }); + const printedSchema = printSchemaWithDirectives(lexicographicSortSchema(await neoSchema.getSchema())); + + expect(printedSchema).toMatchInlineSnapshot(` + "schema { + query: Query + mutation: Mutation + } + + type Actor { + movie: Movie + movies: [Movie] + name: String + } + + type ActorAggregateSelection { + count: Int! + name: StringAggregateSelection! + } + + input ActorCreateInput { + name: String + } + + type ActorEdge { + cursor: String! + node: Actor! + } + + input ActorOptions { + limit: Int + offset: Int + \\"\\"\\" + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + \\"\\"\\" + sort: [ActorSort!] + } + + \\"\\"\\" + Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. + \\"\\"\\" + input ActorSort { + movie: SortDirection + name: SortDirection + } + + input ActorUpdateInput { + name: String + } + + input ActorWhere { + AND: [ActorWhere!] + NOT: ActorWhere + OR: [ActorWhere!] + movie: MovieWhere + movie_NOT: MovieWhere @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + name: String + name_CONTAINS: String + name_ENDS_WITH: String + name_IN: [String] + name_NOT: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + name_NOT_CONTAINS: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + name_NOT_ENDS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + 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 + } + + type ActorsConnection { + edges: [ActorEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + + type CreateActorsMutationResponse { + actors: [Actor!]! + info: CreateInfo! + } + + \\"\\"\\" + Information about the number of nodes and relationships created during a create mutation + \\"\\"\\" + type CreateInfo { + bookmark: String @deprecated(reason: \\"This field has been deprecated because bookmarks are now handled by the driver.\\") + nodesCreated: Int! + relationshipsCreated: Int! + } + + type CreateMoviesMutationResponse { + info: CreateInfo! + movies: [Movie!]! + } + + \\"\\"\\" + Information about the number of nodes and relationships deleted during a delete mutation + \\"\\"\\" + type DeleteInfo { + bookmark: String @deprecated(reason: \\"This field has been deprecated because bookmarks are now handled by the driver.\\") + nodesDeleted: Int! + relationshipsDeleted: Int! + } + + type Movie implements Production { + actor: Actor + actors: [Actor] + } + + type MovieAggregateSelection { + count: Int! + } + + input MovieCreateInput { + \\"\\"\\" + Appears because this input type would be empty otherwise because this type is composed of just generated and/or relationship properties. See https://neo4j.com/docs/graphql-manual/current/troubleshooting/faqs/ + \\"\\"\\" + _emptyInput: Boolean + } + + type MovieEdge { + cursor: String! + node: Movie! + } + + input MovieOptions { + limit: Int + offset: Int + \\"\\"\\" + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + \\"\\"\\" + sort: [MovieSort!] + } + + \\"\\"\\" + Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. + \\"\\"\\" + input MovieSort { + actor: SortDirection + } + + input MovieUpdateInput { + \\"\\"\\" + Appears because this input type would be empty otherwise because this type is composed of just generated and/or relationship properties. See https://neo4j.com/docs/graphql-manual/current/troubleshooting/faqs/ + \\"\\"\\" + _emptyInput: Boolean + } + + input MovieWhere { + AND: [MovieWhere!] + NOT: MovieWhere + OR: [MovieWhere!] + actor: ActorWhere + actor_NOT: ActorWhere @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") + } + + type MoviesConnection { + edges: [MovieEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + + type Mutation { + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteActors(where: ActorWhere): DeleteInfo! + deleteMovies(where: MovieWhere): DeleteInfo! + updateActors(update: ActorUpdateInput, where: ActorWhere): UpdateActorsMutationResponse! + updateMovies(update: MovieUpdateInput, where: MovieWhere): UpdateMoviesMutationResponse! + } + + \\"\\"\\"Pagination information (Relay)\\"\\"\\" + type PageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + } + + interface Production { + actor: Actor + actors: [Actor] + } + + type ProductionAggregateSelection { + count: Int! + } + + type ProductionEdge { + cursor: String! + node: Production! + } + + enum ProductionImplementation { + Movie + } + + input ProductionOptions { + limit: Int + offset: Int + } + + input ProductionWhere { + AND: [ProductionWhere!] + NOT: ProductionWhere + OR: [ProductionWhere!] + typename_IN: [ProductionImplementation!] + } + + type ProductionsConnection { + edges: [ProductionEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + + type Query { + actors(options: ActorOptions, where: ActorWhere): [Actor!]! + actorsAggregate(where: ActorWhere): ActorAggregateSelection! + actorsConnection(after: String, first: Int, sort: [ActorSort], where: ActorWhere): ActorsConnection! + movies(options: MovieOptions, where: MovieWhere): [Movie!]! + moviesAggregate(where: MovieWhere): MovieAggregateSelection! + moviesConnection(after: String, first: Int, sort: [MovieSort], where: MovieWhere): MoviesConnection! + productions(options: ProductionOptions, where: ProductionWhere): [Production!]! + productionsAggregate(where: ProductionWhere): ProductionAggregateSelection! + productionsConnection(after: String, first: Int, where: ProductionWhere): ProductionsConnection! + } + + \\"\\"\\"An enum for sorting in either ascending or descending order.\\"\\"\\" + enum SortDirection { + \\"\\"\\"Sort by field values in ascending order.\\"\\"\\" + ASC + \\"\\"\\"Sort by field values in descending order.\\"\\"\\" + DESC + } + + type StringAggregateSelection { + longest: String + shortest: String + } + + type UpdateActorsMutationResponse { + actors: [Actor!]! + info: UpdateInfo! + } + + \\"\\"\\" + Information about the number of nodes and relationships created and deleted during an update mutation + \\"\\"\\" + type UpdateInfo { + bookmark: String @deprecated(reason: \\"This field has been deprecated because bookmarks are now handled by the driver.\\") + nodesCreated: Int! + nodesDeleted: Int! + relationshipsCreated: Int! + relationshipsDeleted: Int! + } + + type UpdateMoviesMutationResponse { + info: UpdateInfo! + movies: [Movie!]! + }" + `); + }); + test("Filters should be generated only on 1:1 Relationship/Object custom cypher fields", async () => { const typeDefs = /* GraphQL */ ` - type Movie @node { + type Movie implements Production @node { actors: [Actor] @cypher( statement: """ diff --git a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts new file mode 100644 index 0000000000..35ec8aad56 --- /dev/null +++ b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts @@ -0,0 +1,1103 @@ +/* + * 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 - Relationship", () => { + test("1 to 1 relationship", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + actor: Actor! + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(actor:Actor) + RETURN actor + """ + columnName: "actor" + ) + } + + type Actor @node { + name: String + movie: Movie! + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const query = ` + query { + movies(where: { actor: { name: "Keanu Reeves" } }) { + 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 (this)-[:ACTED_IN]->(actor:Actor) + RETURN actor + } + WITH actor AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.name = $param0 + RETURN this { .title } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"Keanu Reeves\\" + }" + `); + }); + + test("1 to 1 relationship with multiple filters", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + actor: Actor! + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(actor:Actor) + RETURN actor + """ + columnName: "actor" + ) + } + + type Actor @node { + name: String + age: Int + movie: Movie! + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const query = ` + query { + movies(where: { released: 2003, actor: { name: "Keanu Reeves", age_GT: 30 } }) { + 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 (this)-[:ACTED_IN]->(actor:Actor) + RETURN actor + } + WITH actor AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE (this.released = $param0 AND (this1.name = $param1 AND this1.age > $param2)) + RETURN this { .title } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"low\\": 2003, + \\"high\\": 0 + }, + \\"param1\\": \\"Keanu Reeves\\", + \\"param2\\": { + \\"low\\": 30, + \\"high\\": 0 + } + }" + `); + }); + + test("1 to 1 relationship with single property filter", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + actor: Actor! + @cypher( + statement: """ + MATCH (this)<-[:ACTED_IN]-(actor:Actor) + RETURN actor + """ + columnName: "actor" + ) + } + + type Actor @node { + name: String + movie: Movie! + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const query = /* GraphQL */ ` + query { + movies(where: { actor: { name: "Keanu Reeves" } }) { + title + actor { + name + } + } + } + `; + + 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 (this)<-[:ACTED_IN]-(actor:Actor) + RETURN actor + } + WITH actor AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.name = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)<-[:ACTED_IN]-(actor:Actor) + RETURN actor + } + WITH actor AS this2 + WITH this2 { .name } AS this2 + RETURN head(collect(this2)) AS var3 + } + RETURN this { .title, actor: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"Keanu Reeves\\" + }" + `); + }); + + test("1 to 1 relationship with null filter", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + actor: Actor + @cypher( + statement: """ + MATCH (this)<-[:ACTED_IN]-(actor:Actor) + RETURN actor + """ + columnName: "actor" + ) + } + + type Actor @node { + name: String + movie: Movie + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const query = /* GraphQL */ ` + query { + movies(where: { released: 2003, actor: null }) { + title + } + } + `; + + 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 (this)<-[:ACTED_IN]-(actor:Actor) + RETURN actor + } + WITH actor AS this0 + RETURN head(collect(this0)) AS this1 + } + WITH * + WHERE (this.released = $param0 AND this1 IS NULL) + RETURN this { .title } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"low\\": 2003, + \\"high\\": 0 + } + }" + `); + }); + + test("1 to 1 relationship with NOT null filter", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + actor: Actor + @cypher( + statement: """ + MATCH (this)<-[:ACTED_IN]-(actor:Actor) + RETURN actor + """ + columnName: "actor" + ) + } + + type Actor @node { + name: String + movie: Movie + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const query = /* GraphQL */ ` + query { + movies(where: { AND: [{ released_IN: [2003], actor: { NOT: null } }] }) { + title + } + } + `; + + 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 (this)<-[:ACTED_IN]-(actor:Actor) + RETURN actor + } + WITH actor AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this.released IN $param0 + RETURN this { .title } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": [ + { + \\"low\\": 2003, + \\"high\\": 0 + } + ] + }" + `); + }); + + test("1 to 1 relationship with auth filter PASS", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @authorization(filter: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) { + title: String + released: Int + directed_by: Person! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = createBearerToken("secret", { custom_value: "Lilly Wachowski" }); + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this3 + WITH this3 { .name } AS this3 + RETURN head(collect(this3)) AS var4 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this5 + RETURN this5 AS var6 + } + WITH * + WHERE ($isAuthenticated = true AND var6 = $param2) + WITH this2 { .title, directed_by: var4 } AS this2 + RETURN head(collect(this2)) AS var7 + } + RETURN this { directed: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"custom_value\\": \\"Lilly Wachowski\\" + }, + }" + `); + }); + + test("1 to 1 relationship with auth filter FAIL", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @authorization(filter: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) { + title: String + released: Int + directed_by: Person! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = createBearerToken("secret", { custom_value: "Something Incorrect" }); + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this3 + WITH this3 { .name } AS this3 + RETURN head(collect(this3)) AS var4 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this5 + RETURN this5 AS var6 + } + WITH * + WHERE ($isAuthenticated = true AND var6 = $param2) + WITH this2 { .title, directed_by: var4 } AS this2 + RETURN head(collect(this2)) AS var7 + } + RETURN this { directed: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"custom_value\\": \\"Something Incorrect\\" + }, + }" + `); + }); + + test("1 to 1 relationship with auth validate PASS", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @authorization(validate: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) { + title: String + released: Int + directed_by: Person! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = createBearerToken("secret", { custom_value: "Lilly Wachowski" }); + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this3 + WITH this3 { .name } AS this3 + RETURN head(collect(this3)) AS var4 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this5 + RETURN this5 AS var6 + } + WITH * + WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND var6 = $param2), \\"@neo4j/graphql/FORBIDDEN\\", [0]) + WITH this2 { .title, directed_by: var4 } AS this2 + RETURN head(collect(this2)) AS var7 + } + RETURN this { directed: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"custom_value\\": \\"Lilly Wachowski\\" + }, + }" + `); + }); + + test("1 to 1 relationship with auth validate FAIL", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @authorization(validate: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) { + title: String + released: Int + directed_by: Person! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = createBearerToken("secret", { custom_value: "Something Wrong" }); + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this3 + WITH this3 { .name } AS this3 + RETURN head(collect(this3)) AS var4 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this5 + RETURN this5 AS var6 + } + WITH * + WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND var6 = $param2), \\"@neo4j/graphql/FORBIDDEN\\", [0]) + WITH this2 { .title, directed_by: var4 } AS this2 + RETURN head(collect(this2)) AS var7 + } + RETURN this { directed: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"custom_value\\": \\"Something Wrong\\" + }, + }" + `); + }); + + test("1 to 1 relationship with nested selection", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN) + directed_by: Person! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + actors { + name + movies { + directed_by { + name + } + title + } + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + MATCH (this2)<-[this3:ACTED_IN]-(this4:Person) + CALL { + WITH this4 + MATCH (this4)-[this5:ACTED_IN]->(this6:Movie) + CALL { + WITH this6 + CALL { + WITH this6 + WITH this6 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this7 + WITH this7 { .name } AS this7 + RETURN head(collect(this7)) AS var8 + } + WITH this6 { .title, directed_by: var8 } AS this6 + RETURN collect(this6) AS var9 + } + WITH this4 { .name, movies: var9 } AS this4 + RETURN collect(this4) AS var10 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this11 + WITH this11 { .name } AS this11 + RETURN head(collect(this11)) AS var12 + } + WITH this2 { .title, directed_by: var12, actors: var10 } AS this2 + RETURN head(collect(this2)) AS var13 + } + RETURN this { directed: var13 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\" + }" + `); + }); + + test("1 to 1 relationship with connection", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN) + directed_by: Person! + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const query = /* GraphQL */ ` + query { + movies(where: { directed_by: { name: "Lilly Wachowski" }, title_ENDS_WITH: "Matrix" }) { + actorsConnection { + totalCount + edges { + node { + name + } + } + } + } + } + `; + + 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 (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE (this.title ENDS WITH $param0 AND this1.name = $param1) + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Person) + WITH collect({ node: this3, relationship: this2 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this3, edge.relationship AS this2 + RETURN collect({ node: { name: this3.name, __resolveType: \\"Person\\" } }) AS var4 + } + RETURN { edges: var4, totalCount: totalCount } AS var5 + } + RETURN this { actorsConnection: var5 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"Matrix\\", + \\"param1\\": \\"Lilly Wachowski\\" + }" + `); + }); +}); From 368fa73404fb2bc01857c94ea6100dec05acfba2 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 29 Oct 2024 13:25:40 +0100 Subject: [PATCH 07/20] feat: add CypherRelationshipFilter --- .../translate/queryAST/ast/QueryASTContext.ts | 11 +- .../ast/filters/CypherRelationshipFilter.ts | 135 +++++++++++++ .../queryAST/factory/FilterFactory.ts | 188 +++++++++++++----- .../queryAST/factory/OperationFactory.ts | 2 +- .../factory/Operations/ConnectionFactory.ts | 1 - 5 files changed, 287 insertions(+), 50 deletions(-) create mode 100644 packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts diff --git a/packages/graphql/src/translate/queryAST/ast/QueryASTContext.ts b/packages/graphql/src/translate/queryAST/ast/QueryASTContext.ts index 8fb6297add..3f6274ce3f 100644 --- a/packages/graphql/src/translate/queryAST/ast/QueryASTContext.ts +++ b/packages/graphql/src/translate/queryAST/ast/QueryASTContext.ts @@ -49,7 +49,6 @@ export class QueryASTContext { + return new QueryASTContext({ + source: this.target, + target, + env: this.env, + neo4jGraphQLContext: this.neo4jGraphQLContext, + returnVariable: this.returnVariable, + }); + } } diff --git a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts new file mode 100644 index 0000000000..caf7083897 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts @@ -0,0 +1,135 @@ +/* + * 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 { RelationshipWhereOperator } from "../../../where/types"; +import type { QueryASTContext } from "../QueryASTContext"; +import type { QueryASTNode } from "../QueryASTNode"; +import type { CustomCypherSelection } from "../selection/CustomCypherSelection"; +import { Filter } from "./Filter"; + +export class CypherRelationshipFilter extends Filter { + private returnVariable: Cypher.Node; + private attribute: AttributeAdapter; + private selection: CustomCypherSelection; + private operator: RelationshipWhereOperator; + private targetNodeFilters: Filter[] = []; + private isNot: boolean; // TODO: remove this when name_NOT is removed + private checkIsNotNull: boolean; + + constructor({ + selection, + attribute, + operator, + isNot, + returnVariable, + checkIsNotNull = false, + }: { + selection: CustomCypherSelection; + attribute: AttributeAdapter; + operator: RelationshipWhereOperator; + isNot: boolean; + returnVariable: Cypher.Node; + checkIsNotNull?: boolean; + }) { + super(); + this.selection = selection; + this.attribute = attribute; + this.isNot = isNot; + this.operator = operator; + this.returnVariable = returnVariable; + this.checkIsNotNull = checkIsNotNull; + } + + public getChildren(): QueryASTNode[] { + return [...this.targetNodeFilters, this.selection]; + } + + public addTargetNodeFilter(...filter: Filter[]): void { + this.targetNodeFilters.push(...filter); + } + + public print(): string { + return `${super.print()} [${this.attribute.name}] <${this.isNot ? "NOT " : ""}${this.operator}>`; + } + + public getSubqueries(context: QueryASTContext): Cypher.Clause[] { + const { selection: cypherSubquery, nestedContext } = this.selection.apply(context); + + const subqueries: Cypher.Clause[] = []; + + let clause: Cypher.Clause; + + if (this.isNullableSingle() && this.operator === "SOME" && this.isNot === true) { + clause = cypherSubquery.return([ + Cypher.head(Cypher.collect(nestedContext.returnVariable)), + this.returnVariable, + ]); + } else { + clause = cypherSubquery.return([nestedContext.returnVariable, this.returnVariable]); + } + + subqueries.push(clause); + + return subqueries; + } + + protected isNullableSingle(): boolean { + return !this.attribute.typeHelper.isList() && this.attribute.typeHelper.isNullable(); + } + + public getPredicate(queryASTContext: QueryASTContext): Cypher.Predicate | undefined { + const context = queryASTContext.setTarget(this.returnVariable); + + const predicate = this.createRelationshipOperation(context); + if (predicate) { + return this.wrapInNotIfNeeded(predicate); + } + } + + protected createRelationshipOperation(queryASTContext: QueryASTContext): Cypher.Predicate | undefined { + const predicates = this.targetNodeFilters.map((c) => c.getPredicate(queryASTContext)); + const innerPredicate = Cypher.and(...predicates); + + switch (this.operator) { + case "NONE": + case "SOME": { + if (this.isNullableSingle() && this.isNot) { + // If the relationship is nullable and the operator is NOT SOME, we need to check if the relationship is null + // Note that NOT SOME is equivalent to NONE + return Cypher.and(innerPredicate, Cypher.isNull(this.returnVariable)); + } + + return innerPredicate; + } + } + } + + protected wrapInNotIfNeeded(predicate: Cypher.Predicate): Cypher.Predicate { + // Cypher.not is not desired when the relationship is a nullable not-list + // This is because we want to check IS NULL, rather than NOT IS NULL, + // even though this.isNot is set to true + if (this.isNot && !this.isNullableSingle()) { + return Cypher.not(predicate); + } + + return predicate; + } +} diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index dfb8ac35aa..b034ee7cdf 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -23,6 +23,7 @@ import type { ConcreteEntityAdapter } from "../../../schema-model/entity/model-a import type { InterfaceEntityAdapter } from "../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; import type { UnionEntityAdapter } from "../../../schema-model/entity/model-adapters/UnionEntityAdapter"; import { RelationshipAdapter } from "../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import { getEntityAdapter } from "../../../schema-model/utils/get-entity-adapter"; import type { ConnectionWhereArg, GraphQLWhereArg } from "../../../types"; import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context"; import { fromGlobalId } from "../../../utils/global-ids"; @@ -30,6 +31,7 @@ import { asArray, filterTruthy } from "../../../utils/utils"; import { isLogicalOperator } from "../../utils/logical-operators"; import type { RelationshipWhereOperator, WhereOperator } from "../../where/types"; import { ConnectionFilter } from "../ast/filters/ConnectionFilter"; +import { CypherRelationshipFilter } from "../ast/filters/CypherRelationshipFilter"; import type { Filter } from "../ast/filters/Filter"; import { isRelationshipOperator } from "../ast/filters/Filter"; import { LogicalFilter } from "../ast/filters/LogicalFilter"; @@ -120,13 +122,11 @@ export class FilterFactory { entity, where, partialOf, - context, }: { rel?: RelationshipAdapter; entity: EntityAdapter; where: GraphQLWhereArg | GraphQLWhereArg[]; partialOf?: InterfaceEntityAdapter | UnionEntityAdapter; - context?: Neo4jGraphQLTranslationContext; }): Filter[] { let entityWhere = where; if (rel && isUnionEntity(rel.target) && where[entity.name]) { @@ -162,13 +162,61 @@ export class FilterFactory { relationship: rel, }); } - return this.createNodeFilters(entity, value, context); + return this.createNodeFilters(entity, value); } }); }); return filterTruthy(filters); } + private createCypherFilter({ + attribute, + comparisonValue, + operator, + isNot, + }: { + attribute: AttributeAdapter; + comparisonValue: GraphQLWhereArg; + operator: WhereOperator | undefined; + isNot: boolean; + }): Filter | Filter[] { + const filterOperator = operator || "EQ"; + + const selection = new CustomCypherSelection({ + operationField: attribute, + rawArguments: {}, + isNested: true, + }); + + if (attribute.annotations.cypher?.targetEntity) { + const entityAdapter = getEntityAdapter(attribute.annotations.cypher.targetEntity); + + if (operator && !isRelationshipOperator(operator)) { + throw new Error(`Invalid operator ${operator} for relationship`); + } + + return this.createCypherRelationshipFilter({ + where: comparisonValue, + selection, + target: entityAdapter, + filterOps: { + isNot, + operator, + }, + attribute, + }); + } + + const comparisonValueParam = new Cypher.Param(comparisonValue); + + return new CypherFilter({ + selection, + attribute, + comparisonValue: comparisonValueParam, + operator: filterOperator, + }); + } + protected createPropertyFilter({ attribute, relationship, @@ -179,27 +227,20 @@ export class FilterFactory { }: { attribute: AttributeAdapter; relationship?: RelationshipAdapter; - comparisonValue: unknown; + comparisonValue: GraphQLWhereArg; operator: WhereOperator | undefined; isNot: boolean; attachedTo?: "node" | "relationship"; - }): PropertyFilter | CypherFilter { + context?: Neo4jGraphQLTranslationContext; + }): Filter | Filter[] { const filterOperator = operator || "EQ"; if (attribute.annotations.cypher) { - const selection = new CustomCypherSelection({ - operationField: attribute, - rawArguments: {}, - isNested: true, - }); - - const comparisonValueParam = new Cypher.Param(comparisonValue); - - return new CypherFilter({ - selection, + return this.createCypherFilter({ attribute, - comparisonValue: comparisonValueParam, - operator: filterOperator, + comparisonValue, + operator, + isNot, }); } @@ -235,8 +276,7 @@ export class FilterFactory { private createRelationshipFilter( relationship: RelationshipAdapter, where: GraphQLWhereArg, - filterOps: { isNot: boolean; operator: RelationshipWhereOperator | undefined }, - context?: Neo4jGraphQLTranslationContext + filterOps: { isNot: boolean; operator: RelationshipWhereOperator | undefined } ): Filter[] { /** * The logic below can be confusing, but it's to handle the following cases: @@ -262,7 +302,7 @@ export class FilterFactory { if (!isNull) { const entityWhere = where[concreteEntity.name] ?? where; - const targetNodeFilters = this.createNodeFilters(concreteEntity, entityWhere, context); + const targetNodeFilters = this.createNodeFilters(concreteEntity, entityWhere); relationshipFilter.addTargetNodeFilter(...targetNodeFilters); } @@ -272,7 +312,67 @@ export class FilterFactory { return this.wrapMultipleFiltersInLogical(relationshipFilters, logicalOp); } - // This allows to override this creation in AuthorizationFilterFactory + private createCypherRelationshipFilter({ + selection, + target, + where, + filterOps, + attribute, + }: { + selection: CustomCypherSelection; + target: EntityAdapter; + where: GraphQLWhereArg; + filterOps: { isNot: boolean; operator: RelationshipWhereOperator | undefined }; + attribute: AttributeAdapter; + }): Filter[] { + /** + * The logic below can be confusing, but it's to handle the following cases: + * 1. where: { actors: null } -> in this case we want to return an Exists filter as showed by tests packages/graphql/tests/tck/null.test.ts + * 2. where: {} -> in this case we want to not apply any filter, as showed by tests packages/graphql/tests/tck/issues/402.test.ts + **/ + const isNull = where === null; + if (!isNull && Object.keys(where).length === 0) { + return []; + } + // this is because if isNull is true we want to wrap the Exist subclause in a NOT, but if isNull is true and isNot is true they negate each other + const isNot = isNull ? !filterOps.isNot : filterOps.isNot; + + const filteredEntities = getConcreteEntities(target, where); + const cypherRelationshipFilters: CypherRelationshipFilter[] = []; + for (const concreteEntity of filteredEntities) { + const returnVariable = new Cypher.Node(); + const cypherRelationshipFilter = this.createCypherRelationshipFilterTreeNode({ + selection, + isNot, + operator: filterOps.operator || "SOME", + attribute, + returnVariable, + }); + + if (!isNull) { + const entityWhere = where[concreteEntity.name] ?? where; + const targetNodeFilters = this.createNodeFilters(concreteEntity, entityWhere); + cypherRelationshipFilter.addTargetNodeFilter(...targetNodeFilters); + } + + cypherRelationshipFilters.push(cypherRelationshipFilter); + } + const logicalOp = this.getLogicalOperatorForRelatedNodeFilters(target, filterOps.operator); + return this.wrapMultipleFiltersInLogical(cypherRelationshipFilters, logicalOp); + } + + // This allows to override this creation in AuthFilterFactory + protected createCypherRelationshipFilterTreeNode(options: { + selection: CustomCypherSelection; + attribute: AttributeAdapter; + isNot: boolean; + operator: RelationshipWhereOperator; + returnVariable: Cypher.Node; + }): CypherRelationshipFilter { + return new CypherRelationshipFilter(options); + } + + // This allows to override this creation in AuthFilterFactory protected createRelationshipFilterTreeNode(options: { relationship: RelationshipAdapter; target: ConcreteEntityAdapter | InterfaceEntityAdapter; @@ -282,7 +382,7 @@ export class FilterFactory { return new RelationshipFilter(options); } - // This allows to override this creation in AuthorizationFilterFactory + // This allows to override this creation in AuthFilterFactory protected createConnectionFilterTreeNode(options: { relationship: RelationshipAdapter; target: ConcreteEntityAdapter | InterfaceEntityAdapter; @@ -297,13 +397,11 @@ export class FilterFactory { targetEntity, whereFields, relationship, - context, }: { entity: InterfaceEntityAdapter; targetEntity?: ConcreteEntityAdapter; whereFields: Record; relationship?: RelationshipAdapter; - context?: Neo4jGraphQLTranslationContext; }): Filter[] { const filters = filterTruthy( Object.entries(whereFields).flatMap(([key, value]): Filter | Filter[] | undefined => { @@ -340,17 +438,17 @@ export class FilterFactory { isNot, isConnection, isAggregate, - context, }); } - const attr = entity.findAttribute(fieldName); + const attribute = entity.findAttribute(fieldName); - if (!attr) { + if (!attribute) { throw new Error(`Attribute ${fieldName} not found`); } + return this.createPropertyFilter({ - attribute: attr, + attribute, relationship, comparisonValue: value, isNot, @@ -364,18 +462,20 @@ export class FilterFactory { public createNodeFilters( entity: ConcreteEntityAdapter | UnionEntityAdapter, - whereFields: Record, - context?: Neo4jGraphQLTranslationContext + whereFields: Record ): Filter[] { if (isUnionEntity(entity)) { return []; } + + console.log("whereFields", whereFields); + const filters = filterTruthy( Object.entries(whereFields).flatMap(([key, value]): Filter | Filter[] | undefined => { const valueAsArray = asArray(value); if (isLogicalOperator(key)) { const nestedFilters = valueAsArray.flatMap((nestedWhere) => { - return this.createNodeFilters(entity, nestedWhere, context); + return this.createNodeFilters(entity, nestedWhere); }); return new LogicalFilter({ operation: key, @@ -398,9 +498,9 @@ export class FilterFactory { }); } - const attr = entity.findAttribute(fieldName); + const attribute = entity.findAttribute(fieldName); - if (!attr) { + if (!attribute) { if (fieldName === "id" && entity.globalIdField) { return this.createRelayIdPropertyFilter(entity, isNot, operator, value); } @@ -409,7 +509,7 @@ export class FilterFactory { } return this.createPropertyFilter({ - attribute: attr, + attribute, comparisonValue: value, isNot, operator, @@ -427,7 +527,6 @@ export class FilterFactory { isNot, isConnection, isAggregate, - context, }: { relationship: RelationshipAdapter; value: any; @@ -435,7 +534,6 @@ export class FilterFactory { isNot: boolean; isConnection: boolean; isAggregate: boolean; - context?: Neo4jGraphQLTranslationContext; }): Filter | Filter[] { if (isAggregate) { return this.createAggregationFilter(relationship, value as AggregateWhereInput); @@ -449,15 +547,10 @@ export class FilterFactory { operator, }); } - return this.createRelationshipFilter( - relationship, - value as GraphQLWhereArg, - { - isNot, - operator, - }, - context - ); + return this.createRelationshipFilter(relationship, value as GraphQLWhereArg, { + isNot, + operator, + }); } private getLogicalOperatorForRelatedNodeFilters( @@ -480,7 +573,7 @@ export class FilterFactory { isNot: boolean, operator: WhereOperator | undefined, value: string - ): Filter { + ): Filter | Filter[] { const relayIdData = fromGlobalId(value); const { typeName, field } = relayIdData; let id = relayIdData.id; @@ -499,9 +592,10 @@ export class FilterFactory { throw new Error("Can't parse non-numeric relay id"); } } + return this.createPropertyFilter({ attribute: idAttribute, - comparisonValue: id, + comparisonValue: id as unknown as GraphQLWhereArg, isNot, operator, }); diff --git a/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts b/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts index 57fca3fba2..af8fe4e1bb 100644 --- a/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts @@ -331,7 +331,7 @@ export class OperationsFactory { }); operation.addFilters(...filters); } else { - const filters = this.filterFactory.createNodeFilters(entity, whereArgs, context); + const filters = this.filterFactory.createNodeFilters(entity, whereArgs); operation.addFilters(...filters); } diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/ConnectionFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/ConnectionFactory.ts index e5f929129e..909933bb81 100644 --- a/packages/graphql/src/translate/queryAST/factory/Operations/ConnectionFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/Operations/ConnectionFactory.ts @@ -381,7 +381,6 @@ export class ConnectionFactory { rel: relationship, entity: target, where: whereArgs, - context, }); operation.setNodeFields(nodeFields); From 7514ba36c7be97cb46667b4da5fefff5a401b846 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Tue, 29 Oct 2024 14:19:27 +0000 Subject: [PATCH 08/20] Fix double wrapped param in cypher filters --- .../ast/filters/CypherRelationshipFilter.ts | 51 ++++++++----------- .../queryAST/factory/AuthFilterFactory.ts | 27 +++++++++- .../queryAST/factory/FilterFactory.ts | 4 +- .../cypher-filtering-relationship.test.ts | 24 ++++----- 4 files changed, 60 insertions(+), 46 deletions(-) diff --git a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts index caf7083897..4e3e16cf49 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts @@ -32,7 +32,6 @@ export class CypherRelationshipFilter extends Filter { private operator: RelationshipWhereOperator; private targetNodeFilters: Filter[] = []; private isNot: boolean; // TODO: remove this when name_NOT is removed - private checkIsNotNull: boolean; constructor({ selection, @@ -40,14 +39,12 @@ export class CypherRelationshipFilter extends Filter { operator, isNot, returnVariable, - checkIsNotNull = false, }: { selection: CustomCypherSelection; attribute: AttributeAdapter; operator: RelationshipWhereOperator; isNot: boolean; returnVariable: Cypher.Node; - checkIsNotNull?: boolean; }) { super(); this.selection = selection; @@ -55,7 +52,6 @@ export class CypherRelationshipFilter extends Filter { this.isNot = isNot; this.operator = operator; this.returnVariable = returnVariable; - this.checkIsNotNull = checkIsNotNull; } public getChildren(): QueryASTNode[] { @@ -71,28 +67,14 @@ export class CypherRelationshipFilter extends Filter { } public getSubqueries(context: QueryASTContext): Cypher.Clause[] { - const { selection: cypherSubquery, nestedContext } = this.selection.apply(context); + const { selection, nestedContext } = this.selection.apply(context); - const subqueries: Cypher.Clause[] = []; + const cypherSubquery = selection.return([ + this.getSubqueryReturnValue(nestedContext.returnVariable), + this.returnVariable, + ]); - let clause: Cypher.Clause; - - if (this.isNullableSingle() && this.operator === "SOME" && this.isNot === true) { - clause = cypherSubquery.return([ - Cypher.head(Cypher.collect(nestedContext.returnVariable)), - this.returnVariable, - ]); - } else { - clause = cypherSubquery.return([nestedContext.returnVariable, this.returnVariable]); - } - - subqueries.push(clause); - - return subqueries; - } - - protected isNullableSingle(): boolean { - return !this.attribute.typeHelper.isList() && this.attribute.typeHelper.isNullable(); + return [cypherSubquery]; } public getPredicate(queryASTContext: QueryASTContext): Cypher.Predicate | undefined { @@ -104,14 +86,21 @@ export class CypherRelationshipFilter extends Filter { } } - protected createRelationshipOperation(queryASTContext: QueryASTContext): Cypher.Predicate | undefined { - const predicates = this.targetNodeFilters.map((c) => c.getPredicate(queryASTContext)); - const innerPredicate = Cypher.and(...predicates); + private getSubqueryReturnValue(returnVariable: Cypher.Variable): Cypher.Expr { + if (this.isNullableSingle() && this.operator === "SOME" && this.isNot) { + return Cypher.head(Cypher.collect(returnVariable)); + } + return returnVariable; + } + + private createRelationshipOperation(queryASTContext: QueryASTContext): Cypher.Predicate | undefined { + const targetNodePredicates = this.targetNodeFilters.map((c) => c.getPredicate(queryASTContext)); + const innerPredicate = Cypher.and(...targetNodePredicates); switch (this.operator) { case "NONE": case "SOME": { - if (this.isNullableSingle() && this.isNot) { + if (this.isNot && this.isNullableSingle()) { // If the relationship is nullable and the operator is NOT SOME, we need to check if the relationship is null // Note that NOT SOME is equivalent to NONE return Cypher.and(innerPredicate, Cypher.isNull(this.returnVariable)); @@ -122,7 +111,11 @@ export class CypherRelationshipFilter extends Filter { } } - protected wrapInNotIfNeeded(predicate: Cypher.Predicate): Cypher.Predicate { + private isNullableSingle(): boolean { + return !this.attribute.typeHelper.isList() && this.attribute.typeHelper.isNullable(); + } + + private wrapInNotIfNeeded(predicate: Cypher.Predicate): Cypher.Predicate { // Cypher.not is not desired when the relationship is a nullable not-list // This is because we want to check IS NULL, rather than NOT IS NULL, // even though this.isNot is set to true diff --git a/packages/graphql/src/translate/queryAST/factory/AuthFilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/AuthFilterFactory.ts index 35dfc5a047..477f4bd2fc 100644 --- a/packages/graphql/src/translate/queryAST/factory/AuthFilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/AuthFilterFactory.ts @@ -24,12 +24,13 @@ import type { AttributeAdapter } from "../../../schema-model/attribute/model-ada import type { ConcreteEntityAdapter } from "../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import type { InterfaceEntityAdapter } from "../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; import type { RelationshipAdapter } from "../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import { getEntityAdapter } from "../../../schema-model/utils/get-entity-adapter"; import type { GraphQLWhereArg } from "../../../types"; import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context"; import { isLogicalOperator } from "../../utils/logical-operators"; import type { RelationshipWhereOperator, WhereOperator } from "../../where/types"; import type { ConnectionFilter } from "../ast/filters/ConnectionFilter"; -import type { Filter } from "../ast/filters/Filter"; +import { isRelationshipOperator, type Filter } from "../ast/filters/Filter"; import { LogicalFilter } from "../ast/filters/LogicalFilter"; import type { RelationshipFilter } from "../ast/filters/RelationshipFilter"; import { AuthConnectionFilter } from "../ast/filters/authorization-filters/AuthConnectionFilter"; @@ -141,7 +142,7 @@ export class AuthFilterFactory extends FilterFactory { isNot: boolean; attachedTo?: "node" | "relationship"; relationship?: RelationshipAdapter; - }): CypherFilter | PropertyFilter { + }): Filter { const filterOperator = operator || "EQ"; const isCypherVariable = @@ -156,6 +157,28 @@ export class AuthFilterFactory extends FilterFactory { isNested: true, }); + if (attribute.annotations.cypher?.targetEntity) { + const entityAdapter = getEntityAdapter(attribute.annotations.cypher.targetEntity); + + if (operator && !isRelationshipOperator(operator)) { + throw new Error(`Invalid operator ${operator} for relationship`); + } + + return new LogicalFilter({ + operation: "AND", + filters: this.createCypherRelationshipFilter({ + where: comparisonValue as GraphQLWhereArg, + selection, + target: entityAdapter, + filterOps: { + isNot, + operator, + }, + attribute, + }), + }); + } + if (isCypherVariable) { return new CypherFilter({ selection, diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index b034ee7cdf..cf9667d748 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -312,7 +312,7 @@ export class FilterFactory { return this.wrapMultipleFiltersInLogical(relationshipFilters, logicalOp); } - private createCypherRelationshipFilter({ + protected createCypherRelationshipFilter({ selection, target, where, @@ -468,8 +468,6 @@ export class FilterFactory { return []; } - console.log("whereFields", whereFields); - const filters = filterTruthy( Object.entries(whereFields).flatMap(([key, value]): Filter | Filter[] | undefined => { const valueAsArray = asArray(value); diff --git a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts index 35ec8aad56..a153b6df80 100644 --- a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts +++ b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts @@ -489,10 +489,10 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS var6 + RETURN this5 AS this6 } WITH * - WHERE ($isAuthenticated = true AND var6 = $param2) + WHERE ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)) WITH this2 { .title, directed_by: var4 } AS this2 RETURN head(collect(this2)) AS var7 } @@ -506,7 +506,7 @@ describe("cypher directive filtering - Relationship", () => { \\"jwt\\": { \\"roles\\": [], \\"custom_value\\": \\"Lilly Wachowski\\" - }, + } }" `); }); @@ -612,10 +612,10 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS var6 + RETURN this5 AS this6 } WITH * - WHERE ($isAuthenticated = true AND var6 = $param2) + WHERE ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)) WITH this2 { .title, directed_by: var4 } AS this2 RETURN head(collect(this2)) AS var7 } @@ -629,7 +629,7 @@ describe("cypher directive filtering - Relationship", () => { \\"jwt\\": { \\"roles\\": [], \\"custom_value\\": \\"Something Incorrect\\" - }, + } }" `); }); @@ -735,10 +735,10 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS var6 + RETURN this5 AS this6 } WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND var6 = $param2), \\"@neo4j/graphql/FORBIDDEN\\", [0]) + WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)), \\"@neo4j/graphql/FORBIDDEN\\", [0]) WITH this2 { .title, directed_by: var4 } AS this2 RETURN head(collect(this2)) AS var7 } @@ -752,7 +752,7 @@ describe("cypher directive filtering - Relationship", () => { \\"jwt\\": { \\"roles\\": [], \\"custom_value\\": \\"Lilly Wachowski\\" - }, + } }" `); }); @@ -858,10 +858,10 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS var6 + RETURN this5 AS this6 } WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND var6 = $param2), \\"@neo4j/graphql/FORBIDDEN\\", [0]) + WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)), \\"@neo4j/graphql/FORBIDDEN\\", [0]) WITH this2 { .title, directed_by: var4 } AS this2 RETURN head(collect(this2)) AS var7 } @@ -875,7 +875,7 @@ describe("cypher directive filtering - Relationship", () => { \\"jwt\\": { \\"roles\\": [], \\"custom_value\\": \\"Something Wrong\\" - }, + } }" `); }); From 4c043926ee5112c55eba74adafdff65b302b159b Mon Sep 17 00:00:00 2001 From: angrykoala Date: Tue, 29 Oct 2024 14:42:38 +0000 Subject: [PATCH 09/20] minor improvements in cypher filtering class --- .../queryAST/ast/filters/CypherRelationshipFilter.ts | 4 ++-- .../filtering/cypher-filtering-relationship.int.test.ts | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts index 4e3e16cf49..6af2aae20f 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts @@ -31,7 +31,7 @@ export class CypherRelationshipFilter extends Filter { private selection: CustomCypherSelection; private operator: RelationshipWhereOperator; private targetNodeFilters: Filter[] = []; - private isNot: boolean; // TODO: remove this when name_NOT is removed + private isNot: boolean; constructor({ selection, @@ -87,7 +87,7 @@ export class CypherRelationshipFilter extends Filter { } private getSubqueryReturnValue(returnVariable: Cypher.Variable): Cypher.Expr { - if (this.isNullableSingle() && this.operator === "SOME" && this.isNot) { + if (this.isNullableSingle() && this.isNot) { return Cypher.head(Cypher.collect(returnVariable)); } return returnVariable; diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts index f4d0ba2cc3..e82b478959 100644 --- a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts @@ -299,7 +299,6 @@ describe("cypher directive filtering - Relationship", () => { CREATE (a)-[:ACTED_IN]->(m2) CREATE (a5:${Person} { name: "Lilly Wachowski" }) CREATE (a5)-[:DIRECTED]->(m) - CREATE (a5)-[:DIRECTED]->(m2) `, {} ); @@ -323,7 +322,7 @@ describe("cypher directive filtering - Relationship", () => { expect(gqlResult?.data).toEqual({ [Person.plural]: [ { - ["directed"]: { + directed: { title: "The Matrix", directed_by: { name: "Lilly Wachowski", @@ -481,7 +480,7 @@ describe("cypher directive filtering - Relationship", () => { expect(gqlResult?.data).toEqual({ [Person.plural]: [ { - ["directed"]: { + directed: { title: "The Matrix", directed_by: { name: "Lilly Wachowski", @@ -648,7 +647,7 @@ describe("cypher directive filtering - Relationship", () => { expect(gqlResult?.data).toEqual({ [Person.plural]: [ { - ["directed"]: { + directed: { title: "The Matrix", directed_by: { name: "Lilly Wachowski", From 722bc58d223f5f4cd5940c64ab009704fbb4586f Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 29 Oct 2024 15:46:36 +0100 Subject: [PATCH 10/20] test: ensure the data setup is one-to-one relationships --- .../cypher-filtering-relationship.int.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts index e82b478959..413bf92fdc 100644 --- a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts @@ -383,7 +383,6 @@ describe("cypher directive filtering - Relationship", () => { CREATE (a)-[:ACTED_IN]->(m2) CREATE (a5:${Person} { name: "Lilly Wachowski" }) CREATE (a5)-[:DIRECTED]->(m) - CREATE (a5)-[:DIRECTED]->(m2) `, {} ); @@ -456,7 +455,6 @@ describe("cypher directive filtering - Relationship", () => { CREATE (a)-[:ACTED_IN]->(m2) CREATE (a5:${Person} { name: "Lilly Wachowski" }) CREATE (a5)-[:DIRECTED]->(m) - CREATE (a5)-[:DIRECTED]->(m2) `, {} ); @@ -541,7 +539,6 @@ describe("cypher directive filtering - Relationship", () => { CREATE (a)-[:ACTED_IN]->(m2) CREATE (a5:${Person} { name: "Lilly Wachowski" }) CREATE (a5)-[:DIRECTED]->(m) - CREATE (a5)-[:DIRECTED]->(m2) `, {} ); @@ -611,9 +608,11 @@ describe("cypher directive filtering - Relationship", () => { CREATE (a2:${Person} { name: "Jada Pinkett Smith" }) CREATE (a2)-[:ACTED_IN]->(m2) CREATE (a2)-[:ACTED_IN]->(m3) + CREATE (a3:${Person} { name: "Director Person" }) + CREATE (a3)-[:DIRECTED]->(m) + CREATE (a4:${Person} { name: "Lana Wachowski" }) + CREATE (a4)-[:DIRECTED]->(m2) CREATE (a5:${Person} { name: "Lilly Wachowski" }) - CREATE (a5)-[:DIRECTED]->(m) - CREATE (a5)-[:DIRECTED]->(m2) CREATE (a5)-[:DIRECTED]->(m3) `, {} @@ -650,7 +649,7 @@ describe("cypher directive filtering - Relationship", () => { directed: { title: "The Matrix", directed_by: { - name: "Lilly Wachowski", + name: "Director Person", }, actors: expect.toIncludeSameMembers([ { @@ -658,13 +657,13 @@ describe("cypher directive filtering - Relationship", () => { movies: expect.toIncludeSameMembers([ { directed_by: { - name: "Lilly Wachowski", + name: "Director Person", }, title: "The Matrix", }, { directed_by: { - name: "Lilly Wachowski", + name: "Lana Wachowski", }, title: "The Matrix Reloaded", }, From 8ff9d92b716765572af36c5d4e3786d62466ec07 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Tue, 29 Oct 2024 15:56:55 +0100 Subject: [PATCH 11/20] test: update schema snapshots --- .../tests/schema/directives/cypher.test.ts | 85 +------------------ 1 file changed, 4 insertions(+), 81 deletions(-) diff --git a/packages/graphql/tests/schema/directives/cypher.test.ts b/packages/graphql/tests/schema/directives/cypher.test.ts index 5bf7d1279a..18a1e1e06e 100644 --- a/packages/graphql/tests/schema/directives/cypher.test.ts +++ b/packages/graphql/tests/schema/directives/cypher.test.ts @@ -357,6 +357,8 @@ describe("Cypher", () => { AND: [MovieWhere!] NOT: MovieWhere OR: [MovieWhere!] + actor: ActorWhere + actor_NOT: ActorWhere @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") custom_big_int: BigInt custom_big_int_GT: BigInt custom_big_int_GTE: BigInt @@ -797,7 +799,7 @@ describe("Cypher", () => { } type Post @node { - thing: String + content: String } `; @@ -901,11 +903,6 @@ describe("Cypher", () => { posts: [Post!]! } - type CreateUsersMutationResponse { - info: CreateInfo! - users: [User!]! - } - \\"\\"\\" Information about the number of nodes and relationships deleted during a delete mutation \\"\\"\\" @@ -918,13 +915,10 @@ describe("Cypher", () => { type Mutation { createBlogs(input: [BlogCreateInput!]!): CreateBlogsMutationResponse! createPosts(input: [PostCreateInput!]!): CreatePostsMutationResponse! - createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! deleteBlogs(where: BlogWhere): DeleteInfo! deletePosts(where: PostWhere): DeleteInfo! - deleteUsers(where: UserWhere): DeleteInfo! updateBlogs(update: BlogUpdateInput, where: BlogWhere): UpdateBlogsMutationResponse! updatePosts(update: PostUpdateInput, where: PostWhere): UpdatePostsMutationResponse! - updateUsers(update: UserUpdateInput, where: UserWhere): UpdateUsersMutationResponse! } \\"\\"\\"Pagination information (Relay)\\"\\"\\" @@ -1003,9 +997,6 @@ describe("Cypher", () => { posts(options: PostOptions, where: PostWhere): [Post!]! postsAggregate(where: PostWhere): PostAggregateSelection! postsConnection(after: String, first: Int, sort: [PostSort], where: PostWhere): PostsConnection! - users(options: UserOptions, where: UserWhere): [User!]! - usersAggregate(where: UserWhere): UserAggregateSelection! - usersConnection(after: String, first: Int, sort: [UserSort], where: UserWhere): UsersConnection! } \\"\\"\\"Input type for options that can be specified on a query operation.\\"\\"\\" @@ -1046,74 +1037,6 @@ describe("Cypher", () => { type UpdatePostsMutationResponse { info: UpdateInfo! posts: [Post!]! - } - - type UpdateUsersMutationResponse { - info: UpdateInfo! - users: [User!]! - } - - type User { - content: Content - contents: [Content!]! - name: String - } - - type UserAggregateSelection { - count: Int! - name: StringAggregateSelection! - } - - input UserCreateInput { - name: String - } - - type UserEdge { - cursor: String! - node: User! - } - - input UserOptions { - limit: Int - offset: Int - \\"\\"\\" - Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array. - \\"\\"\\" - sort: [UserSort!] - } - - \\"\\"\\" - Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object. - \\"\\"\\" - input UserSort { - content: SortDirection - name: SortDirection - } - - input UserUpdateInput { - name: String - } - - input UserWhere { - AND: [UserWhere!] - NOT: UserWhere - OR: [UserWhere!] - name: String - name_CONTAINS: String - name_ENDS_WITH: String - name_IN: [String] - name_NOT: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") - name_NOT_CONTAINS: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") - name_NOT_ENDS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") - 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 - } - - type UsersConnection { - edges: [UserEdge!]! - pageInfo: PageInfo! - totalCount: Int! }" `); }); @@ -1429,7 +1352,7 @@ describe("Cypher", () => { test("Filters should be generated only on 1:1 Relationship/Object custom cypher fields", async () => { const typeDefs = /* GraphQL */ ` - type Movie implements Production @node { + type Movie @node { actors: [Actor] @cypher( statement: """ From bae33332733e12ad6aca66d96a3f2072eb4a4cc4 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 30 Oct 2024 11:33:52 +0100 Subject: [PATCH 12/20] test: add auth field tests --- .../cypher-filtering-relationship.int.test.ts | 321 +++++++++++- .../cypher-filtering-relationship.test.ts | 496 +++++++++++++++++- 2 files changed, 809 insertions(+), 8 deletions(-) diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts index 413bf92fdc..e73c70a192 100644 --- a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts @@ -249,7 +249,7 @@ describe("cypher directive filtering - Relationship", () => { }); }); - test("1 to 1 relationship with auth filter PASS", async () => { + test("1 to 1 relationship with auth filter on type PASS", async () => { const Movie = testHelper.createUniqueType("Movie"); const Person = testHelper.createUniqueType("Person"); @@ -333,7 +333,7 @@ describe("cypher directive filtering - Relationship", () => { }); }); - test("1 to 1 relationship with auth filter FAIL", async () => { + test("1 to 1 relationship with auth filter on type FAIL", async () => { const Movie = testHelper.createUniqueType("Movie"); const Person = testHelper.createUniqueType("Person"); @@ -405,7 +405,163 @@ describe("cypher directive filtering - Relationship", () => { expect(gqlResult.errors).toBeTruthy(); }); - test("1 to 1 relationship with auth validate PASS", async () => { + test("1 to 1 relationship with auth filter on field PASS", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + released: Int + directed_by: ${Person}! @authorization(filter: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = testHelper.createBearerToken("secret", { custom_value: "Lilly Wachowski" }); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Person.plural]: [ + { + directed: { + title: "The Matrix", + directed_by: { + name: "Lilly Wachowski", + }, + }, + }, + ], + }); + }); + + test("1 to 1 relationship with auth filter on field FAIL", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + released: Int + directed_by: ${Person}! @authorization(filter: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = testHelper.createBearerToken("secret", { custom_value: "Something Incorrect" }); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeTruthy(); + }); + + test("1 to 1 relationship with auth validate type PASS", async () => { const Movie = testHelper.createUniqueType("Movie"); const Person = testHelper.createUniqueType("Person"); @@ -489,7 +645,7 @@ describe("cypher directive filtering - Relationship", () => { }); }); - test("1 to 1 relationship with auth validate FAIL", async () => { + test("1 to 1 relationship with auth validate type FAIL", async () => { const Movie = testHelper.createUniqueType("Movie"); const Person = testHelper.createUniqueType("Person"); @@ -562,6 +718,163 @@ describe("cypher directive filtering - Relationship", () => { expect(gqlResult.errors?.[0]?.message).toBe("Forbidden"); }); + test("1 to 1 relationship with auth validate field PASS", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + released: Int + directed_by: ${Person}! @authorization(validate: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = testHelper.createBearerToken("secret", { custom_value: "Lilly Wachowski" }); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Person.plural]: [ + { + directed: { + title: "The Matrix", + directed_by: { + name: "Lilly Wachowski", + }, + }, + }, + ], + }); + }); + + test("1 to 1 relationship with auth validate field FAIL", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Person = testHelper.createUniqueType("Person"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + released: Int + directed_by: ${Person}! @authorization(validate: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:${Person}) + RETURN director + """ + columnName: "director" + ) + } + + type ${Person} @node { + name: String + directed: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = testHelper.createBearerToken("secret", { custom_value: "Something Wrong" }); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix", released: 1999 }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) + CREATE (a:${Person} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a5:${Person} { name: "Lilly Wachowski" }) + CREATE (a5)-[:DIRECTED]->(m) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toHaveLength(1); + expect(gqlResult.errors?.[0]?.message).toBe("Forbidden"); + }); + test("1 to 1 relationship with nested selection", async () => { const Movie = testHelper.createUniqueType("Movie"); const Person = testHelper.createUniqueType("Person"); diff --git a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts index a153b6df80..70ea8c6776 100644 --- a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts +++ b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts @@ -388,7 +388,7 @@ describe("cypher directive filtering - Relationship", () => { `); }); - test("1 to 1 relationship with auth filter PASS", async () => { + test("1 to 1 relationship with auth filter on type PASS", async () => { const typeDefs = /* GraphQL */ ` type Movie @node @@ -511,7 +511,7 @@ describe("cypher directive filtering - Relationship", () => { `); }); - test("1 to 1 relationship with auth filter FAIL", async () => { + test("1 to 1 relationship with auth filter on type FAIL", async () => { const typeDefs = /* GraphQL */ ` type Movie @node @@ -634,7 +634,251 @@ describe("cypher directive filtering - Relationship", () => { `); }); - test("1 to 1 relationship with auth validate PASS", async () => { + test("1 to 1 relationship with auth filter on field PASS", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + directed_by: Person! + @authorization(filter: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = createBearerToken("secret", { custom_value: "Lilly Wachowski" }); + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this3 + WITH this3 { .name } AS this3 + RETURN head(collect(this3)) AS var4 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this5 + RETURN this5 AS this6 + } + WITH * + WHERE ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)) + WITH this2 { .title, directed_by: var4 } AS this2 + RETURN head(collect(this2)) AS var7 + } + RETURN this { directed: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"custom_value\\": \\"Lilly Wachowski\\" + } + }" + `); + }); + + test("1 to 1 relationship with auth filter on field FAIL", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + directed_by: Person! + @authorization(filter: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = createBearerToken("secret", { custom_value: "Something Incorrect" }); + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this3 + WITH this3 { .name } AS this3 + RETURN head(collect(this3)) AS var4 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this5 + RETURN this5 AS this6 + } + WITH * + WHERE ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)) + WITH this2 { .title, directed_by: var4 } AS this2 + RETURN head(collect(this2)) AS var7 + } + RETURN this { directed: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"custom_value\\": \\"Something Incorrect\\" + } + }" + `); + }); + + test("1 to 1 relationship with auth validate type PASS", async () => { const typeDefs = /* GraphQL */ ` type Movie @node @@ -757,7 +1001,7 @@ describe("cypher directive filtering - Relationship", () => { `); }); - test("1 to 1 relationship with auth validate FAIL", async () => { + test("1 to 1 relationship with auth validate type FAIL", async () => { const typeDefs = /* GraphQL */ ` type Movie @node @@ -880,6 +1124,250 @@ describe("cypher directive filtering - Relationship", () => { `); }); + test("1 to 1 relationship with auth validate field PASS", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + directed_by: Person! + @authorization(validate: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = createBearerToken("secret", { custom_value: "Lilly Wachowski" }); + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this3 + WITH this3 { .name } AS this3 + RETURN head(collect(this3)) AS var4 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this5 + RETURN this5 AS this6 + } + WITH * + WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)), \\"@neo4j/graphql/FORBIDDEN\\", [0]) + WITH this2 { .title, directed_by: var4 } AS this2 + RETURN head(collect(this2)) AS var7 + } + RETURN this { directed: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"custom_value\\": \\"Lilly Wachowski\\" + } + }" + `); + }); + + test("1 to 1 relationship with auth validate field FAIL", async () => { + const typeDefs = /* GraphQL */ ` + type Movie @node { + title: String + released: Int + directed_by: Person! + @authorization(validate: [{ where: { node: { directed_by: { name: "$jwt.custom_value" } } } }]) + @cypher( + statement: """ + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + """ + columnName: "director" + ) + } + + type Person @node { + name: String + directed: Movie! + @cypher( + statement: """ + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + const token = createBearerToken("secret", { custom_value: "Something Wrong" }); + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const query = /* GraphQL */ ` + query { + people(where: { directed: { title: "The Matrix" } }) { + directed { + title + directed_by { + name + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Person) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this0 + RETURN this0 AS this1 + } + WITH * + WHERE this1.title = $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (this)-[:DIRECTED]->(movie:Movie) + RETURN movie + } + WITH movie AS this2 + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this3 + WITH this3 { .name } AS this3 + RETURN head(collect(this3)) AS var4 + } + CALL { + WITH this2 + CALL { + WITH this2 + WITH this2 AS this + MATCH (this)<-[:DIRECTED]-(director:Person) + RETURN director + } + WITH director AS this5 + RETURN this5 AS this6 + } + WITH * + WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)), \\"@neo4j/graphql/FORBIDDEN\\", [0]) + WITH this2 { .title, directed_by: var4 } AS this2 + RETURN head(collect(this2)) AS var7 + } + RETURN this { directed: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"The Matrix\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"custom_value\\": \\"Something Wrong\\" + } + }" + `); + }); + test("1 to 1 relationship with nested selection", async () => { const typeDefs = /* GraphQL */ ` type Movie @node { From 72a6504a0e73af5feb72ff7b6d3810fbd50e57f7 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Wed, 30 Oct 2024 11:42:03 +0100 Subject: [PATCH 13/20] docs: add changeset --- .changeset/seven-bobcats-carry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/seven-bobcats-carry.md diff --git a/.changeset/seven-bobcats-carry.md b/.changeset/seven-bobcats-carry.md new file mode 100644 index 0000000000..24366790f3 --- /dev/null +++ b/.changeset/seven-bobcats-carry.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graphql": minor +--- + +Add filtering on 1 to 1 relationship custom cypher fields From a322af5edaca12c361a8e8d69bd662f65924fac5 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 31 Oct 2024 08:17:42 +0100 Subject: [PATCH 14/20] refactor: add isCypherRelationshipField variable to help readability --- .../attribute/model-adapters/AttributeAdapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 41c6a42f60..4ecead61a5 100644 --- a/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts +++ b/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts @@ -131,12 +131,16 @@ export class AttributeAdapter { ); } + isCypherRelationshipField(): boolean { + return this.isCypher() && Boolean(this.annotations.cypher?.targetEntity); + } + isWhereField(): boolean { return ( (this.typeHelper.isEnum() || this.typeHelper.isSpatial() || this.typeHelper.isScalar() || - (this.isCypher() && Boolean(this.annotations.cypher?.targetEntity))) && + this.isCypherRelationshipField()) && this.isFilterable() && !this.isCustomResolvable() ); From 2edd8688e72ae8d7d284944c777dc975f58a9955 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 31 Oct 2024 08:18:03 +0100 Subject: [PATCH 15/20] refactor: use !isRequired instead of isNullable --- .../graphql/src/schema-model/attribute/AttributeTypeHelper.ts | 4 ---- .../queryAST/ast/filters/CypherRelationshipFilter.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/graphql/src/schema-model/attribute/AttributeTypeHelper.ts b/packages/graphql/src/schema-model/attribute/AttributeTypeHelper.ts index 33289b0e5b..8b56681082 100644 --- a/packages/graphql/src/schema-model/attribute/AttributeTypeHelper.ts +++ b/packages/graphql/src/schema-model/attribute/AttributeTypeHelper.ts @@ -177,10 +177,6 @@ export class AttributeTypeHelper { return this.type.isRequired; } - public isNullable(): boolean { - return !this.isRequired(); - } - public isGraphQLBuiltInScalar(options = this.assertionOptions): boolean { const type = this.getTypeForAssertion(options.includeLists); return type.name in GraphQLBuiltInScalarType; diff --git a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts index 6af2aae20f..ab8cc9b517 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts @@ -112,7 +112,7 @@ export class CypherRelationshipFilter extends Filter { } private isNullableSingle(): boolean { - return !this.attribute.typeHelper.isList() && this.attribute.typeHelper.isNullable(); + return !this.attribute.typeHelper.isList() && !this.attribute.typeHelper.isRequired(); } private wrapInNotIfNeeded(predicate: Cypher.Predicate): Cypher.Predicate { From 8cb645c56b8386a360a63d3cc79adedfe8c03f6a Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 31 Oct 2024 08:21:40 +0100 Subject: [PATCH 16/20] refactor: throw error when concrete entity is not found --- packages/graphql/src/schema-model/generate-model.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/graphql/src/schema-model/generate-model.ts b/packages/graphql/src/schema-model/generate-model.ts index 9fabefc6b8..47c5902e5e 100644 --- a/packages/graphql/src/schema-model/generate-model.ts +++ b/packages/graphql/src/schema-model/generate-model.ts @@ -136,7 +136,14 @@ function hydrateCypherAnnotations(schema: Neo4jGraphQLSchemaModel, concreteEntit for (const attributeField of concreteEntity.attributes.values()) { if (attributeField.annotations.cypher) { if (attributeField.type instanceof ObjectType) { - attributeField.annotations.cypher.targetEntity = schema.getConcreteEntity(attributeField.type.name); + const foundConcreteEntity = schema.getConcreteEntity(attributeField.type.name); + if (!foundConcreteEntity) { + throw new Neo4jGraphQLSchemaValidationError( + `Could not find concrete entity with name ${attributeField.type.name}` + ); + } + + attributeField.annotations.cypher.targetEntity = foundConcreteEntity; } } } From a82767ad95a9a5ded8b85a5e439f521e07593061 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 31 Oct 2024 08:22:19 +0100 Subject: [PATCH 17/20] refactor: remove unneeded switch case --- .../ast/filters/CypherRelationshipFilter.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts index ab8cc9b517..77f8165df4 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts @@ -97,18 +97,13 @@ export class CypherRelationshipFilter extends Filter { const targetNodePredicates = this.targetNodeFilters.map((c) => c.getPredicate(queryASTContext)); const innerPredicate = Cypher.and(...targetNodePredicates); - switch (this.operator) { - case "NONE": - case "SOME": { - if (this.isNot && this.isNullableSingle()) { - // If the relationship is nullable and the operator is NOT SOME, we need to check if the relationship is null - // Note that NOT SOME is equivalent to NONE - return Cypher.and(innerPredicate, Cypher.isNull(this.returnVariable)); - } - - return innerPredicate; - } + if (this.isNot && this.isNullableSingle()) { + // If the relationship is nullable and the operator is NOT SOME, we need to check if the relationship is null + // Note that NOT SOME is equivalent to NONE + return Cypher.and(innerPredicate, Cypher.isNull(this.returnVariable)); } + + return innerPredicate; } private isNullableSingle(): boolean { From aedabe653eb596a8ffe1ab41aeeb5c1ba29d3158 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 31 Oct 2024 08:30:08 +0100 Subject: [PATCH 18/20] refactor: remove unneeded context type --- .../graphql/src/translate/queryAST/factory/FilterFactory.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index cf9667d748..223b7f1541 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -25,7 +25,6 @@ import type { UnionEntityAdapter } from "../../../schema-model/entity/model-adap import { RelationshipAdapter } from "../../../schema-model/relationship/model-adapters/RelationshipAdapter"; import { getEntityAdapter } from "../../../schema-model/utils/get-entity-adapter"; import type { ConnectionWhereArg, GraphQLWhereArg } from "../../../types"; -import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context"; import { fromGlobalId } from "../../../utils/global-ids"; import { asArray, filterTruthy } from "../../../utils/utils"; import { isLogicalOperator } from "../../utils/logical-operators"; @@ -231,7 +230,6 @@ export class FilterFactory { operator: WhereOperator | undefined; isNot: boolean; attachedTo?: "node" | "relationship"; - context?: Neo4jGraphQLTranslationContext; }): Filter | Filter[] { const filterOperator = operator || "EQ"; From bb57c527f7ebe2353478ec38e4c1cd8c0d6f8c93 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 31 Oct 2024 17:14:45 +0100 Subject: [PATCH 19/20] refactor: use head and collect in all returns Also cleaned up the filter by passing in an isNull property --- ...ts => CypherOneToOneRelationshipFilter.ts} | 28 ++---- .../queryAST/factory/FilterFactory.ts | 24 +++--- ...ering-one-to-one-relationship.int.test.ts} | 86 ++++++++++++++++++- ...filtering-one-to-one-relationship.test.ts} | 50 +++++------ 4 files changed, 129 insertions(+), 59 deletions(-) rename packages/graphql/src/translate/queryAST/ast/filters/{CypherRelationshipFilter.ts => CypherOneToOneRelationshipFilter.ts} (77%) rename packages/graphql/tests/integration/directives/cypher/filtering/{cypher-filtering-relationship.int.test.ts => cypher-filtering-one-to-one-relationship.int.test.ts} (92%) rename packages/graphql/tests/tck/directives/cypher/filtering/{cypher-filtering-relationship.test.ts => cypher-filtering-one-to-one-relationship.test.ts} (97%) diff --git a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/CypherOneToOneRelationshipFilter.ts similarity index 77% rename from packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts rename to packages/graphql/src/translate/queryAST/ast/filters/CypherOneToOneRelationshipFilter.ts index 77f8165df4..f85fae4185 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/CypherRelationshipFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/CypherOneToOneRelationshipFilter.ts @@ -25,31 +25,35 @@ import type { QueryASTNode } from "../QueryASTNode"; import type { CustomCypherSelection } from "../selection/CustomCypherSelection"; import { Filter } from "./Filter"; -export class CypherRelationshipFilter extends Filter { +export class CypherOneToOneRelationshipFilter extends Filter { private returnVariable: Cypher.Node; private attribute: AttributeAdapter; private selection: CustomCypherSelection; private operator: RelationshipWhereOperator; private targetNodeFilters: Filter[] = []; private isNot: boolean; + private isNull: boolean; constructor({ selection, attribute, operator, isNot, + isNull, returnVariable, }: { selection: CustomCypherSelection; attribute: AttributeAdapter; operator: RelationshipWhereOperator; isNot: boolean; + isNull: boolean; returnVariable: Cypher.Node; }) { super(); this.selection = selection; this.attribute = attribute; this.isNot = isNot; + this.isNull = isNull; this.operator = operator; this.returnVariable = returnVariable; } @@ -70,7 +74,7 @@ export class CypherRelationshipFilter extends Filter { const { selection, nestedContext } = this.selection.apply(context); const cypherSubquery = selection.return([ - this.getSubqueryReturnValue(nestedContext.returnVariable), + Cypher.head(Cypher.collect(nestedContext.returnVariable)), this.returnVariable, ]); @@ -86,35 +90,19 @@ export class CypherRelationshipFilter extends Filter { } } - private getSubqueryReturnValue(returnVariable: Cypher.Variable): Cypher.Expr { - if (this.isNullableSingle() && this.isNot) { - return Cypher.head(Cypher.collect(returnVariable)); - } - return returnVariable; - } - private createRelationshipOperation(queryASTContext: QueryASTContext): Cypher.Predicate | undefined { const targetNodePredicates = this.targetNodeFilters.map((c) => c.getPredicate(queryASTContext)); const innerPredicate = Cypher.and(...targetNodePredicates); - if (this.isNot && this.isNullableSingle()) { - // If the relationship is nullable and the operator is NOT SOME, we need to check if the relationship is null - // Note that NOT SOME is equivalent to NONE + if (this.isNull) { return Cypher.and(innerPredicate, Cypher.isNull(this.returnVariable)); } return innerPredicate; } - private isNullableSingle(): boolean { - return !this.attribute.typeHelper.isList() && !this.attribute.typeHelper.isRequired(); - } - private wrapInNotIfNeeded(predicate: Cypher.Predicate): Cypher.Predicate { - // Cypher.not is not desired when the relationship is a nullable not-list - // This is because we want to check IS NULL, rather than NOT IS NULL, - // even though this.isNot is set to true - if (this.isNot && !this.isNullableSingle()) { + if (this.isNot) { return Cypher.not(predicate); } diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index 223b7f1541..6cb61e3130 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -30,7 +30,7 @@ import { asArray, filterTruthy } from "../../../utils/utils"; import { isLogicalOperator } from "../../utils/logical-operators"; import type { RelationshipWhereOperator, WhereOperator } from "../../where/types"; import { ConnectionFilter } from "../ast/filters/ConnectionFilter"; -import { CypherRelationshipFilter } from "../ast/filters/CypherRelationshipFilter"; +import { CypherOneToOneRelationshipFilter } from "../ast/filters/CypherOneToOneRelationshipFilter"; import type { Filter } from "../ast/filters/Filter"; import { isRelationshipOperator } from "../ast/filters/Filter"; import { LogicalFilter } from "../ast/filters/LogicalFilter"; @@ -332,16 +332,15 @@ export class FilterFactory { if (!isNull && Object.keys(where).length === 0) { return []; } - // this is because if isNull is true we want to wrap the Exist subclause in a NOT, but if isNull is true and isNot is true they negate each other - const isNot = isNull ? !filterOps.isNot : filterOps.isNot; const filteredEntities = getConcreteEntities(target, where); - const cypherRelationshipFilters: CypherRelationshipFilter[] = []; + const cypherOneToOneRelationshipFilters: CypherOneToOneRelationshipFilter[] = []; for (const concreteEntity of filteredEntities) { const returnVariable = new Cypher.Node(); - const cypherRelationshipFilter = this.createCypherRelationshipFilterTreeNode({ + const cypherOneToOneRelationshipFilter = this.createCypherOneToOneRelationshipFilterTreeNode({ selection, - isNot, + isNot: filterOps.isNot, + isNull, operator: filterOps.operator || "SOME", attribute, returnVariable, @@ -350,24 +349,25 @@ export class FilterFactory { if (!isNull) { const entityWhere = where[concreteEntity.name] ?? where; const targetNodeFilters = this.createNodeFilters(concreteEntity, entityWhere); - cypherRelationshipFilter.addTargetNodeFilter(...targetNodeFilters); + cypherOneToOneRelationshipFilter.addTargetNodeFilter(...targetNodeFilters); } - cypherRelationshipFilters.push(cypherRelationshipFilter); + cypherOneToOneRelationshipFilters.push(cypherOneToOneRelationshipFilter); } const logicalOp = this.getLogicalOperatorForRelatedNodeFilters(target, filterOps.operator); - return this.wrapMultipleFiltersInLogical(cypherRelationshipFilters, logicalOp); + return this.wrapMultipleFiltersInLogical(cypherOneToOneRelationshipFilters, logicalOp); } // This allows to override this creation in AuthFilterFactory - protected createCypherRelationshipFilterTreeNode(options: { + protected createCypherOneToOneRelationshipFilterTreeNode(options: { selection: CustomCypherSelection; attribute: AttributeAdapter; isNot: boolean; + isNull: boolean; operator: RelationshipWhereOperator; returnVariable: Cypher.Node; - }): CypherRelationshipFilter { - return new CypherRelationshipFilter(options); + }): CypherOneToOneRelationshipFilter { + return new CypherOneToOneRelationshipFilter(options); } // This allows to override this creation in AuthFilterFactory diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.int.test.ts similarity index 92% rename from packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts rename to packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.int.test.ts index e73c70a192..7df49d7922 100644 --- a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-relationship.int.test.ts +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.int.test.ts @@ -19,7 +19,7 @@ import { TestHelper } from "../../../../utils/tests-helper"; -describe("cypher directive filtering - Relationship", () => { +describe("cypher directive filtering - One To One Relationship", () => { const testHelper = new TestHelper(); afterEach(async () => { @@ -114,6 +114,83 @@ describe("cypher directive filtering - Relationship", () => { }); }); + test("1 to 1 relationship with single property filter with non-deterministic result", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = /* GraphQL */ ` + type ${Movie} @node { + title: String + actor: ${Actor}! + @cypher( + statement: """ + MATCH (this)<-[:ACTED_IN]-(actor:${Actor}) + RETURN actor + """ + columnName: "actor" + ) + } + + type ${Actor} @node { + name: String + movie: ${Movie}! + @cypher( + statement: """ + MATCH (this)-[:ACTED_IN]->(movie:${Movie}) + RETURN movie + """ + columnName: "movie" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded" }) + CREATE (m3:${Movie} { title: "The Matrix Revolutions" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + CREATE (a)-[:ACTED_IN]->(m2) + CREATE (a)-[:ACTED_IN]->(m3) + `, + {} + ); + + const query = /* GraphQL */ ` + query { + ${Actor.plural}( + where: { + movie: { + title_STARTS_WITH: "The Matrix" + } + } + ) { + name + movie { + title + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Actor.plural]: expect.toIncludeSameMembers([ + { + name: "Keanu Reeves", + movie: { + // The result is non-deterministic, so we can potentially match any of the movies + title: expect.toStartWith("The Matrix"), + }, + }, + ]), + }); + }); + test("1 to 1 relationship with null filter", async () => { const Movie = testHelper.createUniqueType("Movie"); const Actor = testHelper.createUniqueType("Actor"); @@ -221,6 +298,8 @@ describe("cypher directive filtering - Relationship", () => { CREATE (m2:${Movie} { title: "The Matrix Reloaded", released: 2003 }) CREATE (m3:${Movie} { title: "The Matrix Revolutions", released: 2003 }) CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a2:${Actor} { name: "Jada Pinkett Smith" }) + CREATE (a2)-[:ACTED_IN]->(m3) CREATE (a)-[:ACTED_IN]->(m) CREATE (a)-[:ACTED_IN]->(m2) `, @@ -230,7 +309,7 @@ describe("cypher directive filtering - Relationship", () => { const query = /* GraphQL */ ` query { ${Movie.plural}( - where: { AND: [{ released_IN: [2003], actor: { NOT: null } }] } + where: { AND: [{ released_IN: [2003], NOT: { actor: null } }] } ) { title } @@ -245,6 +324,9 @@ describe("cypher directive filtering - Relationship", () => { { title: "The Matrix Reloaded", }, + { + title: "The Matrix Revolutions", + }, ]), }); }); diff --git a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.test.ts similarity index 97% rename from packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts rename to packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.test.ts index 70ea8c6776..0d612a8f7d 100644 --- a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-relationship.test.ts +++ b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.test.ts @@ -21,7 +21,7 @@ import { Neo4jGraphQL } from "../../../../../src"; import { createBearerToken } from "../../../../utils/create-bearer-token"; import { formatCypher, formatParams, translateQuery } from "../../../utils/tck-test-utils"; -describe("cypher directive filtering - Relationship", () => { +describe("cypher directive filtering - One To One Relationship", () => { test("1 to 1 relationship", async () => { const typeDefs = /* GraphQL */ ` type Movie @node { @@ -75,7 +75,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN actor } WITH actor AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.name = $param0 @@ -143,7 +143,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN actor } WITH actor AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE (this.released = $param0 AND (this1.name = $param1 AND this1.age > $param2)) @@ -220,7 +220,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN actor } WITH actor AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.name = $param0 @@ -350,7 +350,7 @@ describe("cypher directive filtering - Relationship", () => { const query = /* GraphQL */ ` query { - movies(where: { AND: [{ released_IN: [2003], actor: { NOT: null } }] }) { + movies(where: { AND: [{ released_IN: [2003], NOT: { actor: null } }] }) { title } } @@ -369,10 +369,10 @@ describe("cypher directive filtering - Relationship", () => { RETURN actor } WITH actor AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * - WHERE this.released IN $param0 + WHERE (this.released IN $param0 AND this1 IS NOT NULL) RETURN this { .title } AS this" `); @@ -455,7 +455,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -489,7 +489,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS this6 + RETURN head(collect(this5)) AS this6 } WITH * WHERE ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)) @@ -578,7 +578,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -612,7 +612,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS this6 + RETURN head(collect(this5)) AS this6 } WITH * WHERE ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)) @@ -700,7 +700,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -734,7 +734,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS this6 + RETURN head(collect(this5)) AS this6 } WITH * WHERE ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)) @@ -822,7 +822,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -856,7 +856,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS this6 + RETURN head(collect(this5)) AS this6 } WITH * WHERE ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)) @@ -945,7 +945,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -979,7 +979,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS this6 + RETURN head(collect(this5)) AS this6 } WITH * WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)), \\"@neo4j/graphql/FORBIDDEN\\", [0]) @@ -1068,7 +1068,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -1102,7 +1102,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS this6 + RETURN head(collect(this5)) AS this6 } WITH * WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)), \\"@neo4j/graphql/FORBIDDEN\\", [0]) @@ -1190,7 +1190,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -1224,7 +1224,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS this6 + RETURN head(collect(this5)) AS this6 } WITH * WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)), \\"@neo4j/graphql/FORBIDDEN\\", [0]) @@ -1312,7 +1312,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -1346,7 +1346,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this5 - RETURN this5 AS this6 + RETURN head(collect(this5)) AS this6 } WITH * WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND ($jwt.custom_value IS NOT NULL AND this6.name = $jwt.custom_value)), \\"@neo4j/graphql/FORBIDDEN\\", [0]) @@ -1437,7 +1437,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN movie } WITH movie AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE this1.title = $param0 @@ -1561,7 +1561,7 @@ describe("cypher directive filtering - Relationship", () => { RETURN director } WITH director AS this0 - RETURN this0 AS this1 + RETURN head(collect(this0)) AS this1 } WITH * WHERE (this.title ENDS WITH $param0 AND this1.name = $param1) From 51d9a457350972102133b4e52c2b5d6e194aead7 Mon Sep 17 00:00:00 2001 From: Michael Webb Date: Thu, 31 Oct 2024 17:42:02 +0100 Subject: [PATCH 20/20] test: update snapshot --- .../filtering/cypher-filtering-one-to-one-relationship.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.test.ts b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.test.ts index 0d612a8f7d..2e1b894c95 100644 --- a/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.test.ts +++ b/packages/graphql/tests/tck/directives/cypher/filtering/cypher-filtering-one-to-one-relationship.test.ts @@ -372,7 +372,7 @@ describe("cypher directive filtering - One To One Relationship", () => { RETURN head(collect(this0)) AS this1 } WITH * - WHERE (this.released IN $param0 AND this1 IS NOT NULL) + WHERE (this.released IN $param0 AND NOT (this1 IS NULL)) RETURN this { .title } AS this" `);