diff --git a/.changeset/curvy-tires-sniff.md b/.changeset/curvy-tires-sniff.md new file mode 100644 index 0000000000..fb77191d3c --- /dev/null +++ b/.changeset/curvy-tires-sniff.md @@ -0,0 +1,35 @@ +--- +"@neo4j/graphql": minor +--- + +Add support for case insensitive string filters. These can be enabled with the option `CASE_INSENSITIVE` in features: + +```javascript +const neoSchema = new Neo4jGraphQL({ + features: { + filters: { + String: { + CASE_INSENSITIVE: true, + }, + }, + }, +}); +``` + +This enables the field `caseInsensitive` on string filters: + +```graphql +query { + movies(where: { title: { caseInsensitive: { eq: "the matrix" } } }) { + title + } +} +``` + +This generates the following Cypher: + +```cypher +MATCH (this:Movie) +WHERE toLower(this.title) = toLower($param0) +RETURN this { .title } AS this +``` diff --git a/packages/graphql/src/graphql/input-objects/generic-operators/StringScalarFilters.ts b/packages/graphql/src/graphql/input-objects/generic-operators/StringScalarFilters.ts index 4840b2b3d6..56569b8381 100644 --- a/packages/graphql/src/graphql/input-objects/generic-operators/StringScalarFilters.ts +++ b/packages/graphql/src/graphql/input-objects/generic-operators/StringScalarFilters.ts @@ -49,6 +49,10 @@ export function getStringScalarFilters(features?: Neo4jFeaturesSettings): GraphQ case "LTE": fields["lte"] = { type: GraphQLString }; break; + case "CASE_INSENSITIVE": { + const CaseInsensitiveFilters = getCaseInsensitiveStringScalarFilters(features); + fields["caseInsensitive"] = { type: CaseInsensitiveFilters }; + } } } } @@ -59,6 +63,45 @@ export function getStringScalarFilters(features?: Neo4jFeaturesSettings): GraphQ }); } +function getCaseInsensitiveStringScalarFilters(features?: Neo4jFeaturesSettings): GraphQLInputObjectType { + const fields = { + eq: { + type: GraphQLString, + }, + in: { type: new GraphQLList(new GraphQLNonNull(GraphQLString)) }, + contains: { type: GraphQLString }, + endsWith: { type: GraphQLString }, + startsWith: { type: GraphQLString }, + }; + for (const filter of Object.entries(features?.filters?.String ?? {})) { + const [filterName, isEnabled] = filter; + if (isEnabled) { + switch (filterName) { + case "MATCHES": + fields["matches"] = { type: GraphQLString }; + break; + case "GT": + fields["gt"] = { type: GraphQLString }; + break; + case "GTE": + fields["gte"] = { type: GraphQLString }; + break; + case "LT": + fields["lt"] = { type: GraphQLString }; + break; + case "LTE": + fields["lte"] = { type: GraphQLString }; + break; + } + } + } + return new GraphQLInputObjectType({ + name: "CaseInsensitiveStringScalarFilters", + description: "Case insensitive String filters", + fields, + }); +} + export const StringListFilters = new GraphQLInputObjectType({ name: "StringListFilters", description: "String list filters", diff --git a/packages/graphql/src/schema/get-where-fields.ts b/packages/graphql/src/schema/get-where-fields.ts index 8aca78cf0a..2030940ab0 100644 --- a/packages/graphql/src/schema/get-where-fields.ts +++ b/packages/graphql/src/schema/get-where-fields.ts @@ -170,10 +170,13 @@ export function getWhereFieldsForAttributes({ } ); stringWhereOperators.forEach(({ comparator, typeName }) => { - result[`${field.name}_${comparator}`] = { - type: typeName, - directives: getAttributeDeprecationDirective(deprecatedDirectives, field, comparator), - }; + const excludedComparators = ["CASE_INSENSITIVE"]; + if (!excludedComparators.includes(comparator)) { + result[`${field.name}_${comparator}`] = { + type: typeName, + directives: getAttributeDeprecationDirective(deprecatedDirectives, field, comparator), + }; + } }); } } @@ -189,6 +192,7 @@ function getAttributeDeprecationDirective( if (deprecatedDirectives.length) { return deprecatedDirectives; } + switch (comparator) { case "DISTANCE": case "LT": 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 2a64b2f65a..e80e0364c2 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 @@ -34,6 +34,7 @@ export class PropertyFilter extends Filter { protected comparisonValue: unknown; protected operator: FilterOperator; protected attachedTo: "node" | "relationship"; + protected caseInsensitive: boolean; constructor({ attribute, @@ -41,12 +42,14 @@ export class PropertyFilter extends Filter { comparisonValue, operator, attachedTo = "node", + caseInsensitive = false, }: { attribute: AttributeAdapter; relationship?: RelationshipAdapter; comparisonValue: unknown; operator: FilterOperator; attachedTo?: "node" | "relationship"; + caseInsensitive?: boolean; }) { super(); this.attribute = attribute; @@ -54,6 +57,7 @@ export class PropertyFilter extends Filter { this.comparisonValue = comparisonValue; this.operator = operator; this.attachedTo = attachedTo; + this.caseInsensitive = caseInsensitive; } public getChildren(): QueryASTNode[] { @@ -61,7 +65,8 @@ export class PropertyFilter extends Filter { } public print(): string { - return `${super.print()} [${this.attribute.name}] <${this.operator}>`; + const caseInsensitiveStr = this.caseInsensitive ? "CASE INSENSITIVE " : ""; + return `${super.print()} [${this.attribute.name}] <${caseInsensitiveStr}${this.operator}>`; } public getPredicate(queryASTContext: QueryASTContext): Cypher.Predicate { @@ -148,6 +153,21 @@ export class PropertyFilter extends Filter { }): Cypher.ComparisonOp { const coalesceProperty = coalesceValueIfNeeded(this.attribute, property); - return createComparisonOperation({ operator, property: coalesceProperty, param }); + if (this.caseInsensitive) { + // Need to map all the items in the list to make case insensitive checks for lists + if (operator === "IN") { + const x = new Cypher.Variable(); + const lowercaseList = new Cypher.ListComprehension(x, param).map(Cypher.toLower(x)); + return Cypher.in(Cypher.toLower(coalesceProperty), lowercaseList); + } + + return createComparisonOperation({ + operator, + property: Cypher.toLower(coalesceProperty), + param: Cypher.toLower(param), + }); + } else { + return createComparisonOperation({ operator, property: coalesceProperty, param }); + } } } diff --git a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts index 5265190b8d..13268d6c59 100644 --- a/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/FilterFactory.ts @@ -237,12 +237,14 @@ export class FilterFactory { comparisonValue, operator, attachedTo, + caseInsensitive, }: { attribute: AttributeAdapter; relationship?: RelationshipAdapter; comparisonValue: GraphQLWhereArg; operator: FilterOperator | undefined; attachedTo?: "node" | "relationship"; + caseInsensitive?: boolean; }): Filter | Filter[] { if (attribute.annotations.cypher) { return this.createCypherFilter({ @@ -294,6 +296,7 @@ export class FilterFactory { comparisonValue, operator, attachedTo, + caseInsensitive, }); } @@ -560,10 +563,11 @@ export class FilterFactory { entity: ConcreteEntityAdapter | RelationshipAdapter | InterfaceEntityAdapter, fieldName: string, value: Record, - relationship?: RelationshipAdapter + relationship?: RelationshipAdapter, + caseInsensitive?: boolean ): Filter | Filter[] { const genericFilters = Object.entries(value).flatMap((filterInput) => { - return this.parseGenericFilter(entity, fieldName, filterInput, relationship); + return this.parseGenericFilter(entity, fieldName, filterInput, relationship, caseInsensitive); }); return this.wrapMultipleFiltersInLogical(genericFilters); } @@ -572,12 +576,13 @@ export class FilterFactory { entity: ConcreteEntityAdapter | RelationshipAdapter | InterfaceEntityAdapter, fieldName: string, filterInput: [string, any], - relationship?: RelationshipAdapter + relationship?: RelationshipAdapter, + caseInsensitive?: boolean ): Filter | Filter[] { const [rawOperator, value] = filterInput; if (isLogicalOperator(rawOperator)) { const nestedFilters = asArray(value).flatMap((nestedWhere) => { - return this.parseGenericFilter(entity, fieldName, nestedWhere, relationship); + return this.parseGenericFilter(entity, fieldName, nestedWhere, relationship, caseInsensitive); }); return new LogicalFilter({ operation: rawOperator, @@ -591,6 +596,9 @@ export class FilterFactory { return this.parseGenericFilters(entity, fieldName, desugaredInput, relationship); } + if (rawOperator === "caseInsensitive") { + return this.parseGenericFilters(entity, fieldName, value, relationship, true); + } const operator = this.parseGenericOperator(rawOperator); const attribute = entity.findAttribute(fieldName); @@ -611,6 +619,7 @@ export class FilterFactory { operator, attachedTo, relationship, + caseInsensitive, }); return this.wrapMultipleFiltersInLogical(asArray(filters)); } diff --git a/packages/graphql/src/types/index.ts b/packages/graphql/src/types/index.ts index 82ad352927..708798a774 100644 --- a/packages/graphql/src/types/index.ts +++ b/packages/graphql/src/types/index.ts @@ -384,6 +384,7 @@ export interface Neo4jStringFiltersSettings { LT?: boolean; LTE?: boolean; MATCHES?: boolean; + CASE_INSENSITIVE?: boolean; } export interface Neo4jIDFiltersSettings { diff --git a/packages/graphql/tests/integration/filtering/case-insensitive-string.int.test.ts b/packages/graphql/tests/integration/filtering/case-insensitive-string.int.test.ts new file mode 100644 index 0000000000..1806ec099d --- /dev/null +++ b/packages/graphql/tests/integration/filtering/case-insensitive-string.int.test.ts @@ -0,0 +1,324 @@ +/* + * 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("Filtering case insensitive string", () => { + const testHelper = new TestHelper(); + let Person: UniqueType; + let Movie: UniqueType; + + beforeEach(async () => { + Person = testHelper.createUniqueType("Person"); + Movie = testHelper.createUniqueType("Movie"); + + const typeDefs = ` + type ${Person} @node { + name: String! + movies: [${Movie}!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type ${Movie} @node { + title: String! + actors: [${Person}!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + + type ActedIn @relationshipProperties { + character: String! + } + `; + + await testHelper.executeCypher(` + CREATE (m1:${Movie} {title: "The Matrix"}) + CREATE (m2:${Movie} {title: "THE MATRIX"}) + CREATE (m3:${Movie} {title: "The Italian Job"}) + CREATE (m4:${Movie} {title: "The Lion King"}) + + CREATE(p1:${Person} {name: "Keanu"}) + CREATE(p2:${Person} {name: "Arthur Dent"}) + + CREATE(p1)-[:ACTED_IN {character: "neo"}]->(m1) + CREATE(p1)-[:ACTED_IN {character: "neo"}]->(m2) + + CREATE(p2)-[:ACTED_IN {character: "ghost mufasa"}]->(m4) + `); + + await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + filters: { + String: { + CASE_INSENSITIVE: true, + GTE: true, + MATCHES: true, + }, + }, + }, + }); + }); + + afterEach(async () => { + await testHelper.close(); + }); + + test("case insensitive gte", async () => { + const query = /* GraphQL */ ` + query { + ${Movie.plural}(where: { title: { caseInsensitive: { gte: "The Matrix" } } }) { + title + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + expect(result.data).toEqual({ + [Movie.plural]: expect.toIncludeSameMembers([ + { + title: "The Matrix", + }, + { + title: "THE MATRIX", + }, + ]), + }); + }); + + test("case insensitive in", async () => { + const query = /* GraphQL */ ` + query { + ${Movie.plural}(where: { title: { caseInsensitive: { in: ["the matrix", "THE LION KING"] } } }) { + title + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + expect(result.data).toEqual({ + [Movie.plural]: expect.toIncludeSameMembers([ + { + title: "The Matrix", + }, + { + title: "THE MATRIX", + }, + { + title: "The Lion King", + }, + ]), + }); + }); + + test("case insensitive contains", async () => { + const query = /* GraphQL */ ` + query { + ${Movie.plural}(where: { title: { caseInsensitive: { contains: "Matrix" } } }) { + title + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + expect(result.data).toEqual({ + [Movie.plural]: expect.toIncludeSameMembers([ + { + title: "The Matrix", + }, + { + title: "THE MATRIX", + }, + ]), + }); + }); + + test("case insensitive endsWith", async () => { + const query = /* GraphQL */ ` + query { + ${Movie.plural}(where: { title: { caseInsensitive: { endsWith: "RIX" } } }) { + title + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + expect(result.data).toEqual({ + [Movie.plural]: expect.toIncludeSameMembers([ + { + title: "The Matrix", + }, + { + title: "THE MATRIX", + }, + ]), + }); + }); + + describe("eq", () => { + test("case insensitive eq", async () => { + const query = /* GraphQL */ ` + query { + ${Movie.plural}(where: { title: { caseInsensitive: { eq: "the matrix" } } }) { + title + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + + expect(result.data).toEqual({ + [Movie.plural]: expect.toIncludeSameMembers([ + { + title: "The Matrix", + }, + { + title: "THE MATRIX", + }, + ]), + }); + }); + + test("case insensitive eq on related type filter", async () => { + const query = /* GraphQL */ ` + query { + ${Person.plural}(where: { movies: { none: { title: { caseInsensitive: { eq: "the matrix" } } } } }) { + name + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + + expect(result.data).toEqual({ + [Person.plural]: [ + { + name: "Arthur Dent", + }, + ], + }); + }); + + test("case insensitive eq in connection", async () => { + const query = /* GraphQL */ ` + query { + ${Movie.operations.connection}(where: { title: { caseInsensitive: { eq: "the matrix" } } }) { + edges { + node { + title + } + } + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + + expect(result.data).toEqual({ + [Movie.operations.connection]: { + edges: expect.toIncludeSameMembers([ + { + node: { + title: "The Matrix", + }, + }, + { + node: { + title: "THE MATRIX", + }, + }, + ]), + }, + }); + }); + + test("case insensitive eq on related node filter in connection", async () => { + const query = /* GraphQL */ ` + query { + ${Person.operations.connection}(where: { moviesConnection: { none: { node: { title: { caseInsensitive: { eq: "the matrix" } } } } } }) { + edges { + node { + name + } + } + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + + expect(result.data).toEqual({ + [Person.operations.connection]: { + edges: [ + { + node: { + name: "Arthur Dent", + }, + }, + ], + }, + }); + }); + + test("case insensitive eq on related edge filter in connection", async () => { + const query = /* GraphQL */ ` + query { + ${Person.operations.connection}(where: { moviesConnection: { none: { edge: { character: { caseInsensitive: { eq: "NEO" } } } } } }) { + edges { + node { + name + } + } + } + } + `; + + const result = await testHelper.executeGraphQL(query); + + expect(result.errors).toBeUndefined(); + + expect(result.data).toEqual({ + [Person.operations.connection]: { + edges: [ + { + node: { + name: "Arthur Dent", + }, + }, + ], + }, + }); + }); + }); +}); diff --git a/packages/graphql/tests/schema/string-comparators.test.ts b/packages/graphql/tests/schema/string-comparators.test.ts index c0af9c57ca..732fff383f 100644 --- a/packages/graphql/tests/schema/string-comparators.test.ts +++ b/packages/graphql/tests/schema/string-comparators.test.ts @@ -37,6 +37,7 @@ describe("String Comparators", () => { GT: true, GTE: true, LTE: true, + CASE_INSENSITIVE: true, }, }, }, @@ -50,6 +51,19 @@ describe("String Comparators", () => { mutation: Mutation } + \\"\\"\\"Case insensitive String filters\\"\\"\\" + input CaseInsensitiveStringScalarFilters { + contains: String + endsWith: String + eq: String + gt: String + gte: String + in: [String!] + lt: String + lte: String + startsWith: String + } + type Count { nodes: Int! } @@ -166,6 +180,7 @@ describe("String Comparators", () => { \\"\\"\\"String filters\\"\\"\\" input StringScalarFilters { + caseInsensitive: CaseInsensitiveStringScalarFilters contains: String endsWith: String eq: String @@ -553,6 +568,7 @@ describe("String Comparators", () => { GT: true, LTE: true, GTE: true, + CASE_INSENSITIVE: true, }, }, }, @@ -889,6 +905,19 @@ describe("String Comparators", () => { totalCount: Int! } + \\"\\"\\"Case insensitive String filters\\"\\"\\" + input CaseInsensitiveStringScalarFilters { + contains: String + endsWith: String + eq: String + gt: String + gte: String + in: [String!] + lt: String + lte: String + startsWith: String + } + input ConnectionAggregationCountFilterInput { edges: IntScalarFilters nodes: IntScalarFilters @@ -1256,6 +1285,7 @@ describe("String Comparators", () => { \\"\\"\\"String filters\\"\\"\\" input StringScalarFilters { + caseInsensitive: CaseInsensitiveStringScalarFilters contains: String endsWith: String eq: String diff --git a/packages/graphql/tests/tck/connections/filtering/node/string.test.ts b/packages/graphql/tests/tck/connections/filtering/node/string.test.ts index a4ab27f77f..3b76c0769b 100644 --- a/packages/graphql/tests/tck/connections/filtering/node/string.test.ts +++ b/packages/graphql/tests/tck/connections/filtering/node/string.test.ts @@ -46,6 +46,7 @@ describe("Cypher -> Connections -> Filtering -> Node -> String", () => { features: { filters: { String: { + CASE_INSENSITIVE: true, MATCHES: true, }, }, @@ -244,4 +245,52 @@ describe("Cypher -> Connections -> Filtering -> Node -> String", () => { }" `); }); + + test("Case insensitive contains", async () => { + const query = /* GraphQL */ ` + query { + movies { + title + actorsConnection(where: { node: { name: { caseInsensitive: { contains: "Tom" } } } }) { + edges { + properties { + screenTime + } + node { + name + } + } + } + } + } + `; + + const result = await translateQuery(neoSchema, query); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CYPHER 5 + MATCH (this:Movie) + CALL { + WITH this + MATCH (this)<-[this0:ACTED_IN]-(this1:Actor) + WHERE toLower(this1.name) CONTAINS toLower($param0) + WITH collect({ node: this1, relationship: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this1, edge.relationship AS this0 + RETURN collect({ properties: { screenTime: this0.screenTime, __resolveType: \\"ActedIn\\" }, node: { name: this1.name, __resolveType: \\"Actor\\" } }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS var3 + } + RETURN this { .title, actorsConnection: var3 } AS this" + `); + + expect(formatParams(result.params)).toMatchInlineSnapshot(` + "{ + \\"param0\\": \\"Tom\\" + }" + `); + }); });