diff --git a/.changeset/lovely-beans-grin.md b/.changeset/lovely-beans-grin.md new file mode 100644 index 0000000000..93356c1d4c --- /dev/null +++ b/.changeset/lovely-beans-grin.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graphql": minor +--- + +Add filtering on scalar custom cypher fields diff --git a/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts b/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts index 341786940f..126b379da1 100644 --- a/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts +++ b/packages/graphql/src/schema-model/attribute/model-adapters/AttributeAdapter.ts @@ -135,8 +135,7 @@ export class AttributeAdapter { return ( (this.typeHelper.isEnum() || this.typeHelper.isSpatial() || this.typeHelper.isScalar()) && this.isFilterable() && - !this.isCustomResolvable() && - !this.isCypher() + !this.isCustomResolvable() ); } diff --git a/packages/graphql/src/schema/get-where-fields.ts b/packages/graphql/src/schema/get-where-fields.ts index 927a9b47d1..0928263556 100644 --- a/packages/graphql/src/schema/get-where-fields.ts +++ b/packages/graphql/src/schema/get-where-fields.ts @@ -214,7 +214,7 @@ export function getWhereFieldsForAttributes({ directives: deprecatedDirectives, }; - if (shouldAddDeprecatedFields(features, "negationFilters")) { + if (shouldAddDeprecatedFields(features, "negationFilters") && !field.isCypher()) { result[`${field.name}_NOT`] = { type: field.getInputTypeNames().where.pretty, directives: deprecatedDirectives.length ? deprecatedDirectives : [DEPRECATE_NOT], @@ -247,7 +247,7 @@ export function getWhereFieldsForAttributes({ type: field.getFilterableInputTypeName(), directives: deprecatedDirectives, }; - if (shouldAddDeprecatedFields(features, "negationFilters")) { + if (shouldAddDeprecatedFields(features, "negationFilters") && !field.isCypher()) { result[`${field.name}_NOT_IN`] = { type: field.getFilterableInputTypeName(), directives: deprecatedDirectives.length ? deprecatedDirectives : [DEPRECATE_NOT], diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts new file mode 100644 index 0000000000..d8caf79e4f --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts @@ -0,0 +1,123 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Cypher from "@neo4j/cypher-builder"; +import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; +import { createComparisonOperation } from "../../../utils/create-comparison-operator"; +import type { QueryASTContext } from "../../QueryASTContext"; +import type { QueryASTNode } from "../../QueryASTNode"; +import type { CustomCypherSelection } from "../../selection/CustomCypherSelection"; +import type { FilterOperator } from "../Filter"; +import { Filter } from "../Filter"; +import { coalesceValueIfNeeded } from "../utils/coalesce-if-needed"; +import { createDurationOperation } from "../utils/create-duration-operation"; +import { createPointOperation } from "../utils/create-point-operation"; + +/** A property which comparison has already been parsed into a Param */ +export class CypherFilter extends Filter { + private returnVariable: Cypher.Variable = new Cypher.Variable(); + private attribute: AttributeAdapter; + private selection: CustomCypherSelection; + private operator: FilterOperator; + protected comparisonValue: unknown; + + constructor({ + selection, + attribute, + operator, + comparisonValue, + }: { + selection: CustomCypherSelection; + attribute: AttributeAdapter; + operator: FilterOperator; + comparisonValue: unknown; + }) { + super(); + this.selection = selection; + this.attribute = attribute; + this.operator = operator; + this.comparisonValue = comparisonValue; + } + + public getChildren(): QueryASTNode[] { + return [this.selection]; + } + + public print(): string { + return `${super.print()} [${this.attribute.name}] <${this.operator}>`; + } + + public getSubqueries(context: QueryASTContext): Cypher.Clause[] { + const { selection: cypherSubquery, nestedContext } = this.selection.apply(context); + + const clause = Cypher.concat( + cypherSubquery, + new Cypher.Return([nestedContext.returnVariable, this.returnVariable]) + ); + + return [clause]; + } + + public getPredicate(_queryASTContext: QueryASTContext): Cypher.Predicate { + const operation = this.createBaseOperation({ + operator: this.operator, + property: this.returnVariable, + param: new Cypher.Param(this.comparisonValue), + }); + + return operation; + } + + /** Returns the default operation for a given filter */ + private createBaseOperation({ + operator, + property, + param, + }: { + operator: FilterOperator; + property: Cypher.Expr; + param: Cypher.Expr; + }): Cypher.ComparisonOp { + const coalesceProperty = coalesceValueIfNeeded(this.attribute, property); + + // This could be solved with specific a specific CypherDurationFilter but + // we need to use the return variable for the cypher subquery. + // To allow us to extend the DurationFilter class with a CypherDurationFilter class + // we would need to have a way to provide the return variable + // to the PropertyFilter's getPropertyRef method. + if (this.attribute.typeHelper.isDuration()) { + return createDurationOperation({ + operator, + property: coalesceProperty, + param: new Cypher.Param(this.comparisonValue), + }); + } + + if (this.attribute.typeHelper.isSpatial()) { + return createPointOperation({ + operator, + property: coalesceProperty, + param: new Cypher.Param(this.comparisonValue), + attribute: this.attribute, + }); + } + + return createComparisonOperation({ operator, property: coalesceProperty, param }); + } +} diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/DurationFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/DurationFilter.ts index 3f3e19ed22..6ee83e0cd6 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/DurationFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/DurationFilter.ts @@ -18,38 +18,18 @@ */ import Cypher from "@neo4j/cypher-builder"; -import type { WhereOperator } from "../Filter"; +import { coalesceValueIfNeeded } from "../utils/coalesce-if-needed"; +import { createDurationOperation } from "../utils/create-duration-operation"; import { PropertyFilter } from "./PropertyFilter"; export class DurationFilter extends PropertyFilter { - protected getOperation(prop: Cypher.Property): Cypher.ComparisonOp { - // NOTE: this may not be needed - if (this.operator === "EQ") { - return Cypher.eq(prop, new Cypher.Param(this.comparisonValue)); - } - return this.createDurationOperation({ + protected getOperation(prop: Cypher.Expr): Cypher.ComparisonOp { + const coalesceProperty = coalesceValueIfNeeded(this.attribute, prop); + + return createDurationOperation({ operator: this.operator, - property: prop, + property: coalesceProperty, param: new Cypher.Param(this.comparisonValue), }); } - - private createDurationOperation({ - operator, - property, - param, - }: { - operator: WhereOperator | "EQ"; - property: Cypher.Expr; - param: Cypher.Expr; - }) { - const variable = Cypher.plus(Cypher.datetime(), param); - const propertyRef = Cypher.plus(Cypher.datetime(), property); - - return this.createBaseOperation({ - operator, - property: propertyRef, - param: variable, - }); - } } diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts index 529e267b40..3c557711df 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PointFilter.ts @@ -18,72 +18,16 @@ */ import Cypher from "@neo4j/cypher-builder"; -import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; -import type { WhereOperator } from "../Filter"; +import { createPointOperation } from "../utils/create-point-operation"; import { PropertyFilter } from "./PropertyFilter"; export class PointFilter extends PropertyFilter { protected getOperation(prop: Cypher.Property): Cypher.ComparisonOp { - return this.createPointOperation({ + return createPointOperation({ operator: this.operator || "EQ", property: prop, param: new Cypher.Param(this.comparisonValue), attribute: this.attribute, }); } - - private createPointOperation({ - operator, - property, - param, - attribute, - }: { - operator: WhereOperator | "EQ"; - property: Cypher.Expr; - param: Cypher.Param; - attribute: AttributeAdapter; - }): Cypher.ComparisonOp { - const pointDistance = this.createPointDistanceExpression(property, param); - const distanceRef = param.property("distance"); - - switch (operator) { - case "LT": - return Cypher.lt(pointDistance, distanceRef); - case "LTE": - return Cypher.lte(pointDistance, distanceRef); - case "GT": - return Cypher.gt(pointDistance, distanceRef); - case "GTE": - return Cypher.gte(pointDistance, distanceRef); - case "DISTANCE": - return Cypher.eq(pointDistance, distanceRef); - case "EQ": { - if (attribute.typeHelper.isList()) { - const pointList = this.createPointListComprehension(param); - return Cypher.eq(property, pointList); - } - - return Cypher.eq(property, Cypher.point(param)); - } - case "IN": { - const pointList = this.createPointListComprehension(param); - return Cypher.in(property, pointList); - } - case "INCLUDES": - return Cypher.in(Cypher.point(param), property); - default: - throw new Error(`Invalid operator ${operator}`); - } - } - - private createPointListComprehension(param: Cypher.Param): Cypher.ListComprehension { - const comprehensionVar = new Cypher.Variable(); - const mapPoint = Cypher.point(comprehensionVar); - return new Cypher.ListComprehension(comprehensionVar, param).map(mapPoint); - } - - private createPointDistanceExpression(property: Cypher.Expr, param: Cypher.Param): Cypher.Function { - const nestedPointRef = param.property("point"); - return Cypher.point.distance(property, Cypher.point(nestedPointRef)); - } } diff --git a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts index fbc82bf0de..8e4d5d5995 100644 --- a/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts +++ b/packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts @@ -19,14 +19,15 @@ import Cypher from "@neo4j/cypher-builder"; import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; +import { InterfaceEntityAdapter } from "../../../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; +import type { RelationshipAdapter } from "../../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; +import { hasTarget } from "../../../utils/context-has-target"; import { createComparisonOperation } from "../../../utils/create-comparison-operator"; import type { QueryASTContext } from "../../QueryASTContext"; import type { QueryASTNode } from "../../QueryASTNode"; import type { FilterOperator } from "../Filter"; import { Filter } from "../Filter"; -import { hasTarget } from "../../../utils/context-has-target"; -import type { RelationshipAdapter } from "../../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; -import { InterfaceEntityAdapter } from "../../../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; +import { coalesceValueIfNeeded } from "../utils/coalesce-if-needed"; export class PropertyFilter extends Filter { protected attribute: AttributeAdapter; @@ -130,7 +131,7 @@ export class PropertyFilter extends Filter { /** Returns the operation for a given filter. * To be overridden by subclasses */ - protected getOperation(prop: Cypher.Property | Cypher.Case): Cypher.ComparisonOp { + protected getOperation(prop: Cypher.Expr): Cypher.ComparisonOp { return this.createBaseOperation({ operator: this.operator, property: prop, @@ -148,20 +149,11 @@ export class PropertyFilter extends Filter { property: Cypher.Expr; param: Cypher.Expr; }): Cypher.ComparisonOp { - const coalesceProperty = this.coalesceValueIfNeeded(property); + const coalesceProperty = coalesceValueIfNeeded(this.attribute, property); return createComparisonOperation({ operator, property: coalesceProperty, param }); } - protected coalesceValueIfNeeded(expr: Cypher.Expr): Cypher.Expr { - if (this.attribute.annotations.coalesce) { - const value = this.attribute.annotations.coalesce.value; - const literal = new Cypher.Literal(value); - return Cypher.coalesce(expr, literal); - } - return expr; - } - private getNullPredicate(propertyRef: Cypher.Property | Cypher.Case): Cypher.Predicate { if (this.isNot) { return Cypher.isNotNull(propertyRef); diff --git a/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts b/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts new file mode 100644 index 0000000000..feef64401e --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/filters/utils/coalesce-if-needed.ts @@ -0,0 +1,30 @@ +/* + * 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"; + +export function coalesceValueIfNeeded(attribute: AttributeAdapter, expr: Cypher.Expr): Cypher.Expr { + if (attribute.annotations.coalesce) { + const value = attribute.annotations.coalesce.value; + const literal = new Cypher.Literal(value); + return Cypher.coalesce(expr, literal); + } + return expr; +} diff --git a/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts new file mode 100644 index 0000000000..e713aeca23 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-duration-operation.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Cypher from "@neo4j/cypher-builder"; +import { createComparisonOperation } from "../../../utils/create-comparison-operator"; +import type { WhereOperator } from "../Filter"; + +export function createDurationOperation({ + operator, + property, + param, +}: { + operator: WhereOperator | "EQ"; + property: Cypher.Expr; + param: Cypher.Expr; +}): Cypher.ComparisonOp { + // NOTE: When we simply compare values, we don't need to prepend Cypher.datetime() + if (operator === "EQ") { + return Cypher.eq(property, param); + } + + const variable = Cypher.plus(Cypher.datetime(), param); + const propertyRef = Cypher.plus(Cypher.datetime(), property); + + return createComparisonOperation({ operator, property: propertyRef, param: variable }); +} diff --git a/packages/graphql/src/translate/queryAST/ast/filters/utils/create-point-operation.ts b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-point-operation.ts new file mode 100644 index 0000000000..12176431e4 --- /dev/null +++ b/packages/graphql/src/translate/queryAST/ast/filters/utils/create-point-operation.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Cypher from "@neo4j/cypher-builder"; +import type { AttributeAdapter } from "../../../../../schema-model/attribute/model-adapters/AttributeAdapter"; +import type { WhereOperator } from "../Filter"; + +export function createPointOperation({ + operator, + property, + param, + attribute, +}: { + operator: WhereOperator | "EQ"; + property: Cypher.Expr; + param: Cypher.Param; + attribute: AttributeAdapter; +}): Cypher.ComparisonOp { + const pointDistance = createPointDistanceExpression(property, param); + const distanceRef = param.property("distance"); + + switch (operator) { + case "LT": + return Cypher.lt(pointDistance, distanceRef); + case "LTE": + return Cypher.lte(pointDistance, distanceRef); + case "GT": + return Cypher.gt(pointDistance, distanceRef); + case "GTE": + return Cypher.gte(pointDistance, distanceRef); + case "DISTANCE": + return Cypher.eq(pointDistance, distanceRef); + case "EQ": { + if (attribute.typeHelper.isList()) { + const pointList = createPointListComprehension(param); + return Cypher.eq(property, pointList); + } + + return Cypher.eq(property, Cypher.point(param)); + } + case "IN": { + const pointList = createPointListComprehension(param); + return Cypher.in(property, pointList); + } + case "INCLUDES": + return Cypher.in(Cypher.point(param), property); + default: + throw new Error(`Invalid operator ${operator}`); + } +} + +function createPointListComprehension(param: Cypher.Param): Cypher.ListComprehension { + const comprehensionVar = new Cypher.Variable(); + const mapPoint = Cypher.point(comprehensionVar); + return new Cypher.ListComprehension(comprehensionVar, param).map(mapPoint); +} + +function createPointDistanceExpression(property: Cypher.Expr, param: Cypher.Param): Cypher.Function { + const nestedPointRef = param.property("point"); + return Cypher.point.distance(property, Cypher.point(nestedPointRef)); +} diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index cc31845f00..0b10e8669e 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -37,10 +37,12 @@ import { AggregationDurationFilter } from "../ast/filters/aggregation/Aggregatio import { AggregationFilter } from "../ast/filters/aggregation/AggregationFilter"; import { AggregationPropertyFilter } from "../ast/filters/aggregation/AggregationPropertyFilter"; import { CountFilter } from "../ast/filters/aggregation/CountFilter"; +import { CypherFilter } from "../ast/filters/property-filters/CypherFilter"; import { DurationFilter } from "../ast/filters/property-filters/DurationFilter"; import { PointFilter } from "../ast/filters/property-filters/PointFilter"; import { PropertyFilter } from "../ast/filters/property-filters/PropertyFilter"; import { TypenameFilter } from "../ast/filters/property-filters/TypenameFilter"; +import { CustomCypherSelection } from "../ast/selection/CustomCypherSelection"; import { getConcreteEntities } from "../utils/get-concrete-entities"; import { isConcreteEntity } from "../utils/is-concrete-entity"; import { isInterfaceEntity } from "../utils/is-interface-entity"; @@ -180,8 +182,24 @@ export class FilterFactory { operator: WhereOperator | undefined; isNot: boolean; attachedTo?: "node" | "relationship"; - }): PropertyFilter { + }): PropertyFilter | CypherFilter { const filterOperator = operator || "EQ"; + + if (attribute.annotations.cypher) { + const selection = new CustomCypherSelection({ + operationField: attribute, + rawArguments: {}, + isNested: true, + }); + + return new CypherFilter({ + selection, + attribute, + comparisonValue, + operator: filterOperator, + }); + } + if (attribute.typeHelper.isDuration()) { return new DurationFilter({ attribute, diff --git a/packages/graphql/tests/integration/directives/cypher/cypher.ts b/packages/graphql/tests/integration/directives/cypher/cypher.int.test.ts similarity index 100% rename from packages/graphql/tests/integration/directives/cypher/cypher.ts rename to packages/graphql/tests/integration/directives/cypher/cypher.int.test.ts diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-auth.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-auth.int.test.ts new file mode 100644 index 0000000000..497ea38d51 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-auth.int.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createBearerToken } from "../../../../utils/create-bearer-token"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("With authorization (custom Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const token = createBearerToken("secret", { title: "The Matrix" }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + custom_field + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + custom_field: "hello world!", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With authorization (not custom Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const token = createBearerToken("secret", { title: "The Matrix" }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQLWithToken(query, token); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-connect.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-connect.int.test.ts new file mode 100644 index 0000000000..d2be7e1b9d --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-connect.int.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("Connect filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + }); + + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + mutation { + ${Movie.operations.create}( + input: [ + { + title: "The Matrix Reloaded" + actors: { + connect: [ + { + where: { + node: { + name: "Keanu Reeves", + custom_field: "hello world!" + } + } + } + ] + create: [ + { + node: { + name: "Jada Pinkett Smith" + } + } + ] + } + } + ] + ) { + ${Movie.plural} { + title + actors { + name + } + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data?.[Movie.operations.create]?.[Movie.plural]).toIncludeSameMembers([ + { + title: "The Matrix Reloaded", + actors: expect.toIncludeSameMembers([ + { + name: "Keanu Reeves", + }, + { + name: "Jada Pinkett Smith", + }, + ]), + }, + ]); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-misc.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-misc.int.test.ts new file mode 100644 index 0000000000..c6b833b06c --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-misc.int.test.ts @@ -0,0 +1,363 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("With relationship filter (non-Cypher field)", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}( + where: { + custom_field: "hello world!" + actors_SOME: { + name: "Keanu Reeves" + } + } + ) { + custom_field + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + custom_field: "hello world!", + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("In a nested filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Actor.plural} { + name + movies(where: { custom_field: "hello world!"}) { + title + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Actor.plural]: [ + { + name: "Keanu Reeves", + movies: [ + { + title: "The Matrix", + }, + ], + }, + ], + }); + }); + + test("With a nested filter", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors(where: { name: "Keanu Reeves" }) { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With two cypher fields", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + another_custom_field: Int + @cypher( + statement: """ + RETURN 100 AS i + """ + columnName: "i" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!", another_custom_field_GT: 50 }) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); + + test("With two cypher fields, one nested", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + CREATE (m:${Movie} { title: "The Matrix" }) + CREATE (a:${Actor} { name: "Keanu Reeves" }) + CREATE (a)-[:ACTED_IN]->(m) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}(where: { custom_field: "hello world!" }) { + title + actors(where: { another_custom_field: "goodbye!" name: "Keanu Reeves" }) { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-scalar.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-scalar.int.test.ts new file mode 100644 index 0000000000..6ce947d625 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-scalar.int.test.ts @@ -0,0 +1,288 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../../utils/graphql-types"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + let CustomType: UniqueType; + + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + beforeEach(() => { + CustomType = testHelper.createUniqueType("CustomType"); + }); + + test.each([ + { + title: "Int cypher field: exact match", + filter: `special_count: 1`, + }, + { + title: "Int cypher field: GT", + filter: `special_count_GT: 0`, + }, + { + title: "Int cypher field: GTE", + filter: `special_count_GTE: 1`, + }, + { + title: "Int cypher field: LT", + filter: `special_count_LT: 2`, + }, + { + title: "Int cypher field: LTE", + filter: `special_count_LTE: 2`, + }, + { + title: "Int cypher field: IN", + filter: `special_count_IN: [1, 2, 3]`, + }, + ] as const)("$title", async ({ filter }) => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { ${filter} }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_count: 1, + }, + ], + }); + }); + + test.each([ + { + title: "String cypher field: exact match", + filter: `special_word: "test"`, + }, + { + title: "String cypher field: CONTAINS", + filter: `special_word_CONTAINS: "es"`, + }, + { + title: "String cypher field: ENDS_WITH", + filter: `special_word_ENDS_WITH: "est"`, + }, + { + title: "String cypher field: STARTS_WITH", + filter: `special_word_STARTS_WITH: "tes"`, + }, + { + title: "String cypher field: IN", + filter: `special_word_IN: ["test", "test2"]`, + }, + ] as const)("$title", async ({ filter }) => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_word: String + @cypher( + statement: """ + RETURN "test" as s + """ + columnName: "s" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { ${filter} }) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Int cypher field AND String title field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + UNWIND [ + {title: 'CustomType One' }, + {title: 'CustomType Two' }, + {title: 'CustomType Three' } + ] AS CustomTypeData + CREATE (m:${CustomType}) + SET m = CustomTypeData; + `, + {} + ); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1, title: "CustomType One" }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_count: 3, + }, + ], + }); + }); + + test("unmatched Int cypher field AND String title field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher( + ` + UNWIND [ + {title: 'CustomType One' }, + {title: 'CustomType Two' }, + {title: 'CustomType Three' } + ] AS CustomTypeData + CREATE (m:${CustomType}) + SET m = CustomTypeData; + `, + {} + ); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1, title: "CustomType Unknown" }) { + special_count + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [], + }); + }); + + test("Int cypher field, selecting String title field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:${CustomType}) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}(where: { special_count_GTE: 1 }) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-sorting.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-sorting.int.test.ts new file mode 100644 index 0000000000..d6729fb20d --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-sorting.int.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + test("With sorting", async () => { + const Movie = testHelper.createUniqueType("Movie"); + const Actor = testHelper.createUniqueType("Actor"); + + const typeDefs = ` + type ${Movie} @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [${Actor}!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type ${Actor} @node { + name: String + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + await testHelper.initNeo4jGraphQL({ + typeDefs, + }); + + await testHelper.executeCypher( + ` + CREATE (m1:${Movie} { title: "The Matrix" }) + CREATE (m2:${Movie} { title: "The Matrix Reloaded" }) + CREATE (a1:${Actor} { name: "Keanu Reeves" }) + CREATE (a2:${Actor} { name: "Jada Pinkett Smith" }) + CREATE (a1)-[:ACTED_IN]->(m1) + CREATE (a1)-[:ACTED_IN]->(m2) + CREATE (a2)-[:ACTED_IN]->(m2) + `, + {} + ); + + const query = ` + query { + ${Movie.plural}( + where: { custom_field: "hello world!" } + options: { sort: [{ title: DESC }] } + ) { + title + actors { + name + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [Movie.plural]: [ + { + title: "The Matrix Reloaded", + actors: expect.toIncludeSameMembers([ + { + name: "Keanu Reeves", + }, + { + name: "Jada Pinkett Smith", + }, + ]), + }, + { + title: "The Matrix", + actors: [ + { + name: "Keanu Reeves", + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-spatial.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-spatial.int.test.ts new file mode 100644 index 0000000000..51a1900796 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-spatial.int.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../../utils/graphql-types"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + let CustomType: UniqueType; + + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + beforeEach(() => { + CustomType = testHelper.createUniqueType("CustomType"); + }); + + test("Point cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_location: Point + @cypher( + statement: """ + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_location_DISTANCE: { + point: { latitude: 1, longitude: 1 } + distance: 0 + } + } + ) { + title + special_location { + latitude + longitude + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_location: { + latitude: 1, + longitude: 1, + }, + title: "test", + }, + ], + }); + }); + + test("CartesianPoint cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_location: CartesianPoint + @cypher( + statement: """ + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_location_DISTANCE: { + point: { x: 1, y: 1, z: 2 } + distance: 1 + } + } + ) { + title + special_location { + x + y + z + } + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_location: { + x: 1, + y: 1, + z: 1, + }, + title: "test", + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts new file mode 100644 index 0000000000..fe97cdcc13 --- /dev/null +++ b/packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-temporal.int.test.ts @@ -0,0 +1,283 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { UniqueType } from "../../../../utils/graphql-types"; +import { TestHelper } from "../../../../utils/tests-helper"; + +describe("cypher directive filtering", () => { + let CustomType: UniqueType; + + const testHelper = new TestHelper(); + + afterEach(async () => { + await testHelper.close(); + }); + + beforeEach(() => { + CustomType = testHelper.createUniqueType("CustomType"); + }); + + test("DateTime cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_time: DateTime + @cypher( + statement: """ + RETURN datetime("2024-09-03T15:30:00Z") AS t + """ + columnName: "t" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_time_GT: "2024-09-02T00:00:00Z" + } + ) { + special_time + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + special_time: "2024-09-03T15:30:00.000Z", + title: "test", + }, + ], + }); + }); + + test("Duration cypher field", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration: "P14DT16H12M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Duration cypher field LT", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration_LT: "P14DT16H13M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Duration cypher field LTE", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration_LTE: "P14DT16H12M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Duration cypher field GT", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration_GT: "P14DT16H11M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); + + test("Duration cypher field GTE", async () => { + const typeDefs = ` + type ${CustomType} @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + + await testHelper.initNeo4jGraphQL({ typeDefs }); + await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "test" })`, {}); + + const query = ` + query { + ${CustomType.plural}( + where: { + special_duration_GTE: "P14DT16H12M" + } + ) { + title + } + } + `; + + const gqlResult = await testHelper.executeGraphQL(query); + + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult?.data).toEqual({ + [CustomType.plural]: [ + { + title: "test", + }, + ], + }); + }); +}); diff --git a/packages/graphql/tests/schema/directives/cypher.test.ts b/packages/graphql/tests/schema/directives/cypher.test.ts index aa9b3168d5..2320d8ace7 100644 --- a/packages/graphql/tests/schema/directives/cypher.test.ts +++ b/packages/graphql/tests/schema/directives/cypher.test.ts @@ -357,6 +357,12 @@ describe("Cypher", () => { name_NOT_IN: [String] @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") name_NOT_STARTS_WITH: String @deprecated(reason: \\"Negation filters will be deprecated, use the NOT operator to achieve the same behavior\\") name_STARTS_WITH: String + totalScreenTime: Int + totalScreenTime_GT: Int + totalScreenTime_GTE: Int + totalScreenTime_IN: [Int!] + totalScreenTime_LT: Int + totalScreenTime_LTE: Int } type ActorsConnection { diff --git a/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts b/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts new file mode 100644 index 0000000000..f68f67dfc8 --- /dev/null +++ b/packages/graphql/tests/tck/directives/cypher/cypher-filtering.test.ts @@ -0,0 +1,1301 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Neo4jGraphQL } from "../../../../src"; +import { createBearerToken } from "../../../utils/create-bearer-token"; +import { formatCypher, formatParams, translateQuery } from "../../utils/tck-test-utils"; + +describe("cypher directive filtering", () => { + test("Int cypher field AND String title field", async () => { + const typeDefs = ` + type Movie @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:Movie) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + const query = ` + query { + movies(where: { special_count_GTE: 1, title: "CustomType One" }) { + special_count + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (this.title = $param0 AND var1 >= $param1) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this2 + RETURN this2 AS var3 + } + RETURN this { special_count: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"CustomType One\\", + \\"param1\\": { + \\"low\\": 1, + \\"high\\": 0 + } + }" + `); + }); + + test("unmatched Int cypher field AND String title field", async () => { + const typeDefs = ` + type Movie @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:Movie) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + const query = ` + query { + movies(where: { special_count_GTE: 1, title: "CustomType Unknown" }) { + special_count + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (this.title = $param0 AND var1 >= $param1) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this2 + RETURN this2 AS var3 + } + RETURN this { special_count: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"CustomType Unknown\\", + \\"param1\\": { + \\"low\\": 1, + \\"high\\": 0 + } + }" + `); + }); + + test("Int cypher field, selecting String title field", async () => { + const typeDefs = ` + type Movie @node { + title: String + special_count: Int + @cypher( + statement: """ + MATCH (m:Movie) + RETURN count(m) as c + """ + columnName: "c" + ) + } + `; + + const query = ` + query { + movies(where: { special_count_GTE: 1 }) { + title + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + MATCH (m:Movie) + RETURN count(m) as c + } + WITH c AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 >= $param0 + RETURN this { .title } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"low\\": 1, + \\"high\\": 0 + } + }" + `); + }); + + test("Point cypher field", async () => { + const typeDefs = ` + type Movie @node { + title: String + special_location: Point + @cypher( + statement: """ + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + const query = ` + query { + movies( + where: { + special_location_DISTANCE: { + point: { latitude: 1, longitude: 1 } + distance: 0 + } + } + ) { + title + special_location { + latitude + longitude + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + } + WITH l AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE point.distance(var1, point($param0.point)) = $param0.distance + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN point({ longitude: 1.0, latitude: 1.0 }) AS l + } + WITH l AS this2 + RETURN this2 AS var3 + } + RETURN this { .title, special_location: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"point\\": { + \\"longitude\\": 1, + \\"latitude\\": 1 + }, + \\"distance\\": 0 + } + }" + `); + }); + + test("CartesianPoint cypher field", async () => { + const typeDefs = ` + type Movie @node { + title: String + special_location: CartesianPoint + @cypher( + statement: """ + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + """ + columnName: "l" + ) + } + `; + + const query = ` + query { + movies( + where: { + special_location_DISTANCE: { + point: { x: 1, y: 1, z: 2 } + distance: 1 + } + } + ) { + title + special_location { + x + y + z + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + } + WITH l AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE point.distance(var1, point($param0.point)) = $param0.distance + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN point({ x: 1.0, y: 1.0, z: 1.0 }) AS l + } + WITH l AS this2 + RETURN this2 AS var3 + } + RETURN this { .title, special_location: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"point\\": { + \\"x\\": 1, + \\"y\\": 1, + \\"z\\": 2 + }, + \\"distance\\": 1 + } + }" + `); + }); + + test("DateTime cypher field", async () => { + const typeDefs = ` + type Movie @node { + title: String + special_time: DateTime + @cypher( + statement: """ + RETURN datetime("2024-09-03T15:30:00Z") AS t + """ + columnName: "t" + ) + } + `; + + const query = ` + query { + movies( + where: { + special_time_GT: "2024-09-02T00:00:00Z" + } + ) { + special_time + title + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN datetime(\\"2024-09-03T15:30:00Z\\") AS t + } + WITH t AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 > $param0 + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN datetime(\\"2024-09-03T15:30:00Z\\") AS t + } + WITH t AS this2 + RETURN this2 AS var3 + } + RETURN this { .title, special_time: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"year\\": 2024, + \\"month\\": 9, + \\"day\\": 2, + \\"hour\\": 0, + \\"minute\\": 0, + \\"second\\": 0, + \\"nanosecond\\": 0, + \\"timeZoneOffsetSeconds\\": 0 + } + }" + `); + }); + + test("Duration cypher field", async () => { + const typeDefs = ` + type Movie @node { + title: String + special_duration: Duration + @cypher( + statement: """ + RETURN duration('P14DT16H12M') AS d + """ + columnName: "d" + ) + } + `; + const query = ` + query { + movies( + where: { + special_duration: "P14DT16H12M" + } + ) { + title + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN duration('P14DT16H12M') AS d + } + WITH d AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 = $param0 + RETURN this { .title } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": { + \\"months\\": 0, + \\"days\\": 14, + \\"seconds\\": { + \\"low\\": 58320, + \\"high\\": 0 + }, + \\"nanoseconds\\": { + \\"low\\": 0, + \\"high\\": 0 + } + } + }" + `); + }); + + test("With relationship filter (non-Cypher field)", async () => { + const typeDefs = ` + type Movie @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies( + where: { + custom_field: "hello world!" + actors_SOME: { + name: "Keanu Reeves" + } + } + ) { + custom_field + title + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (var1 = $param0 AND EXISTS { + MATCH (this)<-[:ACTED_IN]-(this2:Actor) + WHERE this2.name = $param1 + }) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this3 + RETURN this3 AS var4 + } + CALL { + WITH this + MATCH (this)<-[this5:ACTED_IN]-(this6:Actor) + WITH this6 { .name } AS this6 + RETURN collect(this6) AS var7 + } + RETURN this { .title, custom_field: var4, actors: var7 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"param1\\": \\"Keanu Reeves\\" + }" + `); + }); + + test("In a nested filter", async () => { + const typeDefs = ` + type Movie @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + actors { + name + movies(where: { custom_field: "hello world!"}) { + title + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Actor) + CALL { + WITH this + MATCH (this)-[this0:ACTED_IN]->(this1:Movie) + CALL { + WITH this1 + CALL { + WITH this1 + WITH this1 AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this2 + RETURN this2 AS var3 + } + WITH * + WHERE var3 = $param0 + WITH this1 { .title } AS this1 + RETURN collect(this1) AS var4 + } + RETURN this { .name, movies: var4 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\" + }" + `); + }); + + test("With a nested filter", async () => { + const typeDefs = ` + type Movie @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies(where: { custom_field: "hello world!" }) { + title + actors(where: { name: "Keanu Reeves" }) { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 = $param0 + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Actor) + WHERE this3.name = $param1 + WITH this3 { .name } AS this3 + RETURN collect(this3) AS var4 + } + RETURN this { .title, actors: var4 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"param1\\": \\"Keanu Reeves\\" + }" + `); + }); + + test("With authorization (custom Cypher field)", async () => { + const typeDefs = ` + type Movie @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const token = createBearerToken("secret", { title: "The Matrix" }); + + const query = ` + query { + movies(where: { custom_field: "hello world!" }) { + title + custom_field + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (var1 = $param0 AND ($isAuthenticated = true AND ($jwt.title IS NOT NULL AND this.title = $jwt.title))) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this2 + RETURN this2 AS var3 + } + CALL { + WITH this + MATCH (this)<-[this4:ACTED_IN]-(this5:Actor) + WITH this5 { .name } AS this5 + RETURN collect(this5) AS var6 + } + RETURN this { .title, custom_field: var3, actors: var6 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"title\\": \\"The Matrix\\" + } + }" + `); + }); + + test("With authorization (not custom Cypher field)", async () => { + const typeDefs = ` + type Movie @node { + title: String @authorization(filter: [{ where: { node: { title: "$jwt.title" } } }]) + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const token = createBearerToken("secret", { title: "The Matrix" }); + + const query = ` + query { + movies(where: { custom_field: "hello world!" }) { + title + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + features: { + authorization: { + key: "secret", + }, + }, + }); + + const result = await translateQuery(neoSchema, query, { token }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE (var1 = $param0 AND ($isAuthenticated = true AND ($jwt.title IS NOT NULL AND this.title = $jwt.title))) + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Actor) + WITH this3 { .name } AS this3 + RETURN collect(this3) AS var4 + } + RETURN this { .title, actors: var4 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"isAuthenticated\\": true, + \\"jwt\\": { + \\"roles\\": [], + \\"title\\": \\"The Matrix\\" + } + }" + `); + }); + + test("With sorting", async () => { + const typeDefs = ` + type Movie @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies( + where: { custom_field: "hello world!" } + options: { sort: [{ title: DESC }] } + ) { + title + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 = $param0 + WITH * + ORDER BY this.title DESC + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Actor) + WITH this3 { .name } AS this3 + RETURN collect(this3) AS var4 + } + RETURN this { .title, actors: var4 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\" + }" + `); + }); + + test("Connect filter", async () => { + const typeDefs = ` + type Movie @node { + title: String + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + mutation { + createMovies( + input: [ + { + title: "The Matrix Reloaded" + actors: { + connect: [ + { + where: { + node: { + name: "Keanu Reeves", + custom_field: "hello world!" + } + } + } + ] + create: [ + { + node: { + name: "Jada Pinkett Smith" + } + } + ] + } + } + ] + ) { + movies { + title + actors { + name + } + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + WITH * + CREATE (this0_actors0_node:Actor) + SET this0_actors0_node.name = $this0_actors0_node_name + MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) + WITH * + CALL { + WITH this0 + OPTIONAL MATCH (this0_actors_connect0_node:Actor) + CALL { + WITH this0_actors_connect0_node + CALL { + WITH this0_actors_connect0_node + WITH this0_actors_connect0_node AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0_actors_connect0_node_this0 + RETURN this0_actors_connect0_node_this0 AS this0_actors_connect0_node_var1 + } + WITH *, CASE (this0_actors_connect0_node.name = $this0_actors_connect0_node_param0 AND this0_actors_connect0_node_var1 = $this0_actors_connect0_node_param1) + WHEN true THEN [this0_actors_connect0_node] + ELSE [NULL] + END AS aggregateWhereFiltervar0 + WITH *, aggregateWhereFiltervar0[0] AS this0_actors_connect0_node + CALL { + WITH * + WITH collect(this0_actors_connect0_node) as connectedNodes, collect(this0) as parentNodes + CALL { + WITH connectedNodes, parentNodes + UNWIND parentNodes as this0 + UNWIND connectedNodes as this0_actors_connect0_node + MERGE (this0)<-[:ACTED_IN]-(this0_actors_connect0_node) + } + } + WITH this0, this0_actors_connect0_node + RETURN count(*) AS connect_this0_actors_connect_Actor0 + } + RETURN this0 + } + CALL { + WITH this0 + CALL { + WITH this0 + MATCH (this0)<-[create_this0:ACTED_IN]-(create_this1:Actor) + WITH create_this1 { .name } AS create_this1 + RETURN collect(create_this1) AS create_var2 + } + RETURN this0 { .title, actors: create_var2 } AS create_var3 + } + RETURN [create_var3] AS data" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"this0_title\\": \\"The Matrix Reloaded\\", + \\"this0_actors0_node_name\\": \\"Jada Pinkett Smith\\", + \\"this0_actors_connect0_node_param0\\": \\"Keanu Reeves\\", + \\"this0_actors_connect0_node_param1\\": \\"hello world!\\", + \\"resolvedCallbacks\\": {} + }" + `); + }); + + test("With two cypher fields", async () => { + const typeDefs = ` + type Movie @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + another_custom_field: Int + @cypher( + statement: """ + RETURN 100 AS i + """ + columnName: "i" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies(where: { custom_field: "hello world!", another_custom_field_GT: 50 }) { + title + actors { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN 100 AS i + } + WITH i AS this2 + RETURN this2 AS var3 + } + WITH * + WHERE (var1 = $param0 AND var3 > $param1) + CALL { + WITH this + MATCH (this)<-[this4:ACTED_IN]-(this5:Actor) + WITH this5 { .name } AS this5 + RETURN collect(this5) AS var6 + } + RETURN this { .title, actors: var6 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"param1\\": { + \\"low\\": 50, + \\"high\\": 0 + } + }" + `); + }); + + test("With two cypher fields, one nested", async () => { + const typeDefs = ` + type Movie @node { + title: String + custom_field: String + @cypher( + statement: """ + RETURN "hello world!" AS s + """ + columnName: "s" + ) + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String + another_custom_field: String + @cypher( + statement: """ + RETURN "goodbye!" AS s + """ + columnName: "s" + ) + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const query = ` + query { + movies(where: { custom_field: "hello world!" }) { + title + actors(where: { another_custom_field: "goodbye!" name: "Keanu Reeves" }) { + name + } + } + } + `; + + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, + }); + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "MATCH (this:Movie) + CALL { + WITH this + CALL { + WITH this + WITH this AS this + RETURN \\"hello world!\\" AS s + } + WITH s AS this0 + RETURN this0 AS var1 + } + WITH * + WHERE var1 = $param0 + CALL { + WITH this + MATCH (this)<-[this2:ACTED_IN]-(this3:Actor) + CALL { + WITH this3 + CALL { + WITH this3 + WITH this3 AS this + RETURN \\"goodbye!\\" AS s + } + WITH s AS this4 + RETURN this4 AS var5 + } + WITH * + WHERE (this3.name = $param1 AND var5 = $param2) + WITH this3 { .name } AS this3 + RETURN collect(this3) AS var6 + } + RETURN this { .title, actors: var6 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"hello world!\\", + \\"param1\\": \\"Keanu Reeves\\", + \\"param2\\": \\"goodbye!\\" + }" + `); + }); +}); diff --git a/packages/graphql/tests/tck/directives/cypher/cypher.test.ts b/packages/graphql/tests/tck/directives/cypher/cypher.test.ts index f7270cdd6c..624e6fe455 100644 --- a/packages/graphql/tests/tck/directives/cypher/cypher.test.ts +++ b/packages/graphql/tests/tck/directives/cypher/cypher.test.ts @@ -21,87 +21,81 @@ import { Neo4jGraphQL } from "../../../../src"; import { formatCypher, formatParams, translateQuery } from "../../utils/tck-test-utils"; describe("Cypher directive", () => { - let typeDefs: string; - let neoSchema: Neo4jGraphQL; - - beforeAll(() => { - typeDefs = /* GraphQL */ ` - type Actor @node { - name: String - year: Int - movies(title: String): [Movie] - @cypher( - statement: """ - MATCH (m:Movie {title: $title}) - RETURN m - """ - columnName: "m" - ) - tvShows(title: String): [Movie] - @cypher( - statement: """ - MATCH (t:TVShow {title: $title}) - RETURN t - """ - columnName: "t" - ) - - randomNumber: Int - @cypher( - statement: """ - RETURN rand() as res - """ - columnName: "res" - ) - } - - type TVShow @node { - id: ID - title: String - numSeasons: Int - actors: [Actor] - @cypher( - statement: """ - MATCH (a:Actor) - RETURN a - """ - columnName: "a" - ) - topActor: Actor - @cypher( - statement: """ - MATCH (a:Actor) - RETURN a - """ - columnName: "a" - ) - } - - type Movie @node { - id: ID - title: String - actors: [Actor] - @cypher( - statement: """ - MATCH (a:Actor) - RETURN a - """ - columnName: "a" - ) - topActor: Actor - @cypher( - statement: """ - MATCH (a:Actor) - RETURN a - """ - columnName: "a" - ) - } - `; - - neoSchema = new Neo4jGraphQL({ - typeDefs, - }); + const typeDefs = /* GraphQL */ ` + type Actor @node { + name: String + year: Int + movies(title: String): [Movie] + @cypher( + statement: """ + MATCH (m:Movie {title: $title}) + RETURN m + """ + columnName: "m" + ) + tvShows(title: String): [Movie] + @cypher( + statement: """ + MATCH (t:TVShow {title: $title}) + RETURN t + """ + columnName: "t" + ) + + randomNumber: Int + @cypher( + statement: """ + RETURN rand() as res + """ + columnName: "res" + ) + } + + type TVShow @node { + id: ID + title: String + numSeasons: Int + actors: [Actor] + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + columnName: "a" + ) + topActor: Actor + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + columnName: "a" + ) + } + + type Movie @node { + id: ID + title: String + actors: [Actor] + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + columnName: "a" + ) + topActor: Actor + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + columnName: "a" + ) + } + `; + const neoSchema: Neo4jGraphQL = new Neo4jGraphQL({ + typeDefs, }); test("Simple directive", async () => {