Skip to content

Commit c1e8510

Browse files
Copilotxperiandri
andcommitted
Support case-insensitive comparison with ObjectListFilter (#582)
Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Andrii Chebukin <XperiAndri@Outlook.com>
1 parent 15df50a commit c1e8510

9 files changed

Lines changed: 378 additions & 111 deletions

File tree

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,8 @@ query TestQuery {
361361
}
362362
```
363363

364+
For string filters, lowercase suffixes are case-insensitive (`name_starts_with`, `name_sw`, `name_contains`, `name_equals`, `name_eq`), while capitalized suffixes are case-sensitive (`name_Starts_With`, `name_SW`, `name_Contains`, `name_Equals`, `name_EQ`). `contains`/`Contains` do not have shorthand aliases.
365+
364366
Also you can apply `not` operator like this:
365367

366368
```graphql
@@ -406,12 +408,16 @@ type ObjectListFilter =
406408
| And of ObjectListFilter * ObjectListFilter
407409
| Or of ObjectListFilter * ObjectListFilter
408410
| Not of ObjectListFilter
409-
| Equals of FieldFilter<System.IComparable>
411+
| Equals of Filter : FieldFilter<System.IComparable> * Comparer : System.Collections.IComparer
410412
| GreaterThan of FieldFilter<System.IComparable>
413+
| GreaterThanOrEqual of FieldFilter<System.IComparable>
411414
| LessThan of FieldFilter<System.IComparable>
412-
| StartsWith of FieldFilter<string>
413-
| EndsWith of FieldFilter<string>
414-
| Contains of FieldFilter<string>
415+
| LessThanOrEqual of FieldFilter<System.IComparable>
416+
| In of FieldFilter<obj list>
417+
| StartsWith of Filter : FieldFilter<string> * Comparer : System.StringComparer
418+
| EndsWith of Filter : FieldFilter<string> * Comparer : System.StringComparer
419+
| Contains of Filter : FieldFilter<System.IComparable> * Comparer : System.Collections.IComparer
420+
| OfTypes of System.Type list
415421
| FilterField of FieldFilter<ObjectListFilter>
416422
```
417423

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,4 +288,5 @@
288288

289289
* **Breaking Change** Migrated to .NET 10
290290
* **Breaking Change** Made Relay `Edge` a read-only struct
291+
* Added case-insensitive string comparison support to `ObjectListFilter`, including comparer-aware filter cases and GraphQL filter suffix handling
291292
* Improved Relay XML documentation comments

src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
<ItemGroup>
2323
<Compile Include="ObjectListFilter.fs" />
24+
<Compile Include="FilterSuffixConstants.fs" />
2425
<Compile Include="TypeSystemExtensions.fs" />
2526
<Compile Include="SchemaDefinitions.fs" />
2627
<Compile Include="MiddlewareDefinitions.fs" />
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/// <summary>
2+
/// String filter suffixes:
3+
/// lowercase (e.g. _ends_with, _ew) → case-insensitive (OrdinalIgnoreCase)
4+
/// Capitalized (e.g. _Ends_With, _EW) → case-sensitive (Ordinal)
5+
/// <para>
6+
/// The <see cref="CI"/> submodule contains lowercase suffixes that map to case-insensitive string comparisons.
7+
/// The <see cref="CS"/> submodule contains capitalized/uppercase suffixes that map to case-sensitive string comparisons.
8+
/// Numeric and comparison operator suffixes are defined at the module level and are case-insensitive by convention.
9+
/// </para>
10+
/// </summary>
11+
[<RequireQualifiedAccess>]
12+
module FSharp.Data.GraphQL.Server.Middleware.FilterSuffixConstants
13+
14+
// Numeric/comparison operators
15+
[<Literal>]
16+
let GreaterThanOrEqualSuffix = "_greater_than_or_equal"
17+
[<Literal>]
18+
let GTESuffix = "_gte"
19+
[<Literal>]
20+
let GreaterThanSuffix = "_greater_than"
21+
[<Literal>]
22+
let GTSuffix = "_gt"
23+
[<Literal>]
24+
let LessThanOrEqualSuffix = "_less_than_or_equal"
25+
[<Literal>]
26+
let LTESuffix = "_lte"
27+
[<Literal>]
28+
let LessThanSuffix = "_less_than"
29+
[<Literal>]
30+
let LTSuffix = "_lt"
31+
[<Literal>]
32+
let InSuffix = "_in"
33+
34+
/// Case-insensitive string operators and all numeric/comparison operators
35+
module CI =
36+
// String operators (case-insensitive)
37+
[<Literal>]
38+
let EndsWithSuffix = "_ends_with"
39+
[<Literal>]
40+
let EWSuffix = "_ew"
41+
[<Literal>]
42+
let StartsWithSuffix = "_starts_with"
43+
[<Literal>]
44+
let SWSuffix = "_sw"
45+
[<Literal>]
46+
let ContainsSuffix = "_contains"
47+
[<Literal>]
48+
let EqualsSuffix = "_equals"
49+
[<Literal>]
50+
let EQSuffix = "_eq"
51+
52+
/// Case-sensitive string operators
53+
module CS =
54+
[<Literal>]
55+
let EndsWithSuffix = "_Ends_With"
56+
[<Literal>]
57+
let EWSuffix = "_EW"
58+
[<Literal>]
59+
let StartsWithSuffix = "_Starts_With"
60+
[<Literal>]
61+
let SWSuffix = "_SW"
62+
[<Literal>]
63+
let ContainsSuffix = "_Contains"
64+
[<Literal>]
65+
let EqualsSuffix = "_Equals"
66+
[<Literal>]
67+
let EQSuffix = "_EQ"

src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs

Lines changed: 95 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,36 @@
11
namespace FSharp.Data.GraphQL.Server.Middleware
22

33
open System
4+
open System.Collections
45
open FSharp.Data.GraphQL
56

67
/// A filter definition for a field value.
78
type FieldFilter<'Val> = { FieldName : string; Value : 'Val }
89

10+
/// <summary>
911
/// A filter definition for an object list.
12+
/// </summary>
13+
/// <remarks>
14+
/// String-based filters can carry a comparer. When the comparer is not provided by the default
15+
/// string operators, `StartsWith`, `EndsWith`, and string `Contains` preserve the existing
16+
/// case-sensitive `StringComparison.CurrentCulture` behavior.
17+
/// `StringComparer.CurrentCultureIgnoreCase` enables case-insensitive matching.
18+
/// When filters are provided through GraphQL input, lowercase string suffixes are interpreted
19+
/// as case-insensitive and capitalized suffixes are interpreted as case-sensitive.
20+
/// </remarks>
1021
type ObjectListFilter =
1122
| And of ObjectListFilter * ObjectListFilter
1223
| Or of ObjectListFilter * ObjectListFilter
1324
| Not of ObjectListFilter
14-
| Equals of FieldFilter<System.IComparable>
15-
| GreaterThan of FieldFilter<System.IComparable>
16-
| GreaterThanOrEqual of FieldFilter<System.IComparable>
17-
| LessThan of FieldFilter<System.IComparable>
18-
| LessThanOrEqual of FieldFilter<System.IComparable>
25+
| Equals of Filter : FieldFilter<IComparable> * Comparer : IComparer
26+
| GreaterThan of FieldFilter<IComparable>
27+
| GreaterThanOrEqual of FieldFilter<IComparable>
28+
| LessThan of FieldFilter<IComparable>
29+
| LessThanOrEqual of FieldFilter<IComparable>
1930
| In of FieldFilter<obj list>
20-
| StartsWith of FieldFilter<string>
21-
| EndsWith of FieldFilter<string>
22-
| Contains of FieldFilter<System.IComparable>
31+
| StartsWith of Filter : FieldFilter<string> * Comparer : StringComparer
32+
| EndsWith of Filter : FieldFilter<string> * Comparer : StringComparer
33+
| Contains of Filter : FieldFilter<IComparable> * Comparer : IComparer
2334
| OfTypes of Type list
2435
| FilterField of FieldFilter<ObjectListFilter>
2536

@@ -95,7 +106,7 @@ module ObjectListFilter =
95106
let ( ||| ) x y = Or (x, y)
96107

97108
/// Creates a new ObjectListFilter representing an EQUALS operation between two comparable values.
98-
let ( === ) fname value = Equals { FieldName = fname; Value = value }
109+
let ( === ) fname value = Equals ({ FieldName = fname; Value = value }, null)
99110

100111
/// Creates a new ObjectListFilter representing a GREATER THAN operation of a comparable value.
101112
let ( >>> ) fname value = GreaterThan { FieldName = fname; Value = value }
@@ -110,23 +121,35 @@ module ObjectListFilter =
110121
let ( <== ) fname value = LessThanOrEqual { FieldName = fname; Value = value }
111122

112123
/// Creates a new ObjectListFilter representing a STARTS WITH operation of a string value.
113-
let ( =@@ ) fname value = StartsWith { FieldName = fname; Value = value }
124+
let ( =@@ ) fname value = StartsWith ({ FieldName = fname; Value = value }, null)
114125

115126
/// Creates a new ObjectListFilter representing an ENDS WITH operation of a string value.
116-
let ( @@= ) fname value = EndsWith { FieldName = fname; Value = value }
127+
let ( @@= ) fname value = EndsWith ({ FieldName = fname; Value = value }, null)
117128

118129
/// Creates a new ObjectListFilter representing a CONTAINS operation.
119-
let ( @=@ ) fname value = Contains { FieldName = fname; Value = value }
130+
let ( @=@ ) fname value = Contains ({ FieldName = fname; Value = value }, null)
120131

121132
/// Creates a new ObjectListFilter representing a IN operation.
122133
let ( =~= ) fname value = In { FieldName = fname; Value = value }
123134

124135
/// Creates a new ObjectListFilter representing a field sub comparison.
125136
let ( --> ) fname filter = FilterField { FieldName = fname; Value = filter }
126137

127-
/// Creates a new ObjectListFilter representing a NOT opreation for the existing one.
138+
/// Creates a new ObjectListFilter representing a NOT operation for the existing one.
128139
let ( !!! ) filter = Not filter
129140

141+
/// Creates a new ObjectListFilter representing a case-insensitive EQUALS operation on a string value.
142+
let ( ===~ ) fname (value : string) = Equals ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase)
143+
144+
/// Creates a new ObjectListFilter representing a case-insensitive STARTS WITH operation on a string value.
145+
let ( =@@~ ) fname (value : string) = StartsWith ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase)
146+
147+
/// Creates a new ObjectListFilter representing a case-insensitive ENDS WITH operation on a string value.
148+
let ( @@=~ ) fname (value : string) = EndsWith ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase)
149+
150+
/// Creates a new ObjectListFilter representing a case-insensitive CONTAINS operation on a string value.
151+
let ( @=@~ ) fname (value : string) = Contains ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase)
152+
130153
let private genericWhereMethod =
131154
typeof<Queryable>.GetMethods ()
132155
|> Seq.where (fun m -> m.Name = "Where")
@@ -144,9 +167,11 @@ module ObjectListFilter =
144167
let private stringType = typeof<string>
145168
let private genericIEnumerableType = typedefof<IEnumerable<_>>
146169

147-
let private StringStartsWithMethod = stringType.GetMethod ("StartsWith", [| stringType |])
148-
let private StringEndsWithMethod = stringType.GetMethod ("EndsWith", [| stringType |])
149-
let private StringContainsMethod = stringType.GetMethod ("Contains", [| stringType |])
170+
let private stringComparisonType = typeof<StringComparison>
171+
let private StringStartsWithMethod = stringType.GetMethod ("StartsWith", [| stringType; stringComparisonType |])
172+
let private StringEndsWithMethod = stringType.GetMethod ("EndsWith", [| stringType; stringComparisonType |])
173+
let private StringContainsMethod = stringType.GetMethod ("Contains", [| stringType; stringComparisonType |])
174+
let private StringEqualsMethod = stringType.GetMethod ("Equals", [| stringType; stringComparisonType |])
150175
let private unwrapOptionMethod =
151176
FSharp.Data.GraphQL.Helpers.moduleType.GetMethod (nameof Helpers.unwrap)
152177

@@ -205,6 +230,21 @@ module ObjectListFilter =
205230
|> Seq.where (fun m -> m.Name = "Equals")
206231
|> Seq.head
207232

233+
/// Maps an IComparer to a StringComparison value.
234+
/// Returns ValueNone only when the comparer is null or is not a recognized StringComparer.
235+
let private comparerToStringComparison (comparer : IComparer) =
236+
match comparer with
237+
| null -> ValueNone
238+
| :? StringComparer as sc ->
239+
if obj.ReferenceEquals (sc, StringComparer.OrdinalIgnoreCase) then ValueSome StringComparison.OrdinalIgnoreCase
240+
elif obj.ReferenceEquals (sc, StringComparer.InvariantCultureIgnoreCase) then ValueSome StringComparison.InvariantCultureIgnoreCase
241+
elif obj.ReferenceEquals (sc, StringComparer.CurrentCultureIgnoreCase) then ValueSome StringComparison.CurrentCultureIgnoreCase
242+
elif obj.ReferenceEquals (sc, StringComparer.Ordinal) then ValueSome StringComparison.Ordinal
243+
elif obj.ReferenceEquals (sc, StringComparer.InvariantCulture) then ValueSome StringComparison.InvariantCulture
244+
elif obj.ReferenceEquals (sc, StringComparer.CurrentCulture) then ValueSome StringComparison.CurrentCulture
245+
else ValueNone
246+
| _ -> ValueNone
247+
208248
let rec buildFilterExpr isEnumerableQuery (param : SourceExpression) buildTypeDiscriminatorCheck filter : Expression =
209249

210250
let build = buildFilterExpr isEnumerableQuery param buildTypeDiscriminatorCheck
@@ -224,31 +264,41 @@ module ObjectListFilter =
224264
| _ -> Expression.Convert (``member``, stringType)
225265

226266
match filter with
227-
| Not (Equals f) ->
267+
| Not (Equals (f, comparer)) ->
228268
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
229-
let hasEqualityOperator = hasEqualityOperator ``member``.Type
230-
match f.Value with
231-
| NoCast when hasEqualityOperator -> Expression.NotEqual (``member``, Expression.Constant f.Value)
232-
| NoCast
233-
| NonEnumerableCast _ ->
234-
Expression.NotEqual (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType))
235-
| Enumerable ->
236-
let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)
237-
Expression.Not (Expression.Call (``const``, equalsMethod, ``member``))
269+
match comparerToStringComparison comparer with
270+
| ValueSome comparison ->
271+
let value = Helpers.unwrap (box f.Value) :?> string
272+
Expression.Not (Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsMethod, Expression.Constant (value, typeof<string>), Expression.Constant comparison)) :> Expression
273+
| ValueNone ->
274+
let hasEqualityOperator = hasEqualityOperator ``member``.Type
275+
match f.Value with
276+
| NoCast when hasEqualityOperator -> Expression.NotEqual (``member``, Expression.Constant f.Value)
277+
| NoCast
278+
| NonEnumerableCast _ ->
279+
Expression.NotEqual (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType))
280+
| Enumerable ->
281+
let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)
282+
Expression.Not (Expression.Call (``const``, equalsMethod, ``member``))
238283
| Not f -> f |> build |> Expression.Not :> Expression
239284
| And (f1, f2) -> Expression.AndAlso (build f1, build f2)
240285
| Or (f1, f2) -> Expression.OrElse (build f1, build f2)
241-
| Equals f ->
286+
| Equals (f, comparer) ->
242287
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
243-
let hasEqualityOperator = hasEqualityOperator ``member``.Type
244-
match f.Value with
245-
| NoCast when hasEqualityOperator -> Expression.Equal (``member``, Expression.Constant f.Value)
246-
| NoCast
247-
| NonEnumerableCast _ ->
248-
Expression.Equal (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType))
249-
| Enumerable ->
250-
let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)
251-
Expression.Call (``const``, equalsMethod, ``member``)
288+
match comparerToStringComparison comparer with
289+
| ValueSome comparison ->
290+
let value = Helpers.unwrap (box f.Value) :?> string
291+
Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsMethod, Expression.Constant (value, typeof<string>), Expression.Constant comparison) :> Expression
292+
| ValueNone ->
293+
let hasEqualityOperator = hasEqualityOperator ``member``.Type
294+
match f.Value with
295+
| NoCast when hasEqualityOperator -> Expression.Equal (``member``, Expression.Constant f.Value)
296+
| NoCast
297+
| NonEnumerableCast _ ->
298+
Expression.Equal (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType))
299+
| Enumerable ->
300+
let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)
301+
Expression.Call (``const``, equalsMethod, ``member``)
252302
| GreaterThan f ->
253303
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
254304
match f.Value with
@@ -273,14 +323,16 @@ module ObjectListFilter =
273323
| NoCast -> Expression.LessThanOrEqual (``member``, Expression.Constant f.Value)
274324
| Enumerable -> Expression.LessThanOrEqual (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value))
275325
| NonEnumerableCast ``type`` -> Expression.LessThanOrEqual ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value)
276-
| StartsWith f ->
326+
| StartsWith (f, comparer) ->
277327
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
278-
Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value)
279-
| EndsWith f ->
328+
let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.CurrentCulture
329+
Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value, Expression.Constant comparison)
330+
| EndsWith (f, comparer) ->
280331
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
281-
Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value)
332+
let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.CurrentCulture
333+
Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value, Expression.Constant comparison)
282334

283-
| Contains f ->
335+
| Contains (f, comparer) ->
284336
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
285337
let isEnumerable (memberType : Type) =
286338
not (Type.(=) (memberType, stringType))
@@ -316,7 +368,8 @@ module ObjectListFilter =
316368
| :? FieldInfo as field when field.FieldType |> isEnumerable -> callContains field.FieldType
317369
| _ ->
318370
let unwrappedValue = Helpers.unwrap f.Value
319-
Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant unwrappedValue)
371+
let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.CurrentCulture
372+
Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant (unwrappedValue :?> string, typeof<string>), Expression.Constant comparison)
320373
| In f when not (f.Value.IsEmpty) ->
321374
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
322375
let enumerableContains = getEnumerableContainsMethod objectType

0 commit comments

Comments
 (0)