Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
00ebd95
Support case insensitive comparison with ObjectListFilter
Copilot May 23, 2026
efb8ed4
Redesign ObjectListFilter: add IComparer to Equals/StartsWith/EndsWit…
Copilot Jul 8, 2026
f10e0a1
Add type annotations to CI operators; clarify suffix comment
Copilot Jul 8, 2026
2ba0051
Remove method pairs; use only StringComparison overloads for string o…
Copilot Jul 8, 2026
4db2715
Change StartsWith/EndsWith to use StringComparer; open System.Collect…
Copilot Jul 8, 2026
c3eccde
Rename CI test names to use descriptive "case insensitive" phrasing
Copilot Jul 8, 2026
9fa9659
Add paired case-sensitive/case-insensitive tests to ObjectListFilter …
Copilot Jul 8, 2026
9585b19
Extract filter suffix constants to FilterSuffixConstants.fs
Copilot Jul 8, 2026
d9a81b7
docs: clarify ObjectListFilter suffix behavior
Copilot Jul 8, 2026
37d7d02
docs: clarify ObjectListFilter comparer notes
Copilot Jul 8, 2026
9ec7da0
docs: use F# XML doc conventions
Copilot Jul 8, 2026
880f976
docs: simplify ObjectListFilter examples
Copilot Jul 8, 2026
adf6265
docs: clarify comparer types in README
Copilot Jul 8, 2026
4b2845e
docs: add ObjectListFilter migration note
Copilot Jul 8, 2026
67110b1
docs: polish ObjectListFilter notes
Copilot Jul 8, 2026
8cd78ae
docs: clarify ObjectListFilter wording
Copilot Jul 8, 2026
d6a87ae
docs: simplify ObjectListFilter overview
Copilot Jul 8, 2026
daa682f
docs: rewrite ObjectListFilter README section
Copilot Jul 8, 2026
e3b95b9
docs: finalize ObjectListFilter clarifications
Copilot Jul 8, 2026
18240a5
docs: align ObjectListFilter descriptions
Copilot Jul 8, 2026
03c2285
Use CurrentCulture as default string filter fallback
Copilot Jul 8, 2026
8fc6eb5
Trim excess ObjectListFilter README changes
Copilot Jul 8, 2026
d60aa8e
Tighten ObjectListFilter README wording
Copilot Jul 8, 2026
ac616e3
Restore README ObjectListFilter declaration block
Copilot Jul 8, 2026
1f7b012
Add release note for ObjectListFilter change
Copilot Jul 8, 2026
2dbd474
Update filters to use `CurrentCulture` string comparison
xperiandri Jul 9, 2026
7513c34
fixup! Change StartsWith/EndsWith to use StringComparer; open System.…
xperiandri Jul 9, 2026
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
124 changes: 89 additions & 35 deletions src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ type ObjectListFilter =
| And of ObjectListFilter * ObjectListFilter
| Or of ObjectListFilter * ObjectListFilter
| Not of ObjectListFilter
| Equals of FieldFilter<System.IComparable>
| Equals of Filter : FieldFilter<System.IComparable> * Comparer : System.Collections.IComparer
| GreaterThan of FieldFilter<System.IComparable>
| GreaterThanOrEqual of FieldFilter<System.IComparable>
| LessThan of FieldFilter<System.IComparable>
| LessThanOrEqual of FieldFilter<System.IComparable>
| In of FieldFilter<obj list>
| StartsWith of FieldFilter<string>
| EndsWith of FieldFilter<string>
| Contains of FieldFilter<System.IComparable>
| StartsWith of Filter : FieldFilter<string> * Comparer : System.Collections.IComparer
| EndsWith of Filter : FieldFilter<string> * Comparer : System.Collections.IComparer
| Contains of Filter : FieldFilter<System.IComparable> * Comparer : System.Collections.IComparer
| OfTypes of Type list
| FilterField of FieldFilter<ObjectListFilter>

Expand All @@ -28,6 +28,7 @@ open System.Linq.Expressions
open System.Runtime.InteropServices
open System.Reflection
open System.Collections.Generic
open System.Collections

Comment thread
xperiandri marked this conversation as resolved.
type private CompareDiscriminatorExpression<'T, 'D> = Expression<Func<'T, 'D, bool>>

