From ffd4ce2a43994ce0b2b227e0122ea3932548ee94 Mon Sep 17 00:00:00 2001 From: angrykoala Date: Mon, 28 Apr 2025 17:06:33 +0100 Subject: [PATCH 1/2] Add case insensitive filters for subscriptions --- .../where/filters/filter-by-properties.ts | 32 ++- ...tring-case-insensitive-filters.e2e.test.ts | 206 ++++++++++++++++++ 2 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 packages/graphql/tests/e2e/subscriptions/filtering/create-string-case-insensitive-filters.e2e.test.ts diff --git a/packages/graphql/src/schema/resolvers/subscriptions/where/filters/filter-by-properties.ts b/packages/graphql/src/schema/resolvers/subscriptions/where/filters/filter-by-properties.ts index 2bb4186f49..e761e19922 100644 --- a/packages/graphql/src/schema/resolvers/subscriptions/where/filters/filter-by-properties.ts +++ b/packages/graphql/src/schema/resolvers/subscriptions/where/filters/filter-by-properties.ts @@ -68,9 +68,15 @@ export function filterByProperties({ } } else { for (const [op, value] of Object.entries(v as Record)) { - const checkFilterPasses = getFilteringFn(op, operatorMapOverrides); - if (!checkFilterPasses(receivedValue, value, fieldMeta)) { - return false; + if (op === "caseInsensitive") { + if (!checkCaseInsensitiveFilters(value, receivedValue, fieldMeta)) { + return false; + } + } else { + const checkFilterPasses = getFilteringFn(op, operatorMapOverrides); + if (!checkFilterPasses(receivedValue, value, fieldMeta)) { + return false; + } } } } @@ -79,6 +85,26 @@ export function filterByProperties({ return true; } +function checkCaseInsensitiveFilters( + v: Record, + receivedValue: any, + fieldMeta?: AttributeAdapter +): boolean { + for (const [op, value] of Object.entries(v)) { + const checkFilterPasses = getFilteringFn(op, operatorMapOverrides); + if (op === "in") { + return value.some((v) => v.toLowerCase() === receivedValue.toLowerCase()); + } + if (typeof receivedValue !== "string" || typeof value !== "string") { + return false; + } else if (!checkFilterPasses(receivedValue.toLowerCase(), value.toLowerCase(), fieldMeta)) { + return false; + } + } + + return true; +} + /** Checks if field is a string that needs to be parsed as int */ function shouldParseAsInt(attributeAdapter: AttributeAdapter | undefined, value: string | number) { if (attributeAdapter?.typeHelper.isFloat() || attributeAdapter?.typeHelper.isString()) { diff --git a/packages/graphql/tests/e2e/subscriptions/filtering/create-string-case-insensitive-filters.e2e.test.ts b/packages/graphql/tests/e2e/subscriptions/filtering/create-string-case-insensitive-filters.e2e.test.ts new file mode 100644 index 0000000000..7fab06e1e1 --- /dev/null +++ b/packages/graphql/tests/e2e/subscriptions/filtering/create-string-case-insensitive-filters.e2e.test.ts @@ -0,0 +1,206 @@ +/* + * 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 { Response } from "supertest"; +import supertest from "supertest"; +import type { UniqueType } from "../../../utils/graphql-types"; +import { TestHelper } from "../../../utils/tests-helper"; +import type { TestGraphQLServer } from "../../setup/apollo-server"; +import { ApolloTestServer } from "../../setup/apollo-server"; +import { WebSocketTestClient } from "../../setup/ws-client"; + +describe("Create Subscription with filters valid on string types (String, ID)", () => { + const testHelper = new TestHelper({ cdc: true }); + let server: TestGraphQLServer; + let wsClient: WebSocketTestClient; + let typeMovie: UniqueType; + + beforeAll(async () => { + await testHelper.assertCDCEnabled(); + }); + + beforeEach(async () => { + typeMovie = testHelper.createUniqueType("Movie"); + const typeDefs = ` + type ${typeMovie} @node @subscription { + id: ID + title: String + similarTitles: [String!] + releasedIn: Int + averageRating: Float + fileSize: BigInt + isFavorite: Boolean + } + `; + + const neoSchema = await testHelper.initNeo4jGraphQL({ + typeDefs, + features: { + subscriptions: await testHelper.getSubscriptionEngine(), + filters: { + String: { + CASE_INSENSITIVE: true, + }, + }, + }, + }); + + // eslint-disable-next-line @typescript-eslint/require-await + server = new ApolloTestServer(neoSchema, async ({ req }) => ({ + sessionConfig: { + database: testHelper.database, + }, + token: req.headers.authorization, + })); + await server.start(); + + wsClient = new WebSocketTestClient(server.wsPath); + }); + + afterEach(async () => { + await wsClient.close(); + await server.close(); + await testHelper.close(); + }); + + test("subscription with where filter using caseInsensitive 'eq' for String", async () => { + await wsClient.subscribe(` + subscription { + ${typeMovie.operations.subscribe.created}(where: { title: { caseInsensitive: { eq: "movie1" } } }) { + ${typeMovie.operations.subscribe.payload.created} { + title + } + } + } + `); + + await createMovie({ title: "Movie1" }); + await createMovie({ title: "mvie2" }); + + await wsClient.waitForEvents(1); + + expect(wsClient.errors).toEqual([]); + expect(wsClient.events).toEqual([ + { + [typeMovie.operations.subscribe.created]: { + [typeMovie.operations.subscribe.payload.created]: { title: "Movie1" }, + }, + }, + ]); + }); + + test("subscription with where filter using caseInsensitive 'contains' for String", async () => { + await wsClient.subscribe(` + subscription { + ${typeMovie.operations.subscribe.created}(where: { title: { caseInsensitive: { contains: "movie" } } }) { + ${typeMovie.operations.subscribe.payload.created} { + title + } + } + } + `); + + await createMovie({ title: "Movie1" }); + await createMovie({ title: "mvie2" }); + + await wsClient.waitForEvents(1); + + expect(wsClient.errors).toEqual([]); + expect(wsClient.events).toEqual([ + { + [typeMovie.operations.subscribe.created]: { + [typeMovie.operations.subscribe.payload.created]: { title: "Movie1" }, + }, + }, + ]); + }); + + test("subscription with where filter using caseInsensitive 'in' for String", async () => { + await wsClient.subscribe(` + subscription { + ${typeMovie.operations.subscribe.created}(where: { title: { caseInsensitive: { in: ["m2", "movie1"] } } }) { + ${typeMovie.operations.subscribe.payload.created} { + title + } + } + } + `); + + await createMovie({ title: "Movie1" }); + await createMovie({ title: "mvie2" }); + + await wsClient.waitForEvents(1); + + expect(wsClient.errors).toEqual([]); + expect(wsClient.events).toEqual([ + { + [typeMovie.operations.subscribe.created]: { + [typeMovie.operations.subscribe.payload.created]: { title: "Movie1" }, + }, + }, + ]); + }); + + const makeTypedFieldValue = (value) => { + if (typeof value === "string") { + return `"${value}"`; + } + if (Array.isArray(value)) { + return `[${value.map(makeTypedFieldValue)}]`; + } + return value; + }; + async function createMovie(all: { + id?: string | number; + title?: string; + releasedIn?: number; + averageRating?: number; + fileSize?: string; + isFavorite?: boolean; + similarTitles?: string[]; + }): Promise { + const movieInput = Object.entries(all) + .filter(([, v]) => v) + .map(([k, v]) => { + return `${k}: ${makeTypedFieldValue(v)}`; + }) + .join(", "); + const result = await supertest(server.path) + .post("") + .send({ + query: ` + mutation { + ${typeMovie.operations.create}(input: [{ ${movieInput} }]) { + ${typeMovie.plural} { + id + title + similarTitles + releasedIn + averageRating + fileSize + isFavorite + } + } + } + `, + }) + .expect(200); + return result; + } +}); From 1abc5bc962a6d0ff0726293bbc4c71128985fe4c Mon Sep 17 00:00:00 2001 From: angrykoala Date: Wed, 30 Apr 2025 15:12:46 +0100 Subject: [PATCH 2/2] Revert "Disable case insensitive flag" --- .changeset/curvy-tires-sniff.md | 35 +++++++++++++++++++ .../generic-operators/StringScalarFilters.ts | 8 ++--- packages/graphql/src/types/index.ts | 2 +- .../case-insensitive-string.int.test.ts | 6 ++-- .../tests/schema/string-comparators.test.ts | 32 +++++++++++++++-- .../connections/filtering/node/string.test.ts | 5 ++- 6 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 .changeset/curvy-tires-sniff.md 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 b395728edd..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,10 +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 }; - // } + case "CASE_INSENSITIVE": { + const CaseInsensitiveFilters = getCaseInsensitiveStringScalarFilters(features); + fields["caseInsensitive"] = { type: CaseInsensitiveFilters }; + } } } } diff --git a/packages/graphql/src/types/index.ts b/packages/graphql/src/types/index.ts index 226e6e0358..708798a774 100644 --- a/packages/graphql/src/types/index.ts +++ b/packages/graphql/src/types/index.ts @@ -384,7 +384,7 @@ export interface Neo4jStringFiltersSettings { LT?: boolean; LTE?: boolean; MATCHES?: boolean; - // CASE_INSENSITIVE?: 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 index d228bd393e..1806ec099d 100644 --- a/packages/graphql/tests/integration/filtering/case-insensitive-string.int.test.ts +++ b/packages/graphql/tests/integration/filtering/case-insensitive-string.int.test.ts @@ -20,9 +20,7 @@ import type { UniqueType } from "../../utils/graphql-types"; import { TestHelper } from "../../utils/tests-helper"; -// Case insensitive is not available yet -// eslint-disable-next-line jest/no-disabled-tests -describe.skip("Filtering case insensitive string", () => { +describe("Filtering case insensitive string", () => { const testHelper = new TestHelper(); let Person: UniqueType; let Movie: UniqueType; @@ -68,7 +66,7 @@ describe.skip("Filtering case insensitive string", () => { features: { filters: { String: { - // CASE_INSENSITIVE: true, + CASE_INSENSITIVE: true, GTE: true, MATCHES: true, }, diff --git a/packages/graphql/tests/schema/string-comparators.test.ts b/packages/graphql/tests/schema/string-comparators.test.ts index ebac24865c..732fff383f 100644 --- a/packages/graphql/tests/schema/string-comparators.test.ts +++ b/packages/graphql/tests/schema/string-comparators.test.ts @@ -37,7 +37,7 @@ describe("String Comparators", () => { GT: true, GTE: true, LTE: true, - // CASE_INSENSITIVE: true, + CASE_INSENSITIVE: true, }, }, }, @@ -51,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! } @@ -167,6 +180,7 @@ describe("String Comparators", () => { \\"\\"\\"String filters\\"\\"\\" input StringScalarFilters { + caseInsensitive: CaseInsensitiveStringScalarFilters contains: String endsWith: String eq: String @@ -554,7 +568,7 @@ describe("String Comparators", () => { GT: true, LTE: true, GTE: true, - // CASE_INSENSITIVE: true, + CASE_INSENSITIVE: true, }, }, }, @@ -891,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 @@ -1258,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 fb1be78a13..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,7 +46,7 @@ describe("Cypher -> Connections -> Filtering -> Node -> String", () => { features: { filters: { String: { - // CASE_INSENSITIVE: true, + CASE_INSENSITIVE: true, MATCHES: true, }, }, @@ -246,8 +246,7 @@ describe("Cypher -> Connections -> Filtering -> Node -> String", () => { `); }); - // eslint-disable-next-line jest/no-disabled-tests - test.skip("Case insensitive contains", async () => { + test("Case insensitive contains", async () => { const query = /* GraphQL */ ` query { movies {