From 00ebd9531184a13639cfbb705d6c20ab5acb0aad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 13:21:21 +0000 Subject: [PATCH 01/25] Support case insensitive comparison with ObjectListFilter Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ObjectListFilter.fs | 36 +++++++++- .../SchemaDefinitions.fs | 16 +++++ .../ObjectListFilterLinqTests.fs | 65 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index 12f54d71..cb1f004a 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -22,6 +22,10 @@ type ObjectListFilter = | Contains of FieldFilter | OfTypes of Type list | FilterField of FieldFilter + | EqualsCI of FieldFilter + | StartsWithCI of FieldFilter + | EndsWithCI of FieldFilter + | ContainsCI of FieldFilter open System.Linq open System.Linq.Expressions @@ -124,9 +128,21 @@ module ObjectListFilter = /// 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 = EqualsCI { FieldName = fname; Value = value } + + /// Creates a new ObjectListFilter representing a case-insensitive STARTS WITH operation on a string value. + let ( =@@~ ) fname value = StartsWithCI { FieldName = fname; Value = value } + + /// Creates a new ObjectListFilter representing a case-insensitive ENDS WITH operation on a string value. + let ( @@=~ ) fname value = EndsWithCI { FieldName = fname; Value = value } + + /// Creates a new ObjectListFilter representing a case-insensitive CONTAINS operation on a string value. + let ( @=@~ ) fname value = ContainsCI { FieldName = fname; Value = value } + let private genericWhereMethod = typeof.GetMethods () |> Seq.where (fun m -> m.Name = "Where") @@ -147,6 +163,12 @@ 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 + let private StringEqualsCIMethod = stringType.GetMethod ("Equals", [| stringType; stringComparisonType |]) + let private StringStartsWithCIMethod = stringType.GetMethod ("StartsWith", [| stringType; stringComparisonType |]) + let private StringEndsWithCIMethod = stringType.GetMethod ("EndsWith", [| stringType; stringComparisonType |]) + let private StringContainsCIMethod = stringType.GetMethod ("Contains", [| stringType; stringComparisonType |]) + let private OrdinalIgnoreCase = Expression.Constant (StringComparison.OrdinalIgnoreCase) let private unwrapOptionMethod = FSharp.Data.GraphQL.Helpers.moduleType.GetMethod (nameof Helpers.unwrap) @@ -329,6 +351,18 @@ module ObjectListFilter = | FilterField f -> let paramExpr = Expression.PropertyOrField (param, f.FieldName) buildFilterExpr isEnumerableQuery (SourceExpression paramExpr) buildTypeDiscriminatorCheck f.Value + | EqualsCI f -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsCIMethod, Expression.Constant f.Value, OrdinalIgnoreCase) + | StartsWithCI f -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithCIMethod, Expression.Constant f.Value, OrdinalIgnoreCase) + | EndsWithCI f -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithCIMethod, Expression.Constant f.Value, OrdinalIgnoreCase) + | ContainsCI f -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + Expression.Call (normalizeStringMemberExpr ``member``, StringContainsCIMethod, Expression.Constant f.Value, OrdinalIgnoreCase) type private CompareDiscriminatorExpressionVisitor<'T, 'D> (compareDiscriminator : CompareDiscriminatorExpression<'T, 'D>, param : SourceExpression, value : obj) = diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index e8239cad..d1d9401c 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -18,6 +18,10 @@ type private ComparisonOperator = | LessThan of string | LessThanOrEqual of string | In of string + | EqualsCI of string + | StartsWithCI of string + | EndsWithCI of string + | ContainsCI of string let rec private coerceObjectListFilterInput (variables : Variables) inputValue : Result = @@ -25,6 +29,14 @@ let rec private coerceObjectListFilterInput (variables : Variables) inputValue : let s = s.ToLowerInvariant () let prefix (suffix : string) (s : string) = s.Substring (0, s.Length - suffix.Length) match s with + | s when s.EndsWith ("_ends_with_ci") && s.Length > "_ends_with_ci".Length -> EndsWithCI (prefix "_ends_with_ci" s) + | s when s.EndsWith ("_ewci") && s.Length > "_ewci".Length -> EndsWithCI (prefix "_ewci" s) + | s when s.EndsWith ("_starts_with_ci") && s.Length > "_starts_with_ci".Length -> StartsWithCI (prefix "_starts_with_ci" s) + | s when s.EndsWith ("_swci") && s.Length > "_swci".Length -> StartsWithCI (prefix "_swci" s) + | s when s.EndsWith ("_contains_ci") && s.Length > "_contains_ci".Length -> ContainsCI (prefix "_contains_ci" s) + | s when s.EndsWith ("_cci") && s.Length > "_cci".Length -> ContainsCI (prefix "_cci" s) + | s when s.EndsWith ("_equals_ci") && s.Length > "_equals_ci".Length -> EqualsCI (prefix "_equals_ci" s) + | s when s.EndsWith ("_eqi") && s.Length > "_eqi".Length -> EqualsCI (prefix "_eqi" s) | s when s.EndsWith ("_ends_with") && s.Length > "_ends_with".Length -> EndsWith (prefix "_ends_with" s) | s when s.EndsWith ("_ew") && s.Length > "_ew".Length -> EndsWith (prefix "_ew" s) | s when s.EndsWith ("_starts_with") && s.Length > "_starts_with".Length -> StartsWith (prefix "_starts_with" s) @@ -99,6 +111,10 @@ let rec private coerceObjectListFilterInput (variables : Variables) inputValue : | EndsWith fname, StringValue value -> Ok (ValueSome (ObjectListFilter.EndsWith { FieldName = fname; Value = value })) | StartsWith fname, StringValue value -> Ok (ValueSome (ObjectListFilter.StartsWith { FieldName = fname; Value = value })) | Contains fname, ComparableValue value -> Ok (ValueSome (ObjectListFilter.Contains { FieldName = fname; Value = value })) + | EndsWithCI fname, StringValue value -> Ok (ValueSome (ObjectListFilter.EndsWithCI { FieldName = fname; Value = value })) + | StartsWithCI fname, StringValue value -> Ok (ValueSome (ObjectListFilter.StartsWithCI { FieldName = fname; Value = value })) + | ContainsCI fname, StringValue value -> Ok (ValueSome (ObjectListFilter.ContainsCI { FieldName = fname; Value = value })) + | EqualsCI fname, StringValue value -> Ok (ValueSome (ObjectListFilter.EqualsCI { FieldName = fname; Value = value })) | Equals fname, ObjectValue value -> match mapInput value with | Error errs -> Error errs diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs index f1c06fa8..7f5759b1 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs @@ -526,3 +526,68 @@ let ``ObjectListFilter OfTypes works with two or more types`` () = let animal = List.last filteredData animal.ID |> equals 4 animal.Name |> equals "Horse D" + +[] +let ``ObjectListFilter works with EqualsCI operator`` () = + let filter = EqualsCI { FieldName = "firstName"; Value = "jonathan" } + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 1 + let result = List.head filteredData + result.ID |> equals 2 + result.FirstName |> equals "Jonathan" + result.LastName |> equals "Abrams" + +[] +let ``ObjectListFilter works with EqualsCI operator upper case`` () = + let filter = EqualsCI { FieldName = "firstName"; Value = "JONATHAN" } + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 1 + let result = List.head filteredData + result.ID |> equals 2 + result.FirstName |> equals "Jonathan" + +[] +let ``ObjectListFilter works with StartsWithCI operator`` () = + let filter = StartsWithCI { FieldName = "firstName"; Value = "j" } + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 2 + let result = List.head filteredData + result.ID |> equals 2 + result.FirstName |> equals "Jonathan" + +[] +let ``ObjectListFilter works with EndsWithCI operator`` () = + let filter = EndsWithCI { FieldName = "lastName"; Value = "AMS" } + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 2 + let result = List.head filteredData + result.ID |> equals 4 + result.LastName |> equals "Adams" + let result = List.last filteredData + result.ID |> equals 2 + result.LastName |> equals "Abrams" + +[] +let ``ObjectListFilter works with ContainsCI operator`` () = + let filter = ContainsCI { FieldName = "firstName"; Value = "EN" } + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 2 + let result = List.head filteredData + result.ID |> equals 4 + result.FirstName |> equals "Ben" + let result = List.last filteredData + result.ID |> equals 7 + result.FirstName |> equals "Jeneffer" + +[] +let ``ObjectListFilter case-insensitive operators do not match with case-sensitive filters`` () = + // Exact case-sensitive StartsWith "j" (lowercase) should match nothing in the data + let filter = StartsWith { FieldName = "firstName"; Value = "j" } + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 0 From efb8ed40d7f436336f2765c49638e8f14b8f41ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:56:39 +0000 Subject: [PATCH 02/25] Redesign ObjectListFilter: add IComparer to Equals/StartsWith/EndsWith/Contains DU cases Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ObjectListFilter.fs | 138 +++++++++------- .../SchemaDefinitions.fs | 147 +++++++++++++----- .../MiddlewareTests.fs | 24 +-- .../ObjectListFilterLinqGenerateTests.fs | 34 ++-- .../ObjectListFilterLinqTests.fs | 38 ++--- 5 files changed, 236 insertions(+), 145 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index cb1f004a..d156838a 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -11,27 +11,24 @@ type ObjectListFilter = | And of ObjectListFilter * ObjectListFilter | Or of ObjectListFilter * ObjectListFilter | Not of ObjectListFilter - | Equals of FieldFilter + | Equals of Filter : FieldFilter * Comparer : System.Collections.IComparer | GreaterThan of FieldFilter | GreaterThanOrEqual of FieldFilter | LessThan of FieldFilter | LessThanOrEqual of FieldFilter | In of FieldFilter - | StartsWith of FieldFilter - | EndsWith of FieldFilter - | Contains of FieldFilter + | StartsWith of Filter : FieldFilter * Comparer : System.Collections.IComparer + | EndsWith of Filter : FieldFilter * Comparer : System.Collections.IComparer + | Contains of Filter : FieldFilter * Comparer : System.Collections.IComparer | OfTypes of Type list | FilterField of FieldFilter - | EqualsCI of FieldFilter - | StartsWithCI of FieldFilter - | EndsWithCI of FieldFilter - | ContainsCI of FieldFilter open System.Linq open System.Linq.Expressions open System.Runtime.InteropServices open System.Reflection open System.Collections.Generic +open System.Collections type private CompareDiscriminatorExpression<'T, 'D> = Expression> @@ -99,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 } @@ -114,13 +111,13 @@ 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 } @@ -132,16 +129,16 @@ module ObjectListFilter = let ( !!! ) filter = Not filter /// Creates a new ObjectListFilter representing a case-insensitive EQUALS operation on a string value. - let ( ===~ ) fname value = EqualsCI { FieldName = fname; Value = 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 = StartsWithCI { FieldName = fname; Value = value } + let ( =@@~ ) fname value = 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 = EndsWithCI { FieldName = fname; Value = value } + let ( @@=~ ) fname value = EndsWith ({ FieldName = fname; Value = value }, StringComparer.OrdinalIgnoreCase) /// Creates a new ObjectListFilter representing a case-insensitive CONTAINS operation on a string value. - let ( @=@~ ) fname value = ContainsCI { FieldName = fname; Value = value } + let ( @=@~ ) fname (value : string) = Contains ({ FieldName = fname; Value = value }, StringComparer.OrdinalIgnoreCase) let private genericWhereMethod = typeof.GetMethods () @@ -164,11 +161,10 @@ module ObjectListFilter = let private StringEndsWithMethod = stringType.GetMethod ("EndsWith", [| stringType |]) let private StringContainsMethod = stringType.GetMethod ("Contains", [| stringType |]) let private stringComparisonType = typeof - let private StringEqualsCIMethod = stringType.GetMethod ("Equals", [| stringType; stringComparisonType |]) - let private StringStartsWithCIMethod = stringType.GetMethod ("StartsWith", [| stringType; stringComparisonType |]) - let private StringEndsWithCIMethod = stringType.GetMethod ("EndsWith", [| stringType; stringComparisonType |]) - let private StringContainsCIMethod = stringType.GetMethod ("Contains", [| stringType; stringComparisonType |]) - let private OrdinalIgnoreCase = Expression.Constant (StringComparison.OrdinalIgnoreCase) + 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 |]) let private unwrapOptionMethod = FSharp.Data.GraphQL.Helpers.moduleType.GetMethod (nameof Helpers.unwrap) @@ -227,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) = + 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 + else ValueNone + | _ -> ValueNone + let rec buildFilterExpr isEnumerableQuery (param : SourceExpression) buildTypeDiscriminatorCheck filter : Expression = let build = buildFilterExpr isEnumerableQuery param buildTypeDiscriminatorCheck @@ -246,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), 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), 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 @@ -295,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)) @@ -338,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), 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 @@ -351,18 +383,6 @@ module ObjectListFilter = | FilterField f -> let paramExpr = Expression.PropertyOrField (param, f.FieldName) buildFilterExpr isEnumerableQuery (SourceExpression paramExpr) buildTypeDiscriminatorCheck f.Value - | EqualsCI f -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsCIMethod, Expression.Constant f.Value, OrdinalIgnoreCase) - | StartsWithCI f -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithCIMethod, Expression.Constant f.Value, OrdinalIgnoreCase) - | EndsWithCI f -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithCIMethod, Expression.Constant f.Value, OrdinalIgnoreCase) - | ContainsCI f -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - Expression.Call (normalizeStringMemberExpr ``member``, StringContainsCIMethod, Expression.Constant f.Value, OrdinalIgnoreCase) type private CompareDiscriminatorExpressionVisitor<'T, 'D> (compareDiscriminator : CompareDiscriminatorExpression<'T, 'D>, param : SourceExpression, value : obj) = diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index d1d9401c..51ef0ec7 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -9,48 +9,122 @@ open FSharp.Data.GraphQL.Ast open FsToolkit.ErrorHandling type private ComparisonOperator = - | EndsWith of string - | StartsWith of string - | Contains of string + | EndsWith of FieldName : string * Comparer : StringComparer + | StartsWith of FieldName : string * Comparer : StringComparer + | Contains of FieldName : string * Comparer : StringComparer + | StringEquals of FieldName : string * Comparer : StringComparer | Equals of string | GreaterThan of string | GreaterThanOrEqual of string | LessThan of string | LessThanOrEqual of string | In of string - | EqualsCI of string - | StartsWithCI of string - | EndsWithCI of string - | ContainsCI of string + +// String filter suffixes: +// lowercase → case-insensitive (OrdinalIgnoreCase) +// Capitalized / UPPER → case-sensitive (Ordinal) +[] +let private endsWithSuffix = "_ends_with" + +[] +let private ewSuffix = "_ew" + +[] +let private EndsWithCSSuffix = "_Ends_With" + +[] +let private EWCSSuffix = "_EW" + +[] +let private startsWithSuffix = "_starts_with" + +[] +let private swSuffix = "_sw" + +[] +let private StartsWithCSSuffix = "_Starts_With" + +[] +let private SWCSSuffix = "_SW" + +[] +let private containsSuffix = "_contains" + +[] +let private ContainsCSSuffix = "_Contains" + +[] +let private equalsSuffix = "_equals" + +[] +let private eqSuffix = "_eq" + +[] +let private EqualsCSSuffix = "_Equals" + +[] +let private EQCSSuffix = "_EQ" + +[] +let private greaterThanOrEqualSuffix = "_greater_than_or_equal" + +[] +let private gteSuffix = "_gte" + +[] +let private greaterThanSuffix = "_greater_than" + +[] +let private gtSuffix = "_gt" + +[] +let private lessThanOrEqualSuffix = "_less_than_or_equal" + +[] +let private lteSuffix = "_lte" + +[] +let private lessThanSuffix = "_less_than" + +[] +let private ltSuffix = "_lt" + +[] +let private inSuffix = "_in" let rec private coerceObjectListFilterInput (variables : Variables) inputValue : Result = let parseFieldCondition (s : string) = - let s = s.ToLowerInvariant () let prefix (suffix : string) (s : string) = s.Substring (0, s.Length - suffix.Length) + // Phase 1: case-sensitive string ops – match original string against capitalized/uppercase suffixes + match s with + | s when s.EndsWith EndsWithCSSuffix && s.Length > EndsWithCSSuffix.Length -> EndsWith (prefix EndsWithCSSuffix s, StringComparer.Ordinal) + | s when s.EndsWith EWCSSuffix && s.Length > EWCSSuffix.Length -> EndsWith (prefix EWCSSuffix s, StringComparer.Ordinal) + | s when s.EndsWith StartsWithCSSuffix && s.Length > StartsWithCSSuffix.Length -> StartsWith (prefix StartsWithCSSuffix s, StringComparer.Ordinal) + | s when s.EndsWith SWCSSuffix && s.Length > SWCSSuffix.Length -> StartsWith (prefix SWCSSuffix s, StringComparer.Ordinal) + | s when s.EndsWith ContainsCSSuffix && s.Length > ContainsCSSuffix.Length -> Contains (prefix ContainsCSSuffix s, StringComparer.Ordinal) + | s when s.EndsWith EqualsCSSuffix && s.Length > EqualsCSSuffix.Length -> StringEquals (prefix EqualsCSSuffix s, StringComparer.Ordinal) + | s when s.EndsWith EQCSSuffix && s.Length > EQCSSuffix.Length -> StringEquals (prefix EQCSSuffix s, StringComparer.Ordinal) + | _ -> + // Phase 2: case-insensitive string ops and numeric ops – lower-case before matching + let s = s.ToLowerInvariant () match s with - | s when s.EndsWith ("_ends_with_ci") && s.Length > "_ends_with_ci".Length -> EndsWithCI (prefix "_ends_with_ci" s) - | s when s.EndsWith ("_ewci") && s.Length > "_ewci".Length -> EndsWithCI (prefix "_ewci" s) - | s when s.EndsWith ("_starts_with_ci") && s.Length > "_starts_with_ci".Length -> StartsWithCI (prefix "_starts_with_ci" s) - | s when s.EndsWith ("_swci") && s.Length > "_swci".Length -> StartsWithCI (prefix "_swci" s) - | s when s.EndsWith ("_contains_ci") && s.Length > "_contains_ci".Length -> ContainsCI (prefix "_contains_ci" s) - | s when s.EndsWith ("_cci") && s.Length > "_cci".Length -> ContainsCI (prefix "_cci" s) - | s when s.EndsWith ("_equals_ci") && s.Length > "_equals_ci".Length -> EqualsCI (prefix "_equals_ci" s) - | s when s.EndsWith ("_eqi") && s.Length > "_eqi".Length -> EqualsCI (prefix "_eqi" s) - | s when s.EndsWith ("_ends_with") && s.Length > "_ends_with".Length -> EndsWith (prefix "_ends_with" s) - | s when s.EndsWith ("_ew") && s.Length > "_ew".Length -> EndsWith (prefix "_ew" s) - | s when s.EndsWith ("_starts_with") && s.Length > "_starts_with".Length -> StartsWith (prefix "_starts_with" s) - | s when s.EndsWith ("_sw") && s.Length > "_sw".Length -> StartsWith (prefix "_sw" s) - | s when s.EndsWith ("_contains") && s.Length > "_contains".Length -> Contains (prefix "_contains" s) - | s when s.EndsWith ("_greater_than") && s.Length > "_greater_than".Length -> GreaterThan (prefix "_greater_than" s) - | s when s.EndsWith ("_gt") && s.Length > "_gt".Length -> GreaterThan (prefix "_gt" s) - | s when s.EndsWith ("_greater_than_or_equal") && s.Length > "_greater_than_or_equal".Length -> GreaterThanOrEqual (prefix "_greater_than_or_equal" s) - | s when s.EndsWith ("_gte") && s.Length > "_gte".Length -> GreaterThanOrEqual (prefix "_gte" s) - | s when s.EndsWith ("_less_than") && s.Length > "_less_than".Length -> LessThan (prefix "_less_than" s) - | s when s.EndsWith ("_lt") && s.Length > "_lt".Length -> LessThan (prefix "_lt" s) - | s when s.EndsWith ("_less_than_or_equal") && s.Length > "_less_than_or_equal".Length -> LessThanOrEqual (prefix "_less_than_or_equal" s) - | s when s.EndsWith ("_lte") && s.Length > "_lte".Length -> LessThanOrEqual (prefix "_lte" s) - | s when s.EndsWith ("_in") && s.Length > "_in".Length -> In (prefix "_in" s) + | s when s.EndsWith endsWithSuffix && s.Length > endsWithSuffix.Length -> EndsWith (prefix endsWithSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith ewSuffix && s.Length > ewSuffix.Length -> EndsWith (prefix ewSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith startsWithSuffix && s.Length > startsWithSuffix.Length -> StartsWith (prefix startsWithSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith swSuffix && s.Length > swSuffix.Length -> StartsWith (prefix swSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith containsSuffix && s.Length > containsSuffix.Length -> Contains (prefix containsSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith equalsSuffix && s.Length > equalsSuffix.Length -> StringEquals (prefix equalsSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith eqSuffix && s.Length > eqSuffix.Length -> StringEquals (prefix eqSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith greaterThanOrEqualSuffix && s.Length > greaterThanOrEqualSuffix.Length -> GreaterThanOrEqual (prefix greaterThanOrEqualSuffix s) + | s when s.EndsWith gteSuffix && s.Length > gteSuffix.Length -> GreaterThanOrEqual (prefix gteSuffix s) + | s when s.EndsWith greaterThanSuffix && s.Length > greaterThanSuffix.Length -> GreaterThan (prefix greaterThanSuffix s) + | s when s.EndsWith gtSuffix && s.Length > gtSuffix.Length -> GreaterThan (prefix gtSuffix s) + | s when s.EndsWith lessThanOrEqualSuffix && s.Length > lessThanOrEqualSuffix.Length -> LessThanOrEqual (prefix lessThanOrEqualSuffix s) + | s when s.EndsWith lteSuffix && s.Length > lteSuffix.Length -> LessThanOrEqual (prefix lteSuffix s) + | s when s.EndsWith lessThanSuffix && s.Length > lessThanSuffix.Length -> LessThan (prefix lessThanSuffix s) + | s when s.EndsWith ltSuffix && s.Length > ltSuffix.Length -> LessThan (prefix ltSuffix s) + | s when s.EndsWith inSuffix && s.Length > inSuffix.Length -> In (prefix inSuffix s) | s -> Equals s let (|EquatableValue|NonEquatableValue|) v = @@ -108,19 +182,16 @@ let rec private coerceObjectListFilterInput (variables : Variables) inputValue : | Error errs -> Error errs | Ok ValueNone -> Ok ValueNone | Ok (ValueSome filter) -> Ok (ValueSome (Not filter)) - | EndsWith fname, StringValue value -> Ok (ValueSome (ObjectListFilter.EndsWith { FieldName = fname; Value = value })) - | StartsWith fname, StringValue value -> Ok (ValueSome (ObjectListFilter.StartsWith { FieldName = fname; Value = value })) - | Contains fname, ComparableValue value -> Ok (ValueSome (ObjectListFilter.Contains { FieldName = fname; Value = value })) - | EndsWithCI fname, StringValue value -> Ok (ValueSome (ObjectListFilter.EndsWithCI { FieldName = fname; Value = value })) - | StartsWithCI fname, StringValue value -> Ok (ValueSome (ObjectListFilter.StartsWithCI { FieldName = fname; Value = value })) - | ContainsCI fname, StringValue value -> Ok (ValueSome (ObjectListFilter.ContainsCI { FieldName = fname; Value = value })) - | EqualsCI fname, StringValue value -> Ok (ValueSome (ObjectListFilter.EqualsCI { FieldName = fname; Value = value })) + | EndsWith (fname, comparer), StringValue value -> Ok (ValueSome (ObjectListFilter.EndsWith ({ FieldName = fname; Value = value }, comparer))) + | StartsWith (fname, comparer), StringValue value -> Ok (ValueSome (ObjectListFilter.StartsWith ({ FieldName = fname; Value = value }, comparer))) + | Contains (fname, comparer), ComparableValue value -> Ok (ValueSome (ObjectListFilter.Contains ({ FieldName = fname; Value = value }, comparer))) + | StringEquals (fname, comparer), StringValue value -> Ok (ValueSome (ObjectListFilter.Equals ({ FieldName = fname; Value = value }, comparer))) | Equals fname, ObjectValue value -> match mapInput value with | Error errs -> Error errs | Ok ValueNone -> Ok ValueNone | Ok (ValueSome filter) -> Ok (ValueSome (FilterField { FieldName = fname; Value = filter })) - | Equals fname, EquatableValue value -> Ok (ValueSome (ObjectListFilter.Equals { FieldName = fname; Value = value })) + | Equals fname, EquatableValue value -> Ok (ValueSome (ObjectListFilter.Equals ({ FieldName = fname; Value = value }, null))) | GreaterThan fname, ComparableValue value -> Ok (ValueSome (ObjectListFilter.GreaterThan { FieldName = fname; Value = value })) | GreaterThanOrEqual fname, ComparableValue value -> Ok (ValueSome (ObjectListFilter.GreaterThanOrEqual { FieldName = fname; Value = value })) | LessThan fname, ComparableValue value -> Ok (ValueSome (ObjectListFilter.LessThan { FieldName = fname; Value = value })) diff --git a/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs b/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs index 8859057e..8180eec4 100644 --- a/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs @@ -599,7 +599,7 @@ let ``Object list filter: must return filter information in Metadata`` () = ] ] let expectedFilter : KeyValuePair = - kvp ([ "A"; "s" ]) (And (Equals { FieldName = "id"; Value = 2L }, StartsWith { FieldName = "value"; Value = "A" })) + kvp ([ "A"; "s" ]) (And (Equals ({ FieldName = "id"; Value = 2L }, null), StartsWith ({ FieldName = "value"; Value = "A" }, StringComparer.OrdinalIgnoreCase))) let result = execute query ensureDirect result <| fun data errors -> @@ -647,7 +647,7 @@ let ``Object list filter: Must return AND filter information in Metadata`` () = ] ] let expectedFilter : KeyValuePair = - kvp ([ "A"; "subjects" ]) (And (StartsWith { FieldName = "value"; Value = "3" }, Equals { FieldName = "id"; Value = 6L })) + kvp ([ "A"; "subjects" ]) (And (StartsWith ({ FieldName = "value"; Value = "3" }, StringComparer.OrdinalIgnoreCase), Equals ({ FieldName = "id"; Value = 6L }, null))) let result = execute query ensureDirect result <| fun data errors -> @@ -693,7 +693,7 @@ let ``Object list filter: Must return OR filter information in Metadata`` () = ] ] let expectedFilter : KeyValuePair = - kvp ([ "A"; "subjects" ]) (Or (StartsWith { FieldName = "value"; Value = "3" }, Equals { FieldName = "id"; Value = 6L })) + kvp ([ "A"; "subjects" ]) (Or (StartsWith ({ FieldName = "value"; Value = "3" }, StringComparer.OrdinalIgnoreCase), Equals ({ FieldName = "id"; Value = 6L }, null))) let result = execute query ensureDirect result <| fun data errors -> @@ -785,7 +785,7 @@ let ``Object list filter: Must return Contains filter information in Metadata`` ] ] let expectedFilter : KeyValuePair = - kvp ([ "A"; "subjects" ]) (Contains { FieldName = "value"; Value = "3" }) + kvp ([ "A"; "subjects" ]) (Contains ({ FieldName = "value"; Value = "3" }, StringComparer.OrdinalIgnoreCase)) let result = execute query ensureDirect result <| fun data errors -> @@ -831,7 +831,7 @@ let ``Object list filter: Must return NOT filter information in Metadata`` () = ] ] let expectedFilter : KeyValuePair = - kvp ([ "A"; "subjects" ]) (Not (StartsWith { FieldName = "value"; Value = "3" })) + kvp ([ "A"; "subjects" ]) (Not (StartsWith ({ FieldName = "value"; Value = "3" }, StringComparer.OrdinalIgnoreCase))) let result = execute query ensureDirect result <| fun data errors -> @@ -879,7 +879,7 @@ let ``Object list filter: Must return filter information in Metadata when suppli do let notStartsFilter = """{ "not": { "value_starts_with": "3" } }""" |> JsonDocument.Parse |> _.RootElement let variables = ImmutableDictionary.Empty.Add ("filter", notStartsFilter) - let filter = Not (StartsWith { FieldName = "value"; Value = "3" }) + let filter = Not (StartsWith ({ FieldName = "value"; Value = "3" }, StringComparer.OrdinalIgnoreCase)) let expectedFilter : KeyValuePair = kvp ([ "A"; "subjects" ]) (filter) let result = executeAndVerifyFilter (query, variables, filter) @@ -891,7 +891,7 @@ let ``Object list filter: Must return filter information in Metadata when suppli do let notEndsFilter = """{ "not": { "value_ends_with": "2" } }""" |> JsonDocument.Parse |> _.RootElement let variables = ImmutableDictionary.Empty.Add ("filter", notEndsFilter) - let filter = Not (EndsWith { FieldName = "value"; Value = "2" }) + let filter = Not (EndsWith ({ FieldName = "value"; Value = "2" }, StringComparer.OrdinalIgnoreCase)) let expectedFilter : KeyValuePair = kvp ([ "A"; "subjects" ]) (filter) let result = executeAndVerifyFilter (query, variables, filter) @@ -903,7 +903,7 @@ let ``Object list filter: Must return filter information in Metadata when suppli do let notStartsFilter = """{ "not": { "value_sw": "3" } }""" |> JsonDocument.Parse |> _.RootElement let variables = ImmutableDictionary.Empty.Add ("filter", notStartsFilter) - let filter = Not (StartsWith { FieldName = "value"; Value = "3" }) + let filter = Not (StartsWith ({ FieldName = "value"; Value = "3" }, StringComparer.OrdinalIgnoreCase)) let expectedFilter : KeyValuePair = kvp ([ "A"; "subjects" ]) (filter) let result = executeAndVerifyFilter (query, variables, filter) @@ -915,7 +915,7 @@ let ``Object list filter: Must return filter information in Metadata when suppli do let notEndsFilter = """{ "not": { "value_ew": "2" } }""" |> JsonDocument.Parse |> _.RootElement let variables = ImmutableDictionary.Empty.Add ("filter", notEndsFilter) - let filter = Not (EndsWith { FieldName = "value"; Value = "2" }) + let filter = Not (EndsWith ({ FieldName = "value"; Value = "2" }, StringComparer.OrdinalIgnoreCase)) let expectedFilter : KeyValuePair = kvp ([ "A"; "subjects" ]) (filter) let result = executeAndVerifyFilter (query, variables, filter) @@ -1023,7 +1023,7 @@ let ``Object list filter: Must return filter information in Metadata when suppli do let notContainsFilter = """{ "not": { "value_contains": "A" } }""" |> JsonDocument.Parse |> _.RootElement let variables = ImmutableDictionary.Empty.Add ("filter", notContainsFilter) - let filter = Not (Contains { FieldName = "value"; Value = "A" }) + let filter = Not (Contains ({ FieldName = "value"; Value = "A" }, StringComparer.OrdinalIgnoreCase)) let expectedFilter : KeyValuePair = kvp ([ "A"; "subjects" ]) (filter) let result = executeAndVerifyFilter (query, variables, filter) @@ -1035,7 +1035,7 @@ let ``Object list filter: Must return filter information in Metadata when suppli do let notEqualsFilter = """{ "not": { "value": "A2" } }""" |> JsonDocument.Parse |> _.RootElement let variables = ImmutableDictionary.Empty.Add ("filter", notEqualsFilter) - let filter = Not (Equals { FieldName = "value"; Value = "A2" }) + let filter = Not (Equals ({ FieldName = "value"; Value = "A2" }, null)) let expectedFilter : KeyValuePair = kvp ([ "A"; "subjects" ]) (filter) let result = executeAndVerifyFilter (query, variables, filter) @@ -1084,7 +1084,7 @@ let ``Object list filter: Must parse filter that references variables`` () = do let filterValue = "3" |> JsonDocument.Parse |> _.RootElement let variables = ImmutableDictionary.Empty.Add ("filter", filterValue) - let filter = (StartsWith { FieldName = "value"; Value = "3" }) + let filter = (StartsWith ({ FieldName = "value"; Value = "3" }, StringComparer.OrdinalIgnoreCase)) let expectedFilter : KeyValuePair = kvp ([ "A"; "subjects" ]) (filter) let result = executeAndVerifyFilter (query, variables, filter) diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs index d249e788..d4c98192 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs @@ -103,7 +103,7 @@ let filterOptions = ObjectListFilterLinqOptions.None [] let ``ObjectListFilter works with Equals operator for ValidStringStruct`` () = let queryable = container.GetItemLinqQueryable () - let filter = Equals { FieldName = "validStringStruct"; Value = "Jonathan" } + let filter = Equals ({ FieldName = "validStringStruct"; Value = "Jonathan" }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText """SELECT VALUE root FROM root WHERE (root["validStringStruct"] = "Jonathan")""" @@ -111,7 +111,7 @@ let ``ObjectListFilter works with Equals operator for ValidStringStruct`` () = [] let ``ObjectListFilter works with not Equals operator for ValidStringStruct`` () = let queryable = container.GetItemLinqQueryable () - let filter = Not (Equals { FieldName = "validStringStruct"; Value = "Jonathan" }) + let filter = Not (Equals ({ FieldName = "validStringStruct"; Value = "Jonathan" }, null)) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText """SELECT VALUE root FROM root WHERE (root["validStringStruct"] != "Jonathan")""" @@ -119,7 +119,7 @@ let ``ObjectListFilter works with not Equals operator for ValidStringStruct`` () [] let ``ObjectListFilter works with Equals operator for ValueOptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Equals { FieldName = "valueOptionString"; Value = "Jonathan" } + let filter = Equals ({ FieldName = "valueOptionString"; Value = "Jonathan" }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText """SELECT VALUE root FROM root WHERE (root["valueOptionString"] = "Jonathan")""" @@ -127,7 +127,7 @@ let ``ObjectListFilter works with Equals operator for ValueOptionString`` () = [] let ``ObjectListFilter works with not Equals operator for ValueOptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Not (Equals { FieldName = "valueOptionString"; Value = "Jonathan" }) + let filter = Not (Equals ({ FieldName = "valueOptionString"; Value = "Jonathan" }, null)) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText """SELECT VALUE root FROM root WHERE (root["valueOptionString"] != "Jonathan")""" @@ -135,7 +135,7 @@ let ``ObjectListFilter works with not Equals operator for ValueOptionString`` () [] let ``ObjectListFilter works with Equals operator for null ValueOptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Equals { FieldName = "valueOptionString"; Value = null } + let filter = Equals ({ FieldName = "valueOptionString"; Value = null }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (root["valueOptionString"] = null)""" @@ -143,7 +143,7 @@ let ``ObjectListFilter works with Equals operator for null ValueOptionString`` ( [] let ``ObjectListFilter works with Equals operator for ValueNone ValueOptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Equals { FieldName = "valueOptionString"; Value = (ValueNone : voption) } + let filter = Equals ({ FieldName = "valueOptionString"; Value = (ValueNone : voption) }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (root["valueOptionString"] = null)""" @@ -151,7 +151,7 @@ let ``ObjectListFilter works with Equals operator for ValueNone ValueOptionStrin [] let ``ObjectListFilter works with not Equals operator for ValueNone ValueOptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Not (Equals { FieldName = "valueOptionString"; Value = (ValueNone : voption) }) + let filter = Not (Equals ({ FieldName = "valueOptionString"; Value = (ValueNone : voption) }, null)) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (root["valueOptionString"] != null)""" @@ -159,7 +159,7 @@ let ``ObjectListFilter works with not Equals operator for ValueNone ValueOptionS [] let ``ObjectListFilter works with Equals operator for OptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Equals { FieldName = "optionString"; Value = "Jonathan" } + let filter = Equals ({ FieldName = "optionString"; Value = "Jonathan" }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText """SELECT VALUE root FROM root WHERE (root["optionString"] = "Jonathan")""" @@ -167,7 +167,7 @@ let ``ObjectListFilter works with Equals operator for OptionString`` () = [] let ``ObjectListFilter works with not Equals operator for OptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Not (Equals { FieldName = "optionString"; Value = "Jonathan" }) + let filter = Not (Equals ({ FieldName = "optionString"; Value = "Jonathan" }, null)) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText """SELECT VALUE root FROM root WHERE (root["optionString"] != "Jonathan")""" @@ -175,7 +175,7 @@ let ``ObjectListFilter works with not Equals operator for OptionString`` () = [] let ``ObjectListFilter works with Equals operator for null OptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Equals { FieldName = "optionString"; Value = null } + let filter = Equals ({ FieldName = "optionString"; Value = null }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (root["optionString"] = null)""" @@ -183,7 +183,7 @@ let ``ObjectListFilter works with Equals operator for null OptionString`` () = [] let ``ObjectListFilter works with not Equals operator for null OptionString`` () = let queryable = container.GetItemLinqQueryable () - let filter = Not (Equals { FieldName = "optionString"; Value = null }) + let filter = Not (Equals ({ FieldName = "optionString"; Value = null }, null)) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (root["optionString"] = null)""" @@ -191,7 +191,7 @@ let ``ObjectListFilter works with not Equals operator for null OptionString`` () [] let ``ObjectListFilter works with StartsWith operator for ValidStringStruct`` () = let queryable = container.GetItemLinqQueryable () - let filter = StartsWith { FieldName = "validStringStruct"; Value = "J" } + let filter = StartsWith ({ FieldName = "validStringStruct"; Value = "J" }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE STARTSWITH(root["validStringStruct"], "J")""" @@ -199,7 +199,7 @@ let ``ObjectListFilter works with StartsWith operator for ValidStringStruct`` () [] let ``ObjectListFilter works with EndsWith operator for ValidStringStruct`` () = let queryable = container.GetItemLinqQueryable () - let filter = EndsWith { FieldName = "validStringStruct"; Value = "n" } + let filter = EndsWith ({ FieldName = "validStringStruct"; Value = "n" }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE ENDSWITH(root["validStringStruct"], "n")""" @@ -207,7 +207,7 @@ let ``ObjectListFilter works with EndsWith operator for ValidStringStruct`` () = [] let ``ObjectListFilter works with Contains operator for ValidStringStruct`` () = let queryable = container.GetItemLinqQueryable () - let filter = Contains { FieldName = "validStringStruct"; Value = "athan" } + let filter = Contains ({ FieldName = "validStringStruct"; Value = "athan" }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE CONTAINS(root["validStringStruct"], "athan")""" @@ -215,7 +215,7 @@ let ``ObjectListFilter works with Contains operator for ValidStringStruct`` () = [] let ``ObjectListFilter works with Contains operator for ValidStringStruct list`` () = let queryable = container.GetItemLinqQueryable () - let filter = Contains { FieldName = "validStringStructList"; Value = "athan" } + let filter = Contains ({ FieldName = "validStringStructList"; Value = "athan" }, null) let filterQuery = queryable.Apply (filter, filterOptions) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE ARRAY_CONTAINS(root["validStringStructList"], "athan")""" @@ -238,7 +238,7 @@ let ``ObjectListFilter works with In operator for empty ValidStringStruct list`` [] let ``ObjectListFilter works with Equals operator for ValidStringObject`` () = - let filter = Equals { FieldName = "validStringObject"; Value = ValidStringObject "Jonathan" } + let filter = Equals ({ FieldName = "validStringObject"; Value = ValidStringObject "Jonathan" }, null) let queryable = container.GetItemLinqQueryable () let filterQuery = queryable.Apply (filter) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery @@ -246,7 +246,7 @@ let ``ObjectListFilter works with Equals operator for ValidStringObject`` () = [] let ``ObjectListFilter works with not Equals operator for ValidStringObject`` () = - let filter = Not (Equals { FieldName = "validStringObject"; Value = ValidStringObject "Jonathan" }) + let filter = Not (Equals ({ FieldName = "validStringObject"; Value = ValidStringObject "Jonathan" }, null)) let queryable = container.GetItemLinqQueryable () let filterQuery = queryable.Apply (filter) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs index 7f5759b1..33ad42c8 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs @@ -9,7 +9,7 @@ open FSharp.Data.GraphQL.Tests.LinqTests [] let ``ObjectListFilter works with Equals operator`` () = - let filter = Equals { FieldName = "firstName"; Value = "Jonathan" } // :> IComparable + let filter = Equals ({ FieldName = "firstName"; Value = "Jonathan" }, null) // :> IComparable let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 1 @@ -90,7 +90,7 @@ let ``ObjectListFilter works with LessThanOrEqual operator`` () = [] let ``ObjectListFilter works with StartsWith operator`` () = - let filter = StartsWith { FieldName = "firstName"; Value = "J" } + let filter = StartsWith ({ FieldName = "firstName"; Value = "J" }, null) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -103,7 +103,7 @@ let ``ObjectListFilter works with StartsWith operator`` () = [] let ``ObjectListFilter works with Contains operator`` () = - let filter = Contains { FieldName = "firstName"; Value = "en" } + let filter = Contains ({ FieldName = "firstName"; Value = "en" }, null) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -116,7 +116,7 @@ let ``ObjectListFilter works with Contains operator`` () = [] let ``ObjectListFilter works with EndsWith operator`` () = - let filter = EndsWith { FieldName = "lastName"; Value = "ams" } + let filter = EndsWith ({ FieldName = "lastName"; Value = "ams" }, null) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -130,7 +130,7 @@ let ``ObjectListFilter works with EndsWith operator`` () = [] let ``ObjectListFilter works with AND operator`` () = let filter = - And (Contains { FieldName = "firstName"; Value = "en" }, Equals { FieldName = "lastName"; Value = "Adams" }) + And (Contains ({ FieldName = "firstName"; Value = "en" }, null), Equals ({ FieldName = "lastName"; Value = "Adams" }, null)) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 1 @@ -144,7 +144,7 @@ let ``ObjectListFilter works with AND operator`` () = [] let ``ObjectListFilter works with OR operator`` () = let filter = - Or (GreaterThan { FieldName = "id"; Value = 4 }, Equals { FieldName = "lastName"; Value = "Adams" }) + Or (GreaterThan { FieldName = "id"; Value = 4 }, Equals ({ FieldName = "lastName"; Value = "Adams" }, null)) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -191,7 +191,7 @@ let ``ObjectListFilter works with IN operator for int type field`` () = [] let ``ObjectListFilter works with Contains operator for array type field`` () = - let filter = Contains { FieldName = "friends"; Value = { Email = "j.abrams@gmail.com" } } + let filter = Contains ({ FieldName = "friends"; Value = { Email = "j.abrams@gmail.com" } }, null) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -215,7 +215,7 @@ let ``ObjectListFilter works with FilterField operator`` () = let filter = FilterField { FieldName = "Contact" - Value = Contains { FieldName = "Email"; Value = "j.trif@gmail.com" } + Value = Contains ({ FieldName = "Email"; Value = "j.trif@gmail.com" }, null) } let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList @@ -229,7 +229,7 @@ let ``ObjectListFilter works with FilterField operator`` () = [] let ``ObjectListFilter works with NOT operator`` () = - let filter = Not (Equals { FieldName = "lastName"; Value = "Adams" }) + let filter = Not (Equals ({ FieldName = "lastName"; Value = "Adams" }, null)) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -416,7 +416,7 @@ let ``ObjectListFilter works with getDiscriminatorValue for Horse`` () = [] let ``ObjectListFilter works with getDiscriminatorValue startsWith for Horse and Hamster`` () = let queryable = animalData.AsQueryable () - let filter = StartsWith { FieldName = "Discriminator"; Value = "H" } + let filter = StartsWith ({ FieldName = "Discriminator"; Value = "H" }, null) let options = ObjectListFilterLinqOptions ( (fun entity (discriminator : string) -> entity.Discriminator.StartsWith discriminator), @@ -449,7 +449,7 @@ let ``ObjectListFilter works with Contains operator on list collection propertie { Name = "Product D"; Tags = [ "Tag4"; "Tag5" ] } ] let queryable = productList.AsQueryable () - let filter = Contains { FieldName = "Tags"; Value = "Tag3" } + let filter = Contains ({ FieldName = "Tags"; Value = "Tag3" }, null) let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 do @@ -471,7 +471,7 @@ let ``ObjectListFilter works with Contains operator on array collection properti { Name = "Product D"; Tags = [| "Tag4"; "Tag5" |] } ] let queryable = productArray.AsQueryable () - let filter = Contains { FieldName = "Tags"; Value = "Tag3" } + let filter = Contains ({ FieldName = "Tags"; Value = "Tag3" }, null) let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 do @@ -493,7 +493,7 @@ let ``ObjectListFilter works with Contains operator on set collection properties { Name = "Product D"; Tags = [| "Tag4"; "Tag5" |] |> Set.ofArray } ] let queryable = productArray.AsQueryable () - let filter = Contains { FieldName = "Tags"; Value = "Tag3" } + let filter = Contains ({ FieldName = "Tags"; Value = "Tag3" }, null) let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 do @@ -529,7 +529,7 @@ let ``ObjectListFilter OfTypes works with two or more types`` () = [] let ``ObjectListFilter works with EqualsCI operator`` () = - let filter = EqualsCI { FieldName = "firstName"; Value = "jonathan" } + let filter = Equals ({ FieldName = "firstName"; Value = "jonathan" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 1 @@ -540,7 +540,7 @@ let ``ObjectListFilter works with EqualsCI operator`` () = [] let ``ObjectListFilter works with EqualsCI operator upper case`` () = - let filter = EqualsCI { FieldName = "firstName"; Value = "JONATHAN" } + let filter = Equals ({ FieldName = "firstName"; Value = "JONATHAN" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 1 @@ -550,7 +550,7 @@ let ``ObjectListFilter works with EqualsCI operator upper case`` () = [] let ``ObjectListFilter works with StartsWithCI operator`` () = - let filter = StartsWithCI { FieldName = "firstName"; Value = "j" } + let filter = StartsWith ({ FieldName = "firstName"; Value = "j" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -560,7 +560,7 @@ let ``ObjectListFilter works with StartsWithCI operator`` () = [] let ``ObjectListFilter works with EndsWithCI operator`` () = - let filter = EndsWithCI { FieldName = "lastName"; Value = "AMS" } + let filter = EndsWith ({ FieldName = "lastName"; Value = "AMS" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -573,7 +573,7 @@ let ``ObjectListFilter works with EndsWithCI operator`` () = [] let ``ObjectListFilter works with ContainsCI operator`` () = - let filter = ContainsCI { FieldName = "firstName"; Value = "EN" } + let filter = Contains ({ FieldName = "firstName"; Value = "EN" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 2 @@ -587,7 +587,7 @@ let ``ObjectListFilter works with ContainsCI operator`` () = [] let ``ObjectListFilter case-insensitive operators do not match with case-sensitive filters`` () = // Exact case-sensitive StartsWith "j" (lowercase) should match nothing in the data - let filter = StartsWith { FieldName = "firstName"; Value = "j" } + let filter = StartsWith ({ FieldName = "firstName"; Value = "j" }, null) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 0 From f10e0a16c77205ee3e0e337c3bde17e1d3f257bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:58:53 +0000 Subject: [PATCH 03/25] Add type annotations to CI operators; clarify suffix comment Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs | 4 ++-- .../SchemaDefinitions.fs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index d156838a..936da00e 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -132,10 +132,10 @@ module ObjectListFilter = 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 = StartsWith ({ FieldName = fname; Value = value }, StringComparer.OrdinalIgnoreCase) + 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 = EndsWith ({ FieldName = fname; Value = value }, StringComparer.OrdinalIgnoreCase) + 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) diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index 51ef0ec7..b6bd478b 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -21,8 +21,8 @@ type private ComparisonOperator = | In of string // String filter suffixes: -// lowercase → case-insensitive (OrdinalIgnoreCase) -// Capitalized / UPPER → case-sensitive (Ordinal) +// lowercase (e.g. _ends_with, _ew) → case-insensitive (OrdinalIgnoreCase) +// Capitalized (e.g. _Ends_With, _EW) → case-sensitive (Ordinal) [] let private endsWithSuffix = "_ends_with" From 2ba0051785a745e2a4a30bb08f38a1f1703b4fd6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:12:10 +0000 Subject: [PATCH 04/25] Remove method pairs; use only StringComparison overloads for string operations Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ObjectListFilter.fs | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index 936da00e..01a9df70 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -157,14 +157,11 @@ module ObjectListFilter = let private stringType = typeof let private genericIEnumerableType = typedefof> - 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 - 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 |]) + 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) @@ -232,6 +229,7 @@ module ObjectListFilter = 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 @@ -261,7 +259,7 @@ module ObjectListFilter = 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), Expression.Constant comparison)) :> Expression + Expression.Not (Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsMethod, Expression.Constant (value, typeof), Expression.Constant comparison)) :> Expression | ValueNone -> let hasEqualityOperator = hasEqualityOperator ``member``.Type match f.Value with @@ -280,7 +278,7 @@ module ObjectListFilter = match comparerToStringComparison comparer with | ValueSome comparison -> let value = Helpers.unwrap (box f.Value) :?> string - Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsWithComparisonMethod, Expression.Constant (value, typeof), Expression.Constant comparison) :> Expression + Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsMethod, Expression.Constant (value, typeof), Expression.Constant comparison) :> Expression | ValueNone -> let hasEqualityOperator = hasEqualityOperator ``member``.Type match f.Value with @@ -317,18 +315,12 @@ module ObjectListFilter = | NonEnumerableCast ``type`` -> Expression.LessThanOrEqual ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) | StartsWith (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - 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) + let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.Ordinal + Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) | EndsWith (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - 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) + let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.Ordinal + Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) | Contains (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) @@ -366,11 +358,8 @@ module ObjectListFilter = | :? FieldInfo as field when field.FieldType |> isEnumerable -> callContains field.FieldType | _ -> let unwrappedValue = Helpers.unwrap f.Value - match comparerToStringComparison comparer with - | ValueSome comparison -> - Expression.Call (normalizeStringMemberExpr ``member``, StringContainsWithComparisonMethod, Expression.Constant (unwrappedValue :?> string, typeof), Expression.Constant comparison) - | ValueNone -> - Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant unwrappedValue) + let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.Ordinal + Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant (unwrappedValue :?> string, typeof), Expression.Constant comparison) | In f when not (f.Value.IsEmpty) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) let enumerableContains = getEnumerableContainsMethod objectType From 4db2715188591c27ede219bcb2b91aa487561b3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:18:55 +0000 Subject: [PATCH 05/25] Change StartsWith/EndsWith to use StringComparer; open System.Collections at top Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ObjectListFilter.fs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index 01a9df70..a5499e0e 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -1,6 +1,7 @@ namespace FSharp.Data.GraphQL.Server.Middleware open System +open System.Collections open FSharp.Data.GraphQL /// A filter definition for a field value. @@ -11,15 +12,15 @@ type ObjectListFilter = | And of ObjectListFilter * ObjectListFilter | Or of ObjectListFilter * ObjectListFilter | Not of ObjectListFilter - | Equals of Filter : FieldFilter * Comparer : System.Collections.IComparer + | Equals of Filter : FieldFilter * Comparer : IComparer | GreaterThan of FieldFilter | GreaterThanOrEqual of FieldFilter | LessThan of FieldFilter | LessThanOrEqual of FieldFilter | In of FieldFilter - | StartsWith of Filter : FieldFilter * Comparer : System.Collections.IComparer - | EndsWith of Filter : FieldFilter * Comparer : System.Collections.IComparer - | Contains of Filter : FieldFilter * Comparer : System.Collections.IComparer + | StartsWith of Filter : FieldFilter * Comparer : StringComparer + | EndsWith of Filter : FieldFilter * Comparer : StringComparer + | Contains of Filter : FieldFilter * Comparer : IComparer | OfTypes of Type list | FilterField of FieldFilter @@ -28,7 +29,6 @@ open System.Linq.Expressions open System.Runtime.InteropServices open System.Reflection open System.Collections.Generic -open System.Collections type private CompareDiscriminatorExpression<'T, 'D> = Expression> @@ -315,11 +315,11 @@ module ObjectListFilter = | NonEnumerableCast ``type`` -> Expression.LessThanOrEqual ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) | StartsWith (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.Ordinal + let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.Ordinal Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) | EndsWith (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.Ordinal + let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.Ordinal Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) | Contains (f, comparer) -> From c3eccdee68c0ddc1b03568c3a32a491a0bc2298a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:22:25 +0000 Subject: [PATCH 06/25] Rename CI test names to use descriptive "case insensitive" phrasing Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ObjectListFilterLinqTests.fs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs index 33ad42c8..55e3fabe 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs @@ -528,7 +528,7 @@ let ``ObjectListFilter OfTypes works with two or more types`` () = animal.Name |> equals "Horse D" [] -let ``ObjectListFilter works with EqualsCI operator`` () = +let ``ObjectListFilter works with Equals case insensitive operator`` () = let filter = Equals ({ FieldName = "firstName"; Value = "jonathan" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList @@ -539,7 +539,7 @@ let ``ObjectListFilter works with EqualsCI operator`` () = result.LastName |> equals "Abrams" [] -let ``ObjectListFilter works with EqualsCI operator upper case`` () = +let ``ObjectListFilter works with Equals case insensitive operator upper case`` () = let filter = Equals ({ FieldName = "firstName"; Value = "JONATHAN" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList @@ -549,7 +549,7 @@ let ``ObjectListFilter works with EqualsCI operator upper case`` () = result.FirstName |> equals "Jonathan" [] -let ``ObjectListFilter works with StartsWithCI operator`` () = +let ``ObjectListFilter works with StartsWith case insensitive operator`` () = let filter = StartsWith ({ FieldName = "firstName"; Value = "j" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList @@ -559,7 +559,7 @@ let ``ObjectListFilter works with StartsWithCI operator`` () = result.FirstName |> equals "Jonathan" [] -let ``ObjectListFilter works with EndsWithCI operator`` () = +let ``ObjectListFilter works with EndsWith case insensitive operator`` () = let filter = EndsWith ({ FieldName = "lastName"; Value = "AMS" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList @@ -572,7 +572,7 @@ let ``ObjectListFilter works with EndsWithCI operator`` () = result.LastName |> equals "Abrams" [] -let ``ObjectListFilter works with ContainsCI operator`` () = +let ``ObjectListFilter works with Contains case insensitive operator`` () = let filter = Contains ({ FieldName = "firstName"; Value = "EN" }, StringComparer.OrdinalIgnoreCase) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList From 9fa9659d92a649a8405f7fc0898588ca0d342712 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:31:02 +0000 Subject: [PATCH 07/25] Add paired case-sensitive/case-insensitive tests to ObjectListFilter test files Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ObjectListFilterLinqGenerateTests.fs | 24 ++++++++++++++ .../ObjectListFilterLinqTests.fs | 33 +++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs index d4c98192..c8a39200 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs @@ -196,6 +196,14 @@ let ``ObjectListFilter works with StartsWith operator for ValidStringStruct`` () let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE STARTSWITH(root["validStringStruct"], "J")""" +[] +let ``ObjectListFilter works with StartsWith case insensitive operator for ValidStringStruct`` () = + let queryable = container.GetItemLinqQueryable () + let filter = StartsWith ({ FieldName = "validStringStruct"; Value = "J" }, StringComparer.OrdinalIgnoreCase) + let filterQuery = queryable.Apply (filter, filterOptions) + let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery + equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE STARTSWITH(root["validStringStruct"], "J", true)""" + [] let ``ObjectListFilter works with EndsWith operator for ValidStringStruct`` () = let queryable = container.GetItemLinqQueryable () @@ -204,6 +212,14 @@ let ``ObjectListFilter works with EndsWith operator for ValidStringStruct`` () = let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE ENDSWITH(root["validStringStruct"], "n")""" +[] +let ``ObjectListFilter works with EndsWith case insensitive operator for ValidStringStruct`` () = + let queryable = container.GetItemLinqQueryable () + let filter = EndsWith ({ FieldName = "validStringStruct"; Value = "n" }, StringComparer.OrdinalIgnoreCase) + let filterQuery = queryable.Apply (filter, filterOptions) + let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery + equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE ENDSWITH(root["validStringStruct"], "n", true)""" + [] let ``ObjectListFilter works with Contains operator for ValidStringStruct`` () = let queryable = container.GetItemLinqQueryable () @@ -212,6 +228,14 @@ let ``ObjectListFilter works with Contains operator for ValidStringStruct`` () = let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE CONTAINS(root["validStringStruct"], "athan")""" +[] +let ``ObjectListFilter works with Contains case insensitive operator for ValidStringStruct`` () = + let queryable = container.GetItemLinqQueryable () + let filter = Contains ({ FieldName = "validStringStruct"; Value = "athan" }, StringComparer.OrdinalIgnoreCase) + let filterQuery = queryable.Apply (filter, filterOptions) + let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery + equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE CONTAINS(root["validStringStruct"], "athan", true)""" + [] let ``ObjectListFilter works with Contains operator for ValidStringStruct list`` () = let queryable = container.GetItemLinqQueryable () diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs index 55e3fabe..bdd14b70 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs @@ -538,6 +538,13 @@ let ``ObjectListFilter works with Equals case insensitive operator`` () = result.FirstName |> equals "Jonathan" result.LastName |> equals "Abrams" +[] +let ``ObjectListFilter works with Equals case sensitive operator`` () = + let filter = Equals ({ FieldName = "firstName"; Value = "jonathan" }, null) + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 0 + [] let ``ObjectListFilter works with Equals case insensitive operator upper case`` () = let filter = Equals ({ FieldName = "firstName"; Value = "JONATHAN" }, StringComparer.OrdinalIgnoreCase) @@ -548,6 +555,13 @@ let ``ObjectListFilter works with Equals case insensitive operator upper case`` result.ID |> equals 2 result.FirstName |> equals "Jonathan" +[] +let ``ObjectListFilter works with Equals case sensitive operator upper case`` () = + let filter = Equals ({ FieldName = "firstName"; Value = "JONATHAN" }, null) + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 0 + [] let ``ObjectListFilter works with StartsWith case insensitive operator`` () = let filter = StartsWith ({ FieldName = "firstName"; Value = "j" }, StringComparer.OrdinalIgnoreCase) @@ -558,6 +572,13 @@ let ``ObjectListFilter works with StartsWith case insensitive operator`` () = result.ID |> equals 2 result.FirstName |> equals "Jonathan" +[] +let ``ObjectListFilter works with StartsWith case sensitive operator`` () = + let filter = StartsWith ({ FieldName = "firstName"; Value = "j" }, null) + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 0 + [] let ``ObjectListFilter works with EndsWith case insensitive operator`` () = let filter = EndsWith ({ FieldName = "lastName"; Value = "AMS" }, StringComparer.OrdinalIgnoreCase) @@ -571,6 +592,13 @@ let ``ObjectListFilter works with EndsWith case insensitive operator`` () = result.ID |> equals 2 result.LastName |> equals "Abrams" +[] +let ``ObjectListFilter works with EndsWith case sensitive operator`` () = + let filter = EndsWith ({ FieldName = "lastName"; Value = "AMS" }, null) + let queryable = data.AsQueryable () + let filteredData = queryable.Apply (filter) |> Seq.toList + List.length filteredData |> equals 0 + [] let ``ObjectListFilter works with Contains case insensitive operator`` () = let filter = Contains ({ FieldName = "firstName"; Value = "EN" }, StringComparer.OrdinalIgnoreCase) @@ -585,9 +613,8 @@ let ``ObjectListFilter works with Contains case insensitive operator`` () = result.FirstName |> equals "Jeneffer" [] -let ``ObjectListFilter case-insensitive operators do not match with case-sensitive filters`` () = - // Exact case-sensitive StartsWith "j" (lowercase) should match nothing in the data - let filter = StartsWith ({ FieldName = "firstName"; Value = "j" }, null) +let ``ObjectListFilter works with Contains case sensitive operator`` () = + let filter = Contains ({ FieldName = "firstName"; Value = "EN" }, null) let queryable = data.AsQueryable () let filteredData = queryable.Apply (filter) |> Seq.toList List.length filteredData |> equals 0 From 9585b19331ac940e1726b09948bb107dd64ac47e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:41:23 +0000 Subject: [PATCH 08/25] Extract filter suffix constants to FilterSuffixConstants.fs Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- ...harp.Data.GraphQL.Server.Middleware.fsproj | 1 + .../FilterSuffixConstants.fs | 67 ++++++++++ .../SchemaDefinitions.fs | 117 ++++-------------- 3 files changed, 91 insertions(+), 94 deletions(-) create mode 100644 src/FSharp.Data.GraphQL.Server.Middleware/FilterSuffixConstants.fs diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj b/src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj index 09357bf2..9eddcf46 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj +++ b/src/FSharp.Data.GraphQL.Server.Middleware/FSharp.Data.GraphQL.Server.Middleware.fsproj @@ -21,6 +21,7 @@ + diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/FilterSuffixConstants.fs b/src/FSharp.Data.GraphQL.Server.Middleware/FilterSuffixConstants.fs new file mode 100644 index 00000000..0f6bc905 --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.Middleware/FilterSuffixConstants.fs @@ -0,0 +1,67 @@ +/// +/// String filter suffixes: +/// lowercase (e.g. _ends_with, _ew) → case-insensitive (OrdinalIgnoreCase) +/// Capitalized (e.g. _Ends_With, _EW) → case-sensitive (Ordinal) +/// +/// The submodule contains lowercase suffixes that map to case-insensitive string comparisons. +/// The 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. +/// +/// +[] +module FSharp.Data.GraphQL.Server.Middleware.FilterSuffixConstants + +// Numeric/comparison operators +[] +let GreaterThanOrEqualSuffix = "_greater_than_or_equal" +[] +let GTESuffix = "_gte" +[] +let GreaterThanSuffix = "_greater_than" +[] +let GTSuffix = "_gt" +[] +let LessThanOrEqualSuffix = "_less_than_or_equal" +[] +let LTESuffix = "_lte" +[] +let LessThanSuffix = "_less_than" +[] +let LTSuffix = "_lt" +[] +let InSuffix = "_in" + +/// Case-insensitive string operators (lowercase suffixes → OrdinalIgnoreCase) +module CI = + // String operators (case-insensitive) + [] + let EndsWithSuffix = "_ends_with" + [] + let EWSuffix = "_ew" + [] + let StartsWithSuffix = "_starts_with" + [] + let SWSuffix = "_sw" + [] + let ContainsSuffix = "_contains" + [] + let EqualsSuffix = "_equals" + [] + let EQSuffix = "_eq" + +/// Case-sensitive string operators +module CS = + [] + let EndsWithSuffix = "_Ends_With" + [] + let EWSuffix = "_EW" + [] + let StartsWithSuffix = "_Starts_With" + [] + let SWSuffix = "_SW" + [] + let ContainsSuffix = "_Contains" + [] + let EqualsSuffix = "_Equals" + [] + let EQSuffix = "_EQ" diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index b6bd478b..8b505f12 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -20,77 +20,6 @@ type private ComparisonOperator = | LessThanOrEqual of string | In of string -// String filter suffixes: -// lowercase (e.g. _ends_with, _ew) → case-insensitive (OrdinalIgnoreCase) -// Capitalized (e.g. _Ends_With, _EW) → case-sensitive (Ordinal) -[] -let private endsWithSuffix = "_ends_with" - -[] -let private ewSuffix = "_ew" - -[] -let private EndsWithCSSuffix = "_Ends_With" - -[] -let private EWCSSuffix = "_EW" - -[] -let private startsWithSuffix = "_starts_with" - -[] -let private swSuffix = "_sw" - -[] -let private StartsWithCSSuffix = "_Starts_With" - -[] -let private SWCSSuffix = "_SW" - -[] -let private containsSuffix = "_contains" - -[] -let private ContainsCSSuffix = "_Contains" - -[] -let private equalsSuffix = "_equals" - -[] -let private eqSuffix = "_eq" - -[] -let private EqualsCSSuffix = "_Equals" - -[] -let private EQCSSuffix = "_EQ" - -[] -let private greaterThanOrEqualSuffix = "_greater_than_or_equal" - -[] -let private gteSuffix = "_gte" - -[] -let private greaterThanSuffix = "_greater_than" - -[] -let private gtSuffix = "_gt" - -[] -let private lessThanOrEqualSuffix = "_less_than_or_equal" - -[] -let private lteSuffix = "_lte" - -[] -let private lessThanSuffix = "_less_than" - -[] -let private ltSuffix = "_lt" - -[] -let private inSuffix = "_in" let rec private coerceObjectListFilterInput (variables : Variables) inputValue : Result = @@ -98,33 +27,33 @@ let rec private coerceObjectListFilterInput (variables : Variables) inputValue : let prefix (suffix : string) (s : string) = s.Substring (0, s.Length - suffix.Length) // Phase 1: case-sensitive string ops – match original string against capitalized/uppercase suffixes match s with - | s when s.EndsWith EndsWithCSSuffix && s.Length > EndsWithCSSuffix.Length -> EndsWith (prefix EndsWithCSSuffix s, StringComparer.Ordinal) - | s when s.EndsWith EWCSSuffix && s.Length > EWCSSuffix.Length -> EndsWith (prefix EWCSSuffix s, StringComparer.Ordinal) - | s when s.EndsWith StartsWithCSSuffix && s.Length > StartsWithCSSuffix.Length -> StartsWith (prefix StartsWithCSSuffix s, StringComparer.Ordinal) - | s when s.EndsWith SWCSSuffix && s.Length > SWCSSuffix.Length -> StartsWith (prefix SWCSSuffix s, StringComparer.Ordinal) - | s when s.EndsWith ContainsCSSuffix && s.Length > ContainsCSSuffix.Length -> Contains (prefix ContainsCSSuffix s, StringComparer.Ordinal) - | s when s.EndsWith EqualsCSSuffix && s.Length > EqualsCSSuffix.Length -> StringEquals (prefix EqualsCSSuffix s, StringComparer.Ordinal) - | s when s.EndsWith EQCSSuffix && s.Length > EQCSSuffix.Length -> StringEquals (prefix EQCSSuffix s, StringComparer.Ordinal) + | s when s.EndsWith FilterSuffixConstants.CS.EndsWithSuffix && s.Length > FilterSuffixConstants.CS.EndsWithSuffix.Length -> EndsWith (prefix FilterSuffixConstants.CS.EndsWithSuffix s, StringComparer.Ordinal) + | s when s.EndsWith FilterSuffixConstants.CS.EWSuffix && s.Length > FilterSuffixConstants.CS.EWSuffix.Length -> EndsWith (prefix FilterSuffixConstants.CS.EWSuffix s, StringComparer.Ordinal) + | s when s.EndsWith FilterSuffixConstants.CS.StartsWithSuffix && s.Length > FilterSuffixConstants.CS.StartsWithSuffix.Length -> StartsWith (prefix FilterSuffixConstants.CS.StartsWithSuffix s, StringComparer.Ordinal) + | s when s.EndsWith FilterSuffixConstants.CS.SWSuffix && s.Length > FilterSuffixConstants.CS.SWSuffix.Length -> StartsWith (prefix FilterSuffixConstants.CS.SWSuffix s, StringComparer.Ordinal) + | s when s.EndsWith FilterSuffixConstants.CS.ContainsSuffix && s.Length > FilterSuffixConstants.CS.ContainsSuffix.Length -> Contains (prefix FilterSuffixConstants.CS.ContainsSuffix s, StringComparer.Ordinal) + | s when s.EndsWith FilterSuffixConstants.CS.EqualsSuffix && s.Length > FilterSuffixConstants.CS.EqualsSuffix.Length -> StringEquals (prefix FilterSuffixConstants.CS.EqualsSuffix s, StringComparer.Ordinal) + | s when s.EndsWith FilterSuffixConstants.CS.EQSuffix && s.Length > FilterSuffixConstants.CS.EQSuffix.Length -> StringEquals (prefix FilterSuffixConstants.CS.EQSuffix s, StringComparer.Ordinal) | _ -> // Phase 2: case-insensitive string ops and numeric ops – lower-case before matching let s = s.ToLowerInvariant () match s with - | s when s.EndsWith endsWithSuffix && s.Length > endsWithSuffix.Length -> EndsWith (prefix endsWithSuffix s, StringComparer.OrdinalIgnoreCase) - | s when s.EndsWith ewSuffix && s.Length > ewSuffix.Length -> EndsWith (prefix ewSuffix s, StringComparer.OrdinalIgnoreCase) - | s when s.EndsWith startsWithSuffix && s.Length > startsWithSuffix.Length -> StartsWith (prefix startsWithSuffix s, StringComparer.OrdinalIgnoreCase) - | s when s.EndsWith swSuffix && s.Length > swSuffix.Length -> StartsWith (prefix swSuffix s, StringComparer.OrdinalIgnoreCase) - | s when s.EndsWith containsSuffix && s.Length > containsSuffix.Length -> Contains (prefix containsSuffix s, StringComparer.OrdinalIgnoreCase) - | s when s.EndsWith equalsSuffix && s.Length > equalsSuffix.Length -> StringEquals (prefix equalsSuffix s, StringComparer.OrdinalIgnoreCase) - | s when s.EndsWith eqSuffix && s.Length > eqSuffix.Length -> StringEquals (prefix eqSuffix s, StringComparer.OrdinalIgnoreCase) - | s when s.EndsWith greaterThanOrEqualSuffix && s.Length > greaterThanOrEqualSuffix.Length -> GreaterThanOrEqual (prefix greaterThanOrEqualSuffix s) - | s when s.EndsWith gteSuffix && s.Length > gteSuffix.Length -> GreaterThanOrEqual (prefix gteSuffix s) - | s when s.EndsWith greaterThanSuffix && s.Length > greaterThanSuffix.Length -> GreaterThan (prefix greaterThanSuffix s) - | s when s.EndsWith gtSuffix && s.Length > gtSuffix.Length -> GreaterThan (prefix gtSuffix s) - | s when s.EndsWith lessThanOrEqualSuffix && s.Length > lessThanOrEqualSuffix.Length -> LessThanOrEqual (prefix lessThanOrEqualSuffix s) - | s when s.EndsWith lteSuffix && s.Length > lteSuffix.Length -> LessThanOrEqual (prefix lteSuffix s) - | s when s.EndsWith lessThanSuffix && s.Length > lessThanSuffix.Length -> LessThan (prefix lessThanSuffix s) - | s when s.EndsWith ltSuffix && s.Length > ltSuffix.Length -> LessThan (prefix ltSuffix s) - | s when s.EndsWith inSuffix && s.Length > inSuffix.Length -> In (prefix inSuffix s) + | s when s.EndsWith FilterSuffixConstants.CI.EndsWithSuffix && s.Length > FilterSuffixConstants.CI.EndsWithSuffix.Length -> EndsWith (prefix FilterSuffixConstants.CI.EndsWithSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.EWSuffix && s.Length > FilterSuffixConstants.CI.EWSuffix.Length -> EndsWith (prefix FilterSuffixConstants.CI.EWSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.StartsWithSuffix && s.Length > FilterSuffixConstants.CI.StartsWithSuffix.Length -> StartsWith (prefix FilterSuffixConstants.CI.StartsWithSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.SWSuffix && s.Length > FilterSuffixConstants.CI.SWSuffix.Length -> StartsWith (prefix FilterSuffixConstants.CI.SWSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.ContainsSuffix && s.Length > FilterSuffixConstants.CI.ContainsSuffix.Length -> Contains (prefix FilterSuffixConstants.CI.ContainsSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.EqualsSuffix && s.Length > FilterSuffixConstants.CI.EqualsSuffix.Length -> StringEquals (prefix FilterSuffixConstants.CI.EqualsSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.EQSuffix && s.Length > FilterSuffixConstants.CI.EQSuffix.Length -> StringEquals (prefix FilterSuffixConstants.CI.EQSuffix s, StringComparer.OrdinalIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.GreaterThanOrEqualSuffix && s.Length > FilterSuffixConstants.GreaterThanOrEqualSuffix.Length -> GreaterThanOrEqual (prefix FilterSuffixConstants.GreaterThanOrEqualSuffix s) + | s when s.EndsWith FilterSuffixConstants.GTESuffix && s.Length > FilterSuffixConstants.GTESuffix.Length -> GreaterThanOrEqual (prefix FilterSuffixConstants.GTESuffix s) + | s when s.EndsWith FilterSuffixConstants.GreaterThanSuffix && s.Length > FilterSuffixConstants.GreaterThanSuffix.Length -> GreaterThan (prefix FilterSuffixConstants.GreaterThanSuffix s) + | s when s.EndsWith FilterSuffixConstants.GTSuffix && s.Length > FilterSuffixConstants.GTSuffix.Length -> GreaterThan (prefix FilterSuffixConstants.GTSuffix s) + | s when s.EndsWith FilterSuffixConstants.LessThanOrEqualSuffix && s.Length > FilterSuffixConstants.LessThanOrEqualSuffix.Length -> LessThanOrEqual (prefix FilterSuffixConstants.LessThanOrEqualSuffix s) + | s when s.EndsWith FilterSuffixConstants.LTESuffix && s.Length > FilterSuffixConstants.LTESuffix.Length -> LessThanOrEqual (prefix FilterSuffixConstants.LTESuffix s) + | s when s.EndsWith FilterSuffixConstants.LessThanSuffix && s.Length > FilterSuffixConstants.LessThanSuffix.Length -> LessThan (prefix FilterSuffixConstants.LessThanSuffix s) + | s when s.EndsWith FilterSuffixConstants.LTSuffix && s.Length > FilterSuffixConstants.LTSuffix.Length -> LessThan (prefix FilterSuffixConstants.LTSuffix s) + | s when s.EndsWith FilterSuffixConstants.InSuffix && s.Length > FilterSuffixConstants.InSuffix.Length -> In (prefix FilterSuffixConstants.InSuffix s) | s -> Equals s let (|EquatableValue|NonEquatableValue|) v = From d9a81b7d3ef9e5216afa9a61a88140f88c5b2b71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:54:44 +0000 Subject: [PATCH 09/25] docs: clarify ObjectListFilter suffix behavior Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 14 ++++++++++---- .../ObjectListFilter.fs | 9 +++++++++ .../SchemaDefinitions.fs | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 39fd14c8..0267c6c7 100644 --- a/README.md +++ b/README.md @@ -361,6 +361,8 @@ query TestQuery { } ``` +For string filters, lowercase suffixes are case-insensitive (`name_starts_with`, `name_sw`, `name_equals`, `name_eq`), while capitalized suffixes are case-sensitive (`name_Starts_With`, `name_SW`, `name_Equals`, `name_EQ`). + Also you can apply `not` operator like this: ```graphql @@ -406,12 +408,16 @@ type ObjectListFilter = | And of ObjectListFilter * ObjectListFilter | Or of ObjectListFilter * ObjectListFilter | Not of ObjectListFilter - | Equals of FieldFilter + | Equals of Filter : FieldFilter * Comparer : System.Collections.IComparer | GreaterThan of FieldFilter + | GreaterThanOrEqual of FieldFilter | LessThan of FieldFilter - | StartsWith of FieldFilter - | EndsWith of FieldFilter - | Contains of FieldFilter + | LessThanOrEqual of FieldFilter + | In of FieldFilter + | StartsWith of Filter : FieldFilter * Comparer : StringComparer + | EndsWith of Filter : FieldFilter * Comparer : StringComparer + | Contains of Filter : FieldFilter * Comparer : System.Collections.IComparer + | OfTypes of Type list | FilterField of FieldFilter ``` diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index a5499e0e..92ea257f 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -7,7 +7,16 @@ open FSharp.Data.GraphQL /// A filter definition for a field value. type FieldFilter<'Val> = { FieldName : string; Value : 'Val } +/// /// A filter definition for an object list. +/// +/// +/// String-based filters can carry a comparer. and +/// represent case-sensitive matching, while +/// 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. +/// type ObjectListFilter = | And of ObjectListFilter * ObjectListFilter | Or of ObjectListFilter * ObjectListFilter diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index 8b505f12..aaa93cee 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -181,7 +181,7 @@ let ObjectListFilterType : InputCustomDefinition = { Name = "ObjectListFilter" Description = Some - "The `Filter` scalar type represents a filter on one or more fields of an object in an object list. The filter is represented by a JSON object where the fields are the complemented by specific suffixes to represent a query." + "The `ObjectListFilter` input represents field filters for object lists. Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive. Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive. Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported." CoerceInput = (fun _ input variables -> match input with From 37d7d02e1cb9622a31ff7f027cb65792d8919b51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:55:27 +0000 Subject: [PATCH 10/25] docs: clarify ObjectListFilter comparer notes Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 2 ++ src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0267c6c7..10713883 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,8 @@ type ObjectListFilter = | FilterField of FieldFilter ``` +`Equals` and `Contains` keep `System.Collections.IComparer` because they also support non-string comparable values. For string comparisons, pass `StringComparer.Ordinal` (or `null`) for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. + And the value recovered by the filter in the query is usable in the `ResolveFieldContext` of the resolve function of the field. To easily access it, you can use the extension method `Filter`, which returns an `ObjectListFilter voption` (it does not have a value if the object doesn't implement a list with the middleware generic definition, or if the user didn't provide a filter input). ```fsharp diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index 92ea257f..0ae18628 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -11,8 +11,8 @@ type FieldFilter<'Val> = { FieldName : string; Value : 'Val } /// A filter definition for an object list. /// /// -/// String-based filters can carry a comparer. and -/// represent case-sensitive matching, while +/// String-based filters can carry a comparer. is treated the same as +/// for case-sensitive matching, while /// 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. From 9ec7da01331f7c6879f46f99c86870c1cf1bef06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:55:58 +0000 Subject: [PATCH 11/25] docs: use F# XML doc conventions Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ObjectListFilter.fs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index 0ae18628..46f07c4d 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -11,9 +11,9 @@ type FieldFilter<'Val> = { FieldName : string; Value : 'Val } /// A filter definition for an object list. /// /// -/// String-based filters can carry a comparer. is treated the same as -/// for case-sensitive matching, while -/// enables case-insensitive matching. +/// String-based filters can carry a comparer. `null` is treated the same as +/// `StringComparer.Ordinal` for case-sensitive matching, while +/// `StringComparer.OrdinalIgnoreCase` 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. /// From 880f976f86d3ae191e314643b3821aecfb76f32a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:57:00 +0000 Subject: [PATCH 12/25] docs: simplify ObjectListFilter examples Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 8 ++++---- .../SchemaDefinitions.fs | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 10713883..386eb15d 100644 --- a/README.md +++ b/README.md @@ -408,15 +408,15 @@ type ObjectListFilter = | And of ObjectListFilter * ObjectListFilter | Or of ObjectListFilter * ObjectListFilter | Not of ObjectListFilter - | Equals of Filter : FieldFilter * Comparer : System.Collections.IComparer + | Equals of FieldFilter * System.Collections.IComparer | GreaterThan of FieldFilter | GreaterThanOrEqual of FieldFilter | LessThan of FieldFilter | LessThanOrEqual of FieldFilter | In of FieldFilter - | StartsWith of Filter : FieldFilter * Comparer : StringComparer - | EndsWith of Filter : FieldFilter * Comparer : StringComparer - | Contains of Filter : FieldFilter * Comparer : System.Collections.IComparer + | StartsWith of FieldFilter * StringComparer + | EndsWith of FieldFilter * StringComparer + | Contains of FieldFilter * System.Collections.IComparer | OfTypes of Type list | FilterField of FieldFilter ``` diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index aaa93cee..fcb713e9 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -181,7 +181,10 @@ let ObjectListFilterType : InputCustomDefinition = { Name = "ObjectListFilter" Description = Some - "The `ObjectListFilter` input represents field filters for object lists. Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive. Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive. Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported." + ("The `ObjectListFilter` input represents field filters for object lists. " + + "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive. " + + "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive. " + + "Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported.") CoerceInput = (fun _ input variables -> match input with From adf6265e7eb0372d4512353ebe0415fb753e4b51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:57:39 +0000 Subject: [PATCH 13/25] docs: clarify comparer types in README Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 386eb15d..d2411eb4 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,7 @@ type ObjectListFilter = | FilterField of FieldFilter ``` -`Equals` and `Contains` keep `System.Collections.IComparer` because they also support non-string comparable values. For string comparisons, pass `StringComparer.Ordinal` (or `null`) for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. +`Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public DU. When the filtered field is a string, pass `StringComparer.Ordinal` (or `null`) for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. `Contains` is also used for collection membership checks, so its value type stays `System.IComparable` instead of being limited to `string`. And the value recovered by the filter in the query is usable in the `ResolveFieldContext` of the resolve function of the field. To easily access it, you can use the extension method `Filter`, which returns an `ObjectListFilter voption` (it does not have a value if the object doesn't implement a list with the middleware generic definition, or if the user didn't provide a filter input). From 4b2845e9156fc0164dfca9a5adcf8145cb5d22e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:58:25 +0000 Subject: [PATCH 14/25] docs: add ObjectListFilter migration note Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 4 +++- src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d2411eb4..3476ac78 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,9 @@ type ObjectListFilter = | FilterField of FieldFilter ``` -`Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public DU. When the filtered field is a string, pass `StringComparer.Ordinal` (or `null`) for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. `Contains` is also used for collection membership checks, so its value type stays `System.IComparable` instead of being limited to `string`. +`Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public DU. When the filtered field is a string and you construct the DU cases directly, pass `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. `Contains` is also used for collection membership checks, so its value type stays `System.IComparable` instead of being limited to `string`. + +If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. And the value recovered by the filter in the query is usable in the `ResolveFieldContext` of the resolve function of the field. To easily access it, you can use the extension method `Filter`, which returns an `ObjectListFilter voption` (it does not have a value if the object doesn't implement a list with the middleware generic definition, or if the user didn't provide a filter input). diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index 46f07c4d..ce2a55eb 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -11,8 +11,8 @@ type FieldFilter<'Val> = { FieldName : string; Value : 'Val } /// A filter definition for an object list. /// /// -/// String-based filters can carry a comparer. `null` is treated the same as -/// `StringComparer.Ordinal` for case-sensitive matching, while +/// String-based filters can carry a comparer. When the comparer is omitted internally by the +/// default case-sensitive operators, that value is handled the same as `StringComparer.Ordinal`. /// `StringComparer.OrdinalIgnoreCase` 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. From 67110b164600c13142ae3abc11fc2de13a8037fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:59:25 +0000 Subject: [PATCH 15/25] docs: polish ObjectListFilter notes Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 8 +++++--- .../SchemaDefinitions.fs | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3476ac78..9b0a1190 100644 --- a/README.md +++ b/README.md @@ -421,9 +421,11 @@ type ObjectListFilter = | FilterField of FieldFilter ``` -`Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public DU. When the filtered field is a string and you construct the DU cases directly, pass `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. `Contains` is also used for collection membership checks, so its value type stays `System.IComparable` instead of being limited to `string`. - -If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. +- `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public DU. +- When the filtered field is a string and you construct the DU cases directly, pass `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. +- The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. +- `Contains` is also used for collection membership checks, so its value type stays `System.IComparable` instead of being limited to `string`. +- If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. And the value recovered by the filter in the query is usable in the `ResolveFieldContext` of the resolve function of the field. To easily access it, you can use the extension method `Filter`, which returns an `ObjectListFilter voption` (it does not have a value if the object doesn't implement a list with the middleware generic definition, or if the user didn't provide a filter input). diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index fcb713e9..20bf8199 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -181,10 +181,14 @@ let ObjectListFilterType : InputCustomDefinition = { Name = "ObjectListFilter" Description = Some - ("The `ObjectListFilter` input represents field filters for object lists. " - + "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive. " - + "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive. " - + "Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported.") + (String.concat + " " + [ + "The `ObjectListFilter` input represents field filters for object lists." + "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive." + "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive." + "Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported." + ]) CoerceInput = (fun _ input variables -> match input with From 8cd78aef25efb050dee3e5ef516a1689317629f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:00:45 +0000 Subject: [PATCH 16/25] docs: clarify ObjectListFilter wording Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 4 ++-- .../SchemaDefinitions.fs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9b0a1190..3945c589 100644 --- a/README.md +++ b/README.md @@ -422,9 +422,9 @@ type ObjectListFilter = ``` - `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public DU. -- When the filtered field is a string and you construct the DU cases directly, pass `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. +- When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot: `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. - The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. -- `Contains` is also used for collection membership checks, so its value type stays `System.IComparable` instead of being limited to `string`. +- `Contains` is also used for collection membership checks, so the wrapped value type inside `FieldFilter<_>` stays `System.IComparable` instead of being limited to `string`. - If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. And the value recovered by the filter in the query is usable in the `ResolveFieldContext` of the resolve function of the field. To easily access it, you can use the extension method `Filter`, which returns an `ObjectListFilter voption` (it does not have a value if the object doesn't implement a list with the middleware generic definition, or if the user didn't provide a filter input). diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index 20bf8199..53c1c0cc 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -185,8 +185,8 @@ let ObjectListFilterType : InputCustomDefinition = { " " [ "The `ObjectListFilter` input represents field filters for object lists." - "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive." - "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive." + "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive when applied to string fields." + "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive when applied to string fields." "Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported." ]) CoerceInput = From d6a87ae82db4dadd95a2b7c5a4794fe71187d340 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:01:29 +0000 Subject: [PATCH 17/25] docs: simplify ObjectListFilter overview Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 29 ++++++++++--------- .../SchemaDefinitions.fs | 2 +- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3945c589..747edfbd 100644 --- a/README.md +++ b/README.md @@ -405,22 +405,23 @@ type FieldFilter<'Val> = Value : 'Val } type ObjectListFilter = - | And of ObjectListFilter * ObjectListFilter - | Or of ObjectListFilter * ObjectListFilter - | Not of ObjectListFilter - | Equals of FieldFilter * System.Collections.IComparer - | GreaterThan of FieldFilter - | GreaterThanOrEqual of FieldFilter - | LessThan of FieldFilter - | LessThanOrEqual of FieldFilter - | In of FieldFilter - | StartsWith of FieldFilter * StringComparer - | EndsWith of FieldFilter * StringComparer - | Contains of FieldFilter * System.Collections.IComparer - | OfTypes of Type list - | FilterField of FieldFilter + | And of ... + | Or of ... + | Not of ... + | Equals of ... + | GreaterThan of ... + | GreaterThanOrEqual of ... + | LessThan of ... + | LessThanOrEqual of ... + | In of ... + | StartsWith of ... + | EndsWith of ... + | Contains of ... + | OfTypes of ... + | FilterField of ... ``` +- `Equals`, `StartsWith`, `EndsWith`, and `Contains` now carry a comparer in the public DU. - `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public DU. - When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot: `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. - The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index 53c1c0cc..dae277e0 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -184,7 +184,7 @@ let ObjectListFilterType : InputCustomDefinition = { (String.concat " " [ - "The `ObjectListFilter` input represents field filters for object lists." + "The `ObjectListFilter` value represents field filters for object lists." "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive when applied to string fields." "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive when applied to string fields." "Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported." From daa682f622debdd1e9eb7671b5d007725927453c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:02:33 +0000 Subject: [PATCH 18/25] docs: rewrite ObjectListFilter README section Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 747edfbd..8f7ad796 100644 --- a/README.md +++ b/README.md @@ -397,32 +397,10 @@ query TestQuery { } ``` -This filter is mapped by the middleware inside an `ObjectListFilter` definition: +This filter is mapped by the middleware into the `ObjectListFilter` discriminated union, with cases such as `And`, `Or`, `Not`, `Equals`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `In`, `StartsWith`, `EndsWith`, `Contains`, `OfTypes`, and `FilterField`. -```fsharp -type FieldFilter<'Val> = - { FieldName : string - Value : 'Val } - -type ObjectListFilter = - | And of ... - | Or of ... - | Not of ... - | Equals of ... - | GreaterThan of ... - | GreaterThanOrEqual of ... - | LessThan of ... - | LessThanOrEqual of ... - | In of ... - | StartsWith of ... - | EndsWith of ... - | Contains of ... - | OfTypes of ... - | FilterField of ... -``` - -- `Equals`, `StartsWith`, `EndsWith`, and `Contains` now carry a comparer in the public DU. -- `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public DU. +- `Equals`, `StartsWith`, `EndsWith`, and `Contains` now carry a comparer in the public discriminated union. +- `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public discriminated union. - When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot: `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. - The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. - `Contains` is also used for collection membership checks, so the wrapped value type inside `FieldFilter<_>` stays `System.IComparable` instead of being limited to `string`. From e3b95b9364c4b1c60d68e94e0581d6b1719abe05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:03:29 +0000 Subject: [PATCH 19/25] docs: finalize ObjectListFilter clarifications Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 2 +- src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs | 4 ++-- .../SchemaDefinitions.fs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8f7ad796..68ad4bd7 100644 --- a/README.md +++ b/README.md @@ -401,7 +401,7 @@ This filter is mapped by the middleware into the `ObjectListFilter` discriminate - `Equals`, `StartsWith`, `EndsWith`, and `Contains` now carry a comparer in the public discriminated union. - `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public discriminated union. -- When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot: `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. +- When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot. `StringComparer` is accepted there because it also implements `System.Collections.IComparer`. Use `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. - The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. - `Contains` is also used for collection membership checks, so the wrapped value type inside `FieldFilter<_>` stays `System.IComparable` instead of being limited to `string`. - If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index ce2a55eb..9ca43f19 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -11,8 +11,8 @@ type FieldFilter<'Val> = { FieldName : string; Value : 'Val } /// A filter definition for an object list. /// /// -/// String-based filters can carry a comparer. When the comparer is omitted internally by the -/// default case-sensitive operators, that value is handled the same as `StringComparer.Ordinal`. +/// String-based filters can carry a comparer. When the comparer is not provided by the default +/// case-sensitive operators, the behavior is equivalent to using `StringComparer.Ordinal`. /// `StringComparer.OrdinalIgnoreCase` 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. diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index dae277e0..7da56b7c 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -185,8 +185,8 @@ let ObjectListFilterType : InputCustomDefinition = { " " [ "The `ObjectListFilter` value represents field filters for object lists." - "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains`, and `_equals`/`_eq` are case-insensitive when applied to string fields." - "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains`, and `_Equals`/`_EQ` are case-sensitive when applied to string fields." + "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains` (no shorthand), and `_equals`/`_eq` are case-insensitive when applied to string fields." + "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains` (no shorthand), and `_Equals`/`_EQ` are case-sensitive when applied to string fields." "Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported." ]) CoerceInput = From 18240a5a901a6ad02d703f087bbc66b026118410 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:04:17 +0000 Subject: [PATCH 20/25] docs: align ObjectListFilter descriptions Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 5 +++-- .../SchemaDefinitions.fs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 68ad4bd7..c4ea8dd6 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,7 @@ query TestQuery { } ``` -For string filters, lowercase suffixes are case-insensitive (`name_starts_with`, `name_sw`, `name_equals`, `name_eq`), while capitalized suffixes are case-sensitive (`name_Starts_With`, `name_SW`, `name_Equals`, `name_EQ`). +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: @@ -399,8 +399,9 @@ query TestQuery { This filter is mapped by the middleware into the `ObjectListFilter` discriminated union, with cases such as `And`, `Or`, `Not`, `Equals`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `In`, `StartsWith`, `EndsWith`, `Contains`, `OfTypes`, and `FilterField`. -- `Equals`, `StartsWith`, `EndsWith`, and `Contains` now carry a comparer in the public discriminated union. +- The comparer-aware cases in the public discriminated union are `Equals`, `StartsWith`, `EndsWith`, and `Contains`. - `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public discriminated union. +- When filters come from GraphQL input, suffix casing selects the string comparer automatically: lowercase suffixes use case-insensitive matching and capitalized suffixes use case-sensitive matching. - When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot. `StringComparer` is accepted there because it also implements `System.Collections.IComparer`. Use `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. - The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. - `Contains` is also used for collection membership checks, so the wrapped value type inside `FieldFilter<_>` stays `System.IComparable` instead of being limited to `string`. diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index 7da56b7c..ce610c7c 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -184,7 +184,7 @@ let ObjectListFilterType : InputCustomDefinition = { (String.concat " " [ - "The `ObjectListFilter` value represents field filters for object lists." + "The ObjectListFilter value represents field filters for object lists." "Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains` (no shorthand), and `_equals`/`_eq` are case-insensitive when applied to string fields." "Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains` (no shorthand), and `_Equals`/`_EQ` are case-sensitive when applied to string fields." "Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported." From 03c2285d0109fea22fe38bfe6d4b9ee88a422162 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:08:41 +0000 Subject: [PATCH 21/25] Use CurrentCulture as default string filter fallback Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 2 +- .../ObjectListFilter.fs | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c4ea8dd6..9e2d18fd 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ This filter is mapped by the middleware into the `ObjectListFilter` discriminate - `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public discriminated union. - When filters come from GraphQL input, suffix casing selects the string comparer automatically: lowercase suffixes use case-insensitive matching and capitalized suffixes use case-sensitive matching. - When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot. `StringComparer` is accepted there because it also implements `System.Collections.IComparer`. Use `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. -- The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. +- The built-in `=@@`, `@@=`, and string `@=@` operators keep the existing case-sensitive `CurrentCulture` behavior when no comparer is supplied. `===` continues to use the normal equality translation. - `Contains` is also used for collection membership checks, so the wrapped value type inside `FieldFilter<_>` stays `System.IComparable` instead of being limited to `string`. - If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index 9ca43f19..35bb3b12 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -12,7 +12,8 @@ type FieldFilter<'Val> = { FieldName : string; Value : 'Val } /// /// /// String-based filters can carry a comparer. When the comparer is not provided by the default -/// case-sensitive operators, the behavior is equivalent to using `StringComparer.Ordinal`. +/// string operators, `StartsWith`, `EndsWith`, and string `Contains` preserve the existing +/// case-sensitive `StringComparison.CurrentCulture` behavior. /// `StringComparer.OrdinalIgnoreCase` 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. @@ -230,7 +231,7 @@ module ObjectListFilter = |> Seq.head /// Maps an IComparer to a StringComparison value. - /// Returns ValueNone for null or Ordinal comparers (use default Expression.Equal path). + /// Returns ValueNone only when the comparer is null or is not a recognized StringComparer. let private comparerToStringComparison (comparer : IComparer) = match comparer with | null -> ValueNone @@ -324,11 +325,11 @@ module ObjectListFilter = | NonEnumerableCast ``type`` -> Expression.LessThanOrEqual ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) | StartsWith (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.Ordinal + 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) - let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.Ordinal + let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.CurrentCulture Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) | Contains (f, comparer) -> @@ -367,7 +368,7 @@ module ObjectListFilter = | :? FieldInfo as field when field.FieldType |> isEnumerable -> callContains field.FieldType | _ -> let unwrappedValue = Helpers.unwrap f.Value - let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.Ordinal + let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.CurrentCulture Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant (unwrappedValue :?> string, typeof), Expression.Constant comparison) | In f when not (f.Value.IsEmpty) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) From 8fc6eb5a977d850c8e25ae7e6f8a33b4931d6162 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:11:49 +0000 Subject: [PATCH 22/25] Trim excess ObjectListFilter README changes Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e2d18fd..23f13327 100644 --- a/README.md +++ b/README.md @@ -399,11 +399,10 @@ query TestQuery { This filter is mapped by the middleware into the `ObjectListFilter` discriminated union, with cases such as `And`, `Or`, `Not`, `Equals`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `In`, `StartsWith`, `EndsWith`, `Contains`, `OfTypes`, and `FilterField`. -- The comparer-aware cases in the public discriminated union are `Equals`, `StartsWith`, `EndsWith`, and `Contains`. +- `Equals`, `StartsWith`, `EndsWith`, and `Contains` now carry a comparer in the public discriminated union. - `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public discriminated union. -- When filters come from GraphQL input, suffix casing selects the string comparer automatically: lowercase suffixes use case-insensitive matching and capitalized suffixes use case-sensitive matching. - When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot. `StringComparer` is accepted there because it also implements `System.Collections.IComparer`. Use `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. -- The built-in `=@@`, `@@=`, and string `@=@` operators keep the existing case-sensitive `CurrentCulture` behavior when no comparer is supplied. `===` continues to use the normal equality translation. +- The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. - `Contains` is also used for collection membership checks, so the wrapped value type inside `FieldFilter<_>` stays `System.IComparable` instead of being limited to `string`. - If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. From d60aa8e6472c40f22809ec6025d1c1ba5b02f1c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:12:17 +0000 Subject: [PATCH 23/25] Tighten ObjectListFilter README wording Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 23f13327..c7183722 100644 --- a/README.md +++ b/README.md @@ -399,10 +399,10 @@ query TestQuery { This filter is mapped by the middleware into the `ObjectListFilter` discriminated union, with cases such as `And`, `Or`, `Not`, `Equals`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `In`, `StartsWith`, `EndsWith`, `Contains`, `OfTypes`, and `FilterField`. -- `Equals`, `StartsWith`, `EndsWith`, and `Contains` now carry a comparer in the public discriminated union. +- The cases `Equals`, `StartsWith`, `EndsWith`, and `Contains` accept a comparer parameter in the public discriminated union. - `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public discriminated union. - When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot. `StringComparer` is accepted there because it also implements `System.Collections.IComparer`. Use `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. -- The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) still use the default comparer behavior for you. +- The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) use the default comparer behavior. - `Contains` is also used for collection membership checks, so the wrapped value type inside `FieldFilter<_>` stays `System.IComparable` instead of being limited to `string`. - If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. From ac616e3f637d4a72765d012aab8d7862b71486bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:16:03 +0000 Subject: [PATCH 24/25] Restore README ObjectListFilter declaration block Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c7183722..3d5b62d7 100644 --- a/README.md +++ b/README.md @@ -397,14 +397,29 @@ query TestQuery { } ``` -This filter is mapped by the middleware into the `ObjectListFilter` discriminated union, with cases such as `And`, `Or`, `Not`, `Equals`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `In`, `StartsWith`, `EndsWith`, `Contains`, `OfTypes`, and `FilterField`. - -- The cases `Equals`, `StartsWith`, `EndsWith`, and `Contains` accept a comparer parameter in the public discriminated union. -- `Equals` and `Contains` keep the non-generic `System.Collections.IComparer` because they also support non-string comparable values in the public discriminated union. -- When the filtered field is a string and you construct the DU cases directly, pass a `StringComparer` instance in that comparer slot. `StringComparer` is accepted there because it also implements `System.Collections.IComparer`. Use `StringComparer.Ordinal` for case-sensitive matching and `StringComparer.OrdinalIgnoreCase` for case-insensitive matching. -- The built-in case-sensitive operators (`===`, `=@@`, `@@=`, `@=@`) use the default comparer behavior. -- `Contains` is also used for collection membership checks, so the wrapped value type inside `FieldFilter<_>` stays `System.IComparable` instead of being limited to `string`. -- If you were previously constructing `Equals`, `StartsWith`, `EndsWith`, or `Contains` directly, update those call sites to provide the comparer argument explicitly. +This filter is mapped by the middleware inside an `ObjectListFilter` definition: + +```fsharp +type FieldFilter<'Val> = + { FieldName : string + Value : 'Val } + +type ObjectListFilter = + | And of ObjectListFilter * ObjectListFilter + | Or of ObjectListFilter * ObjectListFilter + | Not of ObjectListFilter + | Equals of Filter : FieldFilter * Comparer : System.Collections.IComparer + | GreaterThan of FieldFilter + | GreaterThanOrEqual of FieldFilter + | LessThan of FieldFilter + | LessThanOrEqual of FieldFilter + | In of FieldFilter + | StartsWith of Filter : FieldFilter * Comparer : System.StringComparer + | EndsWith of Filter : FieldFilter * Comparer : System.StringComparer + | Contains of Filter : FieldFilter * Comparer : System.Collections.IComparer + | OfTypes of System.Type list + | FilterField of FieldFilter +``` And the value recovered by the filter in the query is usable in the `ResolveFieldContext` of the resolve function of the field. To easily access it, you can use the extension method `Filter`, which returns an `ObjectListFilter voption` (it does not have a value if the object doesn't implement a list with the middleware generic definition, or if the user didn't provide a filter input). From 1f7b012a13895498b29cc8cf2adda59e7d9a09f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:52:14 +0000 Subject: [PATCH 25/25] Add release note for ObjectListFilter change Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- RELEASE_NOTES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 948a801c..059e705c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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