Skip to content

Commit 53f794a

Browse files
authored
Merge pull request #6200 from neo4j/4827-add-new-filters-for-string-types-to-enable-case-insensitive-filtering
Add caseInsensitive field on string filters
2 parents 3da0780 + 8bf3013 commit 53f794a

File tree

9 files changed

+525
-10
lines changed

9 files changed

+525
-10
lines changed

.changeset/curvy-tires-sniff.md

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@neo4j/graphql": minor
3+
---
4+
5+
Add support for case insensitive string filters. These can be enabled with the option `CASE_INSENSITIVE` in features:
6+
7+
```javascript
8+
const neoSchema = new Neo4jGraphQL({
9+
features: {
10+
filters: {
11+
String: {
12+
CASE_INSENSITIVE: true,
13+
},
14+
},
15+
},
16+
});
17+
```
18+
19+
This enables the field `caseInsensitive` on string filters:
20+
21+
```graphql
22+
query {
23+
movies(where: { title: { caseInsensitive: { eq: "the matrix" } } }) {
24+
title
25+
}
26+
}
27+
```
28+
29+
This generates the following Cypher:
30+
31+
```cypher
32+
MATCH (this:Movie)
33+
WHERE toLower(this.title) = toLower($param0)
34+
RETURN this { .title } AS this
35+
```

packages/graphql/src/graphql/input-objects/generic-operators/StringScalarFilters.ts

+43
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export function getStringScalarFilters(features?: Neo4jFeaturesSettings): GraphQ
4949
case "LTE":
5050
fields["lte"] = { type: GraphQLString };
5151
break;
52+
case "CASE_INSENSITIVE": {
53+
const CaseInsensitiveFilters = getCaseInsensitiveStringScalarFilters(features);
54+
fields["caseInsensitive"] = { type: CaseInsensitiveFilters };
55+
}
5256
}
5357
}
5458
}
@@ -59,6 +63,45 @@ export function getStringScalarFilters(features?: Neo4jFeaturesSettings): GraphQ
5963
});
6064
}
6165

66+
function getCaseInsensitiveStringScalarFilters(features?: Neo4jFeaturesSettings): GraphQLInputObjectType {
67+
const fields = {
68+
eq: {
69+
type: GraphQLString,
70+
},
71+
in: { type: new GraphQLList(new GraphQLNonNull(GraphQLString)) },
72+
contains: { type: GraphQLString },
73+
endsWith: { type: GraphQLString },
74+
startsWith: { type: GraphQLString },
75+
};
76+
for (const filter of Object.entries(features?.filters?.String ?? {})) {
77+
const [filterName, isEnabled] = filter;
78+
if (isEnabled) {
79+
switch (filterName) {
80+
case "MATCHES":
81+
fields["matches"] = { type: GraphQLString };
82+
break;
83+
case "GT":
84+
fields["gt"] = { type: GraphQLString };
85+
break;
86+
case "GTE":
87+
fields["gte"] = { type: GraphQLString };
88+
break;
89+
case "LT":
90+
fields["lt"] = { type: GraphQLString };
91+
break;
92+
case "LTE":
93+
fields["lte"] = { type: GraphQLString };
94+
break;
95+
}
96+
}
97+
}
98+
return new GraphQLInputObjectType({
99+
name: "CaseInsensitiveStringScalarFilters",
100+
description: "Case insensitive String filters",
101+
fields,
102+
});
103+
}
104+
62105
export const StringListFilters = new GraphQLInputObjectType({
63106
name: "StringListFilters",
64107
description: "String list filters",

packages/graphql/src/schema/get-where-fields.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,13 @@ export function getWhereFieldsForAttributes({
170170
}
171171
);
172172
stringWhereOperators.forEach(({ comparator, typeName }) => {
173-
result[`${field.name}_${comparator}`] = {
174-
type: typeName,
175-
directives: getAttributeDeprecationDirective(deprecatedDirectives, field, comparator),
176-
};
173+
const excludedComparators = ["CASE_INSENSITIVE"];
174+
if (!excludedComparators.includes(comparator)) {
175+
result[`${field.name}_${comparator}`] = {
176+
type: typeName,
177+
directives: getAttributeDeprecationDirective(deprecatedDirectives, field, comparator),
178+
};
179+
}
177180
});
178181
}
179182
}
@@ -189,6 +192,7 @@ function getAttributeDeprecationDirective(
189192
if (deprecatedDirectives.length) {
190193
return deprecatedDirectives;
191194
}
195+
192196
switch (comparator) {
193197
case "DISTANCE":
194198
case "LT":

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

+22-2
Original file line numberDiff line numberDiff line change
@@ -34,34 +34,39 @@ export class PropertyFilter extends Filter {
3434
protected comparisonValue: unknown;
3535
protected operator: FilterOperator;
3636
protected attachedTo: "node" | "relationship";
37+
protected caseInsensitive: boolean;
3738

3839
constructor({
3940
attribute,
4041
relationship,
4142
comparisonValue,
4243
operator,
4344
attachedTo = "node",
45+
caseInsensitive = false,
4446
}: {
4547
attribute: AttributeAdapter;
4648
relationship?: RelationshipAdapter;
4749
comparisonValue: unknown;
4850
operator: FilterOperator;
4951
attachedTo?: "node" | "relationship";
52+
caseInsensitive?: boolean;
5053
}) {
5154
super();
5255
this.attribute = attribute;
5356
this.relationship = relationship;
5457
this.comparisonValue = comparisonValue;
5558
this.operator = operator;
5659
this.attachedTo = attachedTo;
60+
this.caseInsensitive = caseInsensitive;
5761
}
5862

5963
public getChildren(): QueryASTNode[] {
6064
return [];
6165
}
6266

6367
public print(): string {
64-
return `${super.print()} [${this.attribute.name}] <${this.operator}>`;
68+
const caseInsensitiveStr = this.caseInsensitive ? "CASE INSENSITIVE " : "";
69+
return `${super.print()} [${this.attribute.name}] <${caseInsensitiveStr}${this.operator}>`;
6570
}
6671

6772
public getPredicate(queryASTContext: QueryASTContext): Cypher.Predicate {
@@ -148,6 +153,21 @@ export class PropertyFilter extends Filter {
148153
}): Cypher.ComparisonOp {
149154
const coalesceProperty = coalesceValueIfNeeded(this.attribute, property);
150155

151-
return createComparisonOperation({ operator, property: coalesceProperty, param });
156+
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+
});
169+
} else {
170+
return createComparisonOperation({ operator, property: coalesceProperty, param });
171+
}
152172
}
153173
}

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

+13-4
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,14 @@ export class FilterFactory {
237237
comparisonValue,
238238
operator,
239239
attachedTo,
240+
caseInsensitive,
240241
}: {
241242
attribute: AttributeAdapter;
242243
relationship?: RelationshipAdapter;
243244
comparisonValue: GraphQLWhereArg;
244245
operator: FilterOperator | undefined;
245246
attachedTo?: "node" | "relationship";
247+
caseInsensitive?: boolean;
246248
}): Filter | Filter[] {
247249
if (attribute.annotations.cypher) {
248250
return this.createCypherFilter({
@@ -294,6 +296,7 @@ export class FilterFactory {
294296
comparisonValue,
295297
operator,
296298
attachedTo,
299+
caseInsensitive,
297300
});
298301
}
299302

@@ -560,10 +563,11 @@ export class FilterFactory {
560563
entity: ConcreteEntityAdapter | RelationshipAdapter | InterfaceEntityAdapter,
561564
fieldName: string,
562565
value: Record<string, any>,
563-
relationship?: RelationshipAdapter
566+
relationship?: RelationshipAdapter,
567+
caseInsensitive?: boolean
564568
): Filter | Filter[] {
565569
const genericFilters = Object.entries(value).flatMap((filterInput) => {
566-
return this.parseGenericFilter(entity, fieldName, filterInput, relationship);
570+
return this.parseGenericFilter(entity, fieldName, filterInput, relationship, caseInsensitive);
567571
});
568572
return this.wrapMultipleFiltersInLogical(genericFilters);
569573
}
@@ -572,12 +576,13 @@ export class FilterFactory {
572576
entity: ConcreteEntityAdapter | RelationshipAdapter | InterfaceEntityAdapter,
573577
fieldName: string,
574578
filterInput: [string, any],
575-
relationship?: RelationshipAdapter
579+
relationship?: RelationshipAdapter,
580+
caseInsensitive?: boolean
576581
): Filter | Filter[] {
577582
const [rawOperator, value] = filterInput;
578583
if (isLogicalOperator(rawOperator)) {
579584
const nestedFilters = asArray(value).flatMap((nestedWhere) => {
580-
return this.parseGenericFilter(entity, fieldName, nestedWhere, relationship);
585+
return this.parseGenericFilter(entity, fieldName, nestedWhere, relationship, caseInsensitive);
581586
});
582587
return new LogicalFilter({
583588
operation: rawOperator,
@@ -591,6 +596,9 @@ export class FilterFactory {
591596
return this.parseGenericFilters(entity, fieldName, desugaredInput, relationship);
592597
}
593598

599+
if (rawOperator === "caseInsensitive") {
600+
return this.parseGenericFilters(entity, fieldName, value, relationship, true);
601+
}
594602
const operator = this.parseGenericOperator(rawOperator);
595603

596604
const attribute = entity.findAttribute(fieldName);
@@ -611,6 +619,7 @@ export class FilterFactory {
611619
operator,
612620
attachedTo,
613621
relationship,
622+
caseInsensitive,
614623
});
615624
return this.wrapMultipleFiltersInLogical(asArray(filters));
616625
}

packages/graphql/src/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ export interface Neo4jStringFiltersSettings {
384384
LT?: boolean;
385385
LTE?: boolean;
386386
MATCHES?: boolean;
387+
CASE_INSENSITIVE?: boolean;
387388
}
388389

389390
export interface Neo4jIDFiltersSettings {

0 commit comments

Comments
 (0)