Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions samples/client-provider/file-upload/server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
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
| 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)
| 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)

| 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)
| In f when not (f.Value.IsEmpty) ->
let ``member`` = Expression.PropertyOrField (param, f.FieldName)
let enumerableContains = getEnumerableContainsMethod objectType
Expand Down
Loading
Loading