Skip to content

Enable case insensitive strings #6250

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/curvy-tires-sniff.md
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,15 @@ export function filterByProperties<T>({
}
} else {
for (const [op, value] of Object.entries(v as Record<string, any>)) {
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;
}
}
}
}
Expand All @@ -79,6 +85,26 @@ export function filterByProperties<T>({
return true;
}

function checkCaseInsensitiveFilters(
v: Record<string, any>,
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()) {
Expand Down
2 changes: 1 addition & 1 deletion packages/graphql/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ export interface Neo4jStringFiltersSettings {
LT?: boolean;
LTE?: boolean;
MATCHES?: boolean;
// CASE_INSENSITIVE?: boolean;
CASE_INSENSITIVE?: boolean;
}

export interface Neo4jIDFiltersSettings {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Response> {
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;
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,7 +66,7 @@ describe.skip("Filtering case insensitive string", () => {
features: {
filters: {
String: {
// CASE_INSENSITIVE: true,
CASE_INSENSITIVE: true,
GTE: true,
MATCHES: true,
},
Expand Down
Loading
Loading