Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ query TestQuery {
}
```

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.

Also you can apply `not` operator like this:

```graphql
Expand Down Expand Up @@ -406,12 +408,16 @@ 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>
| StartsWith of FieldFilter<string>
| EndsWith of FieldFilter<string>
| Contains of FieldFilter<string>
| LessThanOrEqual of FieldFilter<System.IComparable>
| In of FieldFilter<obj list>
| StartsWith of Filter : FieldFilter<string> * Comparer : System.StringComparer
| EndsWith of Filter : FieldFilter<string> * Comparer : System.StringComparer
| Contains of Filter : FieldFilter<System.IComparable> * Comparer : System.Collections.IComparer
| OfTypes of System.Type list
| FilterField of FieldFilter<ObjectListFilter>
```

Expand Down
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,4 +288,5 @@

* **Breaking Change** Migrated to .NET 10
* **Breaking Change** Made Relay `Edge` a read-only struct
* Added case-insensitive string comparison support to `ObjectListFilter`, including comparer-aware filter cases and GraphQL filter suffix handling
* Improved Relay XML documentation comments
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

<ItemGroup>
<Compile Include="ObjectListFilter.fs" />
<Compile Include="FilterSuffixConstants.fs" />
<Compile Include="TypeSystemExtensions.fs" />
<Compile Include="SchemaDefinitions.fs" />
<Compile Include="MiddlewareDefinitions.fs" />
Expand Down
67 changes: 67 additions & 0 deletions src/FSharp.Data.GraphQL.Server.Middleware/FilterSuffixConstants.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/// <summary>
/// String filter suffixes:
/// lowercase (e.g. _ends_with, _ew) → case-insensitive (OrdinalIgnoreCase)
/// Capitalized (e.g. _Ends_With, _EW) → case-sensitive (Ordinal)
/// <para>
/// The <see cref="CI"/> submodule contains lowercase suffixes that map to case-insensitive string comparisons.
/// The <see cref="CS"/> submodule contains capitalized/uppercase suffixes that map to case-sensitive string comparisons.
/// Numeric and comparison operator suffixes are defined at the module level and are case-insensitive by convention.
/// </para>
/// </summary>
[<RequireQualifiedAccess>]
module FSharp.Data.GraphQL.Server.Middleware.FilterSuffixConstants

// Numeric/comparison operators
[<Literal>]
let GreaterThanOrEqualSuffix = "_greater_than_or_equal"
[<Literal>]
let GTESuffix = "_gte"
[<Literal>]
let GreaterThanSuffix = "_greater_than"
[<Literal>]
let GTSuffix = "_gt"
[<Literal>]
let LessThanOrEqualSuffix = "_less_than_or_equal"
[<Literal>]
let LTESuffix = "_lte"
[<Literal>]
let LessThanSuffix = "_less_than"
[<Literal>]
let LTSuffix = "_lt"
[<Literal>]
let InSuffix = "_in"

/// Case-insensitive string operators (lowercase suffixes → OrdinalIgnoreCase)
module CI =
// String operators (case-insensitive)
[<Literal>]
let EndsWithSuffix = "_ends_with"
[<Literal>]
let EWSuffix = "_ew"
[<Literal>]
let StartsWithSuffix = "_starts_with"
[<Literal>]
let SWSuffix = "_sw"
[<Literal>]
let ContainsSuffix = "_contains"
[<Literal>]
let EqualsSuffix = "_equals"
[<Literal>]
let EQSuffix = "_eq"

/// Case-sensitive string operators
module CS =
[<Literal>]
let EndsWithSuffix = "_Ends_With"
[<Literal>]
let EWSuffix = "_EW"
[<Literal>]
let StartsWithSuffix = "_Starts_With"
[<Literal>]
let SWSuffix = "_SW"
[<Literal>]
let ContainsSuffix = "_Contains"
[<Literal>]
let EqualsSuffix = "_Equals"
[<Literal>]
let EQSuffix = "_EQ"
137 changes: 95 additions & 42 deletions src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
namespace FSharp.Data.GraphQL.Server.Middleware

open System
open System.Collections
open FSharp.Data.GraphQL

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

/// <summary>
/// A filter definition for an object list.
/// </summary>
/// <remarks>
/// String-based filters can carry a comparer. When the comparer is not provided by the default
/// string operators, `StartsWith`, `EndsWith`, and string `Contains` preserve the existing
/// case-sensitive `StringComparison.CurrentCulture` behavior.
/// `StringComparer.CurrentCultureIgnoreCase` enables case-insensitive matching.
/// When filters are provided through GraphQL input, lowercase string suffixes are interpreted
/// as case-insensitive and capitalized suffixes are interpreted as case-sensitive.
/// </remarks>
type ObjectListFilter =
| And of ObjectListFilter * ObjectListFilter
| Or of ObjectListFilter * ObjectListFilter
| Not of ObjectListFilter
| Equals of FieldFilter<System.IComparable>
| GreaterThan of FieldFilter<System.IComparable>
| GreaterThanOrEqual of FieldFilter<System.IComparable>
| LessThan of FieldFilter<System.IComparable>
| LessThanOrEqual of FieldFilter<System.IComparable>
| Equals of Filter : FieldFilter<IComparable> * Comparer : IComparer
| GreaterThan of FieldFilter<IComparable>
| GreaterThanOrEqual of FieldFilter<IComparable>
| LessThan of FieldFilter<IComparable>
| LessThanOrEqual of FieldFilter<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 : StringComparer
| EndsWith of Filter : FieldFilter<string> * Comparer : StringComparer
| Contains of Filter : FieldFilter<IComparable> * Comparer : IComparer
| OfTypes of Type list
| FilterField of FieldFilter<ObjectListFilter>

Expand Down Expand Up @@ -95,7 +106,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 +121,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.CurrentCultureIgnoreCase)

/// 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.CurrentCultureIgnoreCase)

/// 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.CurrentCultureIgnoreCase)

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

let private genericWhereMethod =
typeof<Queryable>.GetMethods ()
|> Seq.where (fun m -> m.Name = "Where")
Expand All @@ -144,9 +167,11 @@ module ObjectListFilter =
let private stringType = typeof<string>
let private genericIEnumerableType = typedefof<IEnumerable<_>>

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 StringStartsWithMethod = stringType.GetMethod ("StartsWith", [| stringType; stringComparisonType |])
let private StringEndsWithMethod = stringType.GetMethod ("EndsWith", [| stringType; stringComparisonType |])
let private StringContainsMethod = stringType.GetMethod ("Contains", [| stringType; stringComparisonType |])
let private StringEqualsMethod = stringType.GetMethod ("Equals", [| stringType; stringComparisonType |])
let private unwrapOptionMethod =
FSharp.Data.GraphQL.Helpers.moduleType.GetMethod (nameof Helpers.unwrap)

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

/// Maps an IComparer to a StringComparison value.
/// Returns ValueNone only when the comparer is null or is not a recognized StringComparer.
let private comparerToStringComparison (comparer : IComparer) =
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.Ordinal) then ValueSome StringComparison.Ordinal
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 +264,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``, StringEqualsMethod, Expression.Constant (value, typeof<string>), Expression.Constant comparison)) :> Expression
Comment thread
xperiandri marked this conversation as resolved.
| 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``, StringEqualsMethod, 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 +323,16 @@ 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 ->
let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.CurrentCulture
Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value, Expression.Constant comparison)
Comment thread
xperiandri marked this conversation as resolved.
| EndsWith (f, comparer) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value)
let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.CurrentCulture
Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value, Expression.Constant comparison)
Comment thread
xperiandri marked this conversation as resolved.

| 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 +368,8 @@ 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)
let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.CurrentCulture
Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant (unwrappedValue :?> string, typeof<string>), Expression.Constant comparison)
Comment thread
xperiandri marked this conversation as resolved.
| In f when not (f.Value.IsEmpty) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
let enumerableContains = getEnumerableContainsMethod objectType
Expand Down
Loading
Loading