Skip to content

Commit c3e4e09

Browse files
authored
Merge pull request #6811 from neo4j/6800-caseinsensitive-filter-does-not-work-for-fields-being-resolved-via-cypher-directive
Add case insensitive support to Cypher filter
2 parents b3598d8 + ae9d2e6 commit c3e4e09

File tree

8 files changed

+574
-17
lines changed

8 files changed

+574
-17
lines changed

.changeset/dirty-carrots-march.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@neo4j/graphql": patch
3+
---
4+
5+
Add case insensitive support to Cypher filter

packages/graphql/src/translate/queryAST/ast/filters/Filter.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* limitations under the License.
1818
*/
1919

20-
import type Cypher from "@neo4j/cypher-builder";
20+
import Cypher from "@neo4j/cypher-builder";
2121
import type { QueryASTContext } from "../QueryASTContext";
2222
import { QueryASTNode } from "../QueryASTNode";
2323

@@ -52,4 +52,26 @@ export function isRelationshipOperator(operator: string): operator is Relationsh
5252

5353
export abstract class Filter extends QueryASTNode {
5454
public abstract getPredicate(context: QueryASTContext): Cypher.Predicate | undefined;
55+
56+
protected applyCaseInsensitive(
57+
operator: FilterOperator,
58+
property: Cypher.Expr,
59+
param: Cypher.Expr
60+
): { operator: FilterOperator; property: Cypher.Expr; param: Cypher.Expr } {
61+
if (operator === "IN") {
62+
const x = new Cypher.Variable();
63+
const lowercaseList = new Cypher.ListComprehension(x).in(param).map(Cypher.toLower(x));
64+
return {
65+
operator,
66+
property: Cypher.toLower(property),
67+
param: lowercaseList,
68+
};
69+
}
70+
71+
return {
72+
operator,
73+
property: Cypher.toLower(property),
74+
param: Cypher.toLower(param),
75+
};
76+
}
5577
}

packages/graphql/src/translate/queryAST/ast/filters/property-filters/CypherFilter.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,30 @@ export class CypherFilter extends Filter {
3939
private operator: FilterOperator;
4040
protected comparisonValue: Cypher.Param | Cypher.Variable | Cypher.Property;
4141
private checkIsNotNull: boolean;
42+
protected caseInsensitive: boolean;
4243

4344
constructor({
4445
selection,
4546
attribute,
4647
operator,
4748
comparisonValue,
4849
checkIsNotNull = false,
50+
caseInsensitive = false,
4951
}: {
5052
selection: CustomCypherSelection;
5153
attribute: AttributeAdapter;
5254
operator: FilterOperator;
5355
comparisonValue: Cypher.Param | Cypher.Variable | Cypher.Property;
5456
checkIsNotNull?: boolean;
57+
caseInsensitive?: boolean;
5558
}) {
5659
super();
5760
this.selection = selection;
5861
this.attribute = attribute;
5962
this.operator = operator;
6063
this.comparisonValue = comparisonValue;
6164
this.checkIsNotNull = checkIsNotNull;
65+
this.caseInsensitive = caseInsensitive;
6266
}
6367

6468
public getChildren(): QueryASTNode[] {
@@ -147,6 +151,10 @@ export class CypherFilter extends Filter {
147151
});
148152
}
149153

150-
return createComparisonOperation({ operator, property: coalesceProperty, param });
154+
if (this.caseInsensitive) {
155+
return createComparisonOperation({ ...this.applyCaseInsensitive(operator, coalesceProperty, param) });
156+
} else {
157+
return createComparisonOperation({ operator, property: coalesceProperty, param });
158+
}
151159
}
152160
}

packages/graphql/src/translate/queryAST/ast/filters/property-filters/PropertyFilter.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -154,18 +154,7 @@ export class PropertyFilter extends Filter {
154154
const coalesceProperty = coalesceValueIfNeeded(this.attribute, property);
155155

156156
if (this.caseInsensitive) {
157-
// Need to map all the items in the list to make case insensitive checks for lists
158-
if (operator === "IN") {
159-
const x = new Cypher.Variable();
160-
const lowercaseList = new Cypher.ListComprehension(x, param).map(Cypher.toLower(x));
161-
return Cypher.in(Cypher.toLower(coalesceProperty), lowercaseList);
162-
}
163-
164-
return createComparisonOperation({
165-
operator,
166-
property: Cypher.toLower(coalesceProperty),
167-
param: Cypher.toLower(param),
168-
});
157+
return createComparisonOperation({ ...this.applyCaseInsensitive(operator, coalesceProperty, param) });
169158
} else {
170159
return createComparisonOperation({ operator, property: coalesceProperty, param });
171160
}

packages/graphql/src/translate/queryAST/factory/FilterFactory.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,12 @@ export class FilterFactory {
180180
attribute,
181181
comparisonValue,
182182
operator,
183+
caseInsensitive,
183184
}: {
184185
attribute: AttributeAdapter;
185186
comparisonValue: GraphQLWhereArg;
186187
operator: FilterOperator | undefined;
188+
caseInsensitive?: boolean;
187189
}): Filter | Filter[] {
188190
const selection = new CustomCypherSelection({
189191
operationField: attribute,
@@ -228,6 +230,7 @@ export class FilterFactory {
228230
attribute,
229231
comparisonValue: comparisonValueParam,
230232
operator: operator ?? "EQ",
233+
caseInsensitive,
231234
});
232235
}
233236

@@ -251,6 +254,7 @@ export class FilterFactory {
251254
attribute,
252255
comparisonValue,
253256
operator,
257+
caseInsensitive,
254258
});
255259
}
256260
// Implicit _EQ filters are removed but the argument "operator" can still be undefined in some cases, for instance:

packages/graphql/tests/integration/directives/cypher/filtering/cypher-filtering-scalar.int.test.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,15 @@ describe("cypher directive filtering - Scalar", () => {
106106
},
107107
{
108108
title: "String cypher field: ENDS_WITH",
109-
filter: `special_word:{ endsWith: "est"}`,
109+
filter: `special_word: { endsWith: "est"}`,
110110
},
111111
{
112112
title: "String cypher field: STARTS_WITH",
113-
filter: `special_word:{ startsWith: "tes"}`,
113+
filter: `special_word: { startsWith: "tes"}`,
114114
},
115115
{
116116
title: "String cypher field: IN",
117-
filter: `special_word:{ in: ["test", "test2"]}`,
117+
filter: `special_word: { in: ["test", "test2"]}`,
118118
},
119119
] as const)("$title", async ({ filter }) => {
120120
const typeDefs = /* GraphQL */ `
@@ -284,4 +284,71 @@ describe("cypher directive filtering - Scalar", () => {
284284
],
285285
});
286286
});
287+
288+
test.each([
289+
{
290+
title: "String cypher field: caseInsensitive eq",
291+
filter: `special_word: { caseInsensitive: { eq: "toyota" } }`,
292+
},
293+
{
294+
title: "String cypher field: caseInsensitive contains",
295+
filter: `special_word: { caseInsensitive: { contains: "YO" } }`,
296+
},
297+
{
298+
title: "String cypher field: caseInsensitive startsWith",
299+
filter: `special_word: { caseInsensitive: { startsWith: "to" } }`,
300+
},
301+
{
302+
title: "String cypher field: caseInsensitive endsWith",
303+
filter: `special_word: { caseInsensitive: { endsWith: "tA" } }`,
304+
},
305+
{
306+
title: "String cypher field: caseInsensitive in",
307+
filter: `special_word: { caseInsensitive: { in: ["toyota"] } }`,
308+
},
309+
] as const)("$title", async ({ filter }) => {
310+
const typeDefs = /* GraphQL */ `
311+
type ${CustomType} @node {
312+
title: String
313+
special_word: String
314+
@cypher(
315+
statement: """
316+
RETURN "Toyota" as s
317+
"""
318+
columnName: "s"
319+
)
320+
}
321+
`;
322+
323+
await testHelper.initNeo4jGraphQL({
324+
typeDefs,
325+
features: {
326+
filters: {
327+
String: {
328+
CASE_INSENSITIVE: true,
329+
},
330+
},
331+
},
332+
});
333+
await testHelper.executeCypher(`CREATE (m:${CustomType} { title: "Toyota" })`, {});
334+
335+
const query = /* GraphQL */ `
336+
query {
337+
${CustomType.plural}(where: { ${filter} }) {
338+
title
339+
}
340+
}
341+
`;
342+
343+
const gqlResult = await testHelper.executeGraphQL(query);
344+
345+
expect(gqlResult.errors).toBeFalsy();
346+
expect(gqlResult?.data).toEqual({
347+
[CustomType.plural]: [
348+
{
349+
title: "Toyota",
350+
},
351+
],
352+
});
353+
});
287354
});
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
* Copyright (c) "Neo4j"
3+
* Neo4j Sweden AB [http://neo4j.com]
4+
*
5+
* This file is part of Neo4j.
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
20+
import { UniqueType } from "../../utils/graphql-types";
21+
import { TestHelper } from "../../utils/tests-helper";
22+
23+
describe("https://github.com/neo4j/graphql/issues/6800", () => {
24+
const testHelper = new TestHelper();
25+
let typeDefs: string;
26+
27+
let Person: UniqueType;
28+
29+
beforeAll(async () => {
30+
Person = new UniqueType("Person");
31+
typeDefs = /* GraphQL */ `
32+
type ${Person} @node {
33+
name: String
34+
nickname: String!
35+
@cypher(
36+
statement: """
37+
RETURN this.name AS result
38+
"""
39+
columnName: "result"
40+
)
41+
}
42+
`;
43+
44+
await testHelper.initNeo4jGraphQL({
45+
typeDefs,
46+
features: {
47+
filters: {
48+
String: {
49+
CASE_INSENSITIVE: true,
50+
},
51+
},
52+
},
53+
});
54+
55+
await testHelper.executeCypher(`
56+
CREATE (:${Person} { name: "Toyota" })
57+
CREATE (:${Person} { name: "Marvin" })
58+
`);
59+
});
60+
61+
afterAll(async () => {
62+
await testHelper.close();
63+
});
64+
65+
test("should filter string field using case insensitive", async () => {
66+
const query = /* GraphQL */ `
67+
query {
68+
eq: ${Person.plural}(where: { name: { caseInsensitive: { eq: "toyota" } } }) {
69+
name
70+
}
71+
contains: ${Person.plural}(where: { name: { caseInsensitive: { contains: "YO" } } }) {
72+
name
73+
}
74+
startsWith: ${Person.plural}(where: { name: { caseInsensitive: { startsWith: "to" } } }) {
75+
name
76+
}
77+
endsWith: ${Person.plural}(where: { name: { caseInsensitive: { endsWith: "tA" } } }) {
78+
name
79+
}
80+
in: ${Person.plural}(where: { name: { caseInsensitive: { in: ["toyota"] } } }) {
81+
name
82+
}
83+
}
84+
`;
85+
86+
const queryResult = await testHelper.executeGraphQL(query);
87+
88+
expect(queryResult.errors).toBeUndefined();
89+
expect(queryResult.data).toEqual({
90+
eq: [
91+
{
92+
name: "Toyota",
93+
},
94+
],
95+
contains: [
96+
{
97+
name: "Toyota",
98+
},
99+
],
100+
startsWith: [
101+
{
102+
name: "Toyota",
103+
},
104+
],
105+
endsWith: [
106+
{
107+
name: "Toyota",
108+
},
109+
],
110+
in: [
111+
{
112+
name: "Toyota",
113+
},
114+
],
115+
});
116+
});
117+
118+
test("should filter cypher field using case insensitive", async () => {
119+
const query = /* GraphQL */ `
120+
query {
121+
eq: ${Person.plural}(where: { nickname: { caseInsensitive: { eq: "toyota" } } }) {
122+
name
123+
}
124+
contains: ${Person.plural}(where: { nickname: { caseInsensitive: { contains: "YO" } } }) {
125+
name
126+
}
127+
startsWith: ${Person.plural}(where: { nickname: { caseInsensitive: { startsWith: "to" } } }) {
128+
name
129+
}
130+
endsWith: ${Person.plural}(where: { nickname: { caseInsensitive: { endsWith: "tA" } } }) {
131+
name
132+
}
133+
in: ${Person.plural}(where: { nickname: { caseInsensitive: { in: ["toyota"] } } }) {
134+
name
135+
}
136+
}
137+
`;
138+
139+
const queryResult = await testHelper.executeGraphQL(query);
140+
141+
expect(queryResult.errors).toBeUndefined();
142+
expect(queryResult.data).toEqual({
143+
eq: [
144+
{
145+
name: "Toyota",
146+
},
147+
],
148+
contains: [
149+
{
150+
name: "Toyota",
151+
},
152+
],
153+
startsWith: [
154+
{
155+
name: "Toyota",
156+
},
157+
],
158+
endsWith: [
159+
{
160+
name: "Toyota",
161+
},
162+
],
163+
in: [
164+
{
165+
name: "Toyota",
166+
},
167+
],
168+
});
169+
});
170+
});

0 commit comments

Comments
 (0)