Expand Down Expand Up @@ -95,7 +96,7 @@ module ObjectListFilter =
let ( ||| ) x y = Or (x, y)

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

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

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

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

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

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

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

/// Creates a new ObjectListFilter representing a NOT opreation for the existing one.
/// Creates a new ObjectListFilter representing a NOT operation for the existing one.
let ( !!! ) filter = Not filter

/// Creates a new ObjectListFilter representing a case-insensitive EQUALS operation on a string value.
let ( ===~ ) fname (value : string) = Equals ({ FieldName = fname; Value = value }, StringComparer.OrdinalIgnoreCase)

/// Creates a new ObjectListFilter representing a case-insensitive STARTS WITH operation on a string value.
let ( =@@~ ) fname (value : string) = StartsWith ({ FieldName = fname; Value = value }, StringComparer.OrdinalIgnoreCase)

/// Creates a new ObjectListFilter representing a case-insensitive ENDS WITH operation on a string value.
let ( @@=~ ) fname (value : string) = EndsWith ({ FieldName = fname; Value = value }, StringComparer.OrdinalIgnoreCase)

/// Creates a new ObjectListFilter representing a case-insensitive CONTAINS operation on a string value.
let ( @=@~ ) fname (value : string) = Contains ({ FieldName = fname; Value = value }, StringComparer.OrdinalIgnoreCase)

let private genericWhereMethod =
typeof<Queryable>.GetMethods ()
|> Seq.where (fun m -> m.Name = "Where")
Expand All @@ -147,6 +160,11 @@ module ObjectListFilter =
let private StringStartsWithMethod = stringType.GetMethod ("StartsWith", [| stringType |])
let private StringEndsWithMethod = stringType.GetMethod ("EndsWith", [| stringType |])
let private StringContainsMethod = stringType.GetMethod ("Contains", [| stringType |])
let private stringComparisonType = typeof<StringComparison>
let private StringStartsWithWithComparisonMethod = stringType.GetMethod ("StartsWith", [| stringType; stringComparisonType |])
let private StringEndsWithWithComparisonMethod = stringType.GetMethod ("EndsWith", [| stringType; stringComparisonType |])
let private StringContainsWithComparisonMethod = stringType.GetMethod ("Contains", [| stringType; stringComparisonType |])
let private StringEqualsWithComparisonMethod = stringType.GetMethod ("Equals", [| stringType; stringComparisonType |])
Comment thread
xperiandri marked this conversation as resolved.
Outdated
let private unwrapOptionMethod =
FSharp.Data.GraphQL.Helpers.moduleType.GetMethod (nameof Helpers.unwrap)

Expand Down Expand Up @@ -205,6 +223,20 @@ module ObjectListFilter =
|> Seq.where (fun m -> m.Name = "Equals")
|> Seq.head

/// Maps an IComparer to a StringComparison value.
/// Returns ValueNone for null or Ordinal comparers (use default Expression.Equal path).
let private comparerToStringComparison (comparer : IComparer) =
Comment thread
xperiandri marked this conversation as resolved.
match comparer with
| null -> ValueNone
| :? StringComparer as sc ->
if obj.ReferenceEquals (sc, StringComparer.OrdinalIgnoreCase) then ValueSome StringComparison.OrdinalIgnoreCase
elif obj.ReferenceEquals (sc, StringComparer.InvariantCultureIgnoreCase) then ValueSome StringComparison.InvariantCultureIgnoreCase
elif obj.ReferenceEquals (sc, StringComparer.CurrentCultureIgnoreCase) then ValueSome StringComparison.CurrentCultureIgnoreCase
elif obj.ReferenceEquals (sc, StringComparer.InvariantCulture) then ValueSome StringComparison.InvariantCulture
elif obj.ReferenceEquals (sc, StringComparer.CurrentCulture) then ValueSome StringComparison.CurrentCulture
Comment thread
xperiandri marked this conversation as resolved.
else ValueNone
| _ -> ValueNone

let rec buildFilterExpr isEnumerableQuery (param : SourceExpression) buildTypeDiscriminatorCheck filter : Expression =

let build = buildFilterExpr isEnumerableQuery param buildTypeDiscriminatorCheck
Expand All @@ -224,31 +256,41 @@ module ObjectListFilter =
| _ -> Expression.Convert (``member``, stringType)

match filter with
| Not (Equals f) ->
| Not (Equals (f, comparer)) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
let hasEqualityOperator = hasEqualityOperator ``member``.Type
match f.Value with
| NoCast when hasEqualityOperator -> Expression.NotEqual (``member``, Expression.Constant f.Value)
| NoCast
| NonEnumerableCast _ ->
Expression.NotEqual (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType))
| Enumerable ->
let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)
Expression.Not (Expression.Call (``const``, equalsMethod, ``member``))
match comparerToStringComparison comparer with
| ValueSome comparison ->
let value = Helpers.unwrap (box f.Value) :?> string
Expression.Not (Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsWithComparisonMethod, Expression.Constant (value, typeof<string>), Expression.Constant comparison)) :> Expression
| ValueNone ->
let hasEqualityOperator = hasEqualityOperator ``member``.Type
match f.Value with
| NoCast when hasEqualityOperator -> Expression.NotEqual (``member``, Expression.Constant f.Value)
| NoCast
| NonEnumerableCast _ ->
Expression.NotEqual (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType))
| Enumerable ->
let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)
Expression.Not (Expression.Call (``const``, equalsMethod, ``member``))
| Not f -> f |> build |> Expression.Not :> Expression
| And (f1, f2) -> Expression.AndAlso (build f1, build f2)
| Or (f1, f2) -> Expression.OrElse (build f1, build f2)
| Equals f ->
| Equals (f, comparer) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
let hasEqualityOperator = hasEqualityOperator ``member``.Type
match f.Value with
| NoCast when hasEqualityOperator -> Expression.Equal (``member``, Expression.Constant f.Value)
| NoCast
| NonEnumerableCast _ ->
Expression.Equal (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType))
| Enumerable ->
let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)
Expression.Call (``const``, equalsMethod, ``member``)
match comparerToStringComparison comparer with
| ValueSome comparison ->
let value = Helpers.unwrap (box f.Value) :?> string
Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsWithComparisonMethod, Expression.Constant (value, typeof<string>), Expression.Constant comparison) :> Expression
| ValueNone ->
let hasEqualityOperator = hasEqualityOperator ``member``.Type
match f.Value with
| NoCast when hasEqualityOperator -> Expression.Equal (``member``, Expression.Constant f.Value)
| NoCast
| NonEnumerableCast _ ->
Expression.Equal (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType))
| Enumerable ->
let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)
Expression.Call (``const``, equalsMethod, ``member``)
| GreaterThan f ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
match f.Value with
Expand All @@ -273,14 +315,22 @@ module ObjectListFilter =
| NoCast -> Expression.LessThanOrEqual (``member``, Expression.Constant f.Value)
| Enumerable -> Expression.LessThanOrEqual (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value))
| NonEnumerableCast ``type`` -> Expression.LessThanOrEqual ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value)
| StartsWith f ->
| StartsWith (f, comparer) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value)
| EndsWith f ->
match comparerToStringComparison comparer with
| ValueSome comparison ->
Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithWithComparisonMethod, Expression.Constant f.Value, Expression.Constant comparison)
| ValueNone ->
Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value)
| EndsWith (f, comparer) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value)
match comparerToStringComparison comparer with
| ValueSome comparison ->
Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithWithComparisonMethod, Expression.Constant f.Value, Expression.Constant comparison)
| ValueNone ->
Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value)

| Contains f ->
| Contains (f, comparer) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
let isEnumerable (memberType : Type) =
not (Type.(=) (memberType, stringType))
Expand Down Expand Up @@ -316,7 +366,11 @@ module ObjectListFilter =
| :? FieldInfo as field when field.FieldType |> isEnumerable -> callContains field.FieldType
| _ ->
let unwrappedValue = Helpers.unwrap f.Value
Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant unwrappedValue)
match comparerToStringComparison comparer with
| ValueSome comparison ->
Expression.Call (normalizeStringMemberExpr ``member``, StringContainsWithComparisonMethod, Expression.Constant (unwrappedValue :?> string, typeof<string>), Expression.Constant comparison)
| ValueNone ->
Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant unwrappedValue)
| In f when not (f.Value.IsEmpty) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
let enumerableContains = getEnumerableContainsMethod objectType
Expand Down
Loading
Loading