From 15df50a655eebbebc9690e60b3f59899d2f905e1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:59:36 +0200 Subject: [PATCH 01/11] High severity vulnerability fix: upgrade `SQLitePCLRaw.lib.e_sqlite3` to `3.53.3` (#588) Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- Packages.props | 1 + samples/relay-book-store/relay-book-store.fsproj | 1 + 2 files changed, 2 insertions(+) diff --git a/Packages.props b/Packages.props index 323f603f..4f5545f2 100644 --- a/Packages.props +++ b/Packages.props @@ -80,6 +80,7 @@ + diff --git a/samples/relay-book-store/relay-book-store.fsproj b/samples/relay-book-store/relay-book-store.fsproj index 492a31de..d2afed9b 100644 --- a/samples/relay-book-store/relay-book-store.fsproj +++ b/samples/relay-book-store/relay-book-store.fsproj @@ -14,6 +14,7 @@ + From c1e85105ba1a43b25eea35d1dfd8cc4e612f8bc5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:09:46 +0200 Subject: [PATCH 02/11] Support case-insensitive comparison with `ObjectListFilter` (#582) Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Andrii Chebukin --- README.md | 14 +- RELEASE_NOTES.md | 1 + ...harp.Data.GraphQL.Server.Middleware.fsproj | 1 + .../FilterSuffixConstants.fs | 67 +++++++++ .../ObjectListFilter.fs | 137 ++++++++++++------ .../SchemaDefinitions.fs | 69 ++++++--- .../MiddlewareTests.fs | 24 +-- .../ObjectListFilterLinqGenerateTests.fs | 58 +++++--- .../ObjectListFilterLinqTests.fs | 118 +++++++++++++-- 9 files changed, 378 insertions(+), 111 deletions(-) create mode 100644 src/FSharp.Data.GraphQL.Server.Middleware/FilterSuffixConstants.fs diff --git a/README.md b/README.md index 39fd14c8..3d5b62d7 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_contains`, `name_equals`, `name_eq`), while capitalized suffixes are case-sensitive (`name_Starts_With`, `name_SW`, `name_Contains`, `name_Equals`, `name_EQ`). `contains`/`Contains` do not have shorthand aliases. + Also you can apply `not` operator like this: ```graphql @@ -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 : 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 ``` 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 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..3e376f28 --- /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 and all numeric/comparison operators +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/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index 12f54d71..bb4f80e4 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -1,25 +1,36 @@ namespace FSharp.Data.GraphQL.Server.Middleware open System +open System.Collections open FSharp.Data.GraphQL /// A filter definition for a field value. type FieldFilter<'Val> = { FieldName : string; Value : 'Val } +/// /// A filter definition for an object list. +/// +/// +/// String-based filters can carry a comparer. When the comparer is not provided by the default +/// string operators, `StartsWith`, `EndsWith`, and string `Contains` preserve the existing +/// case-sensitive `StringComparison.CurrentCulture` behavior. +/// `StringComparer.CurrentCultureIgnoreCase` enables case-insensitive matching. +/// When filters are provided through GraphQL input, lowercase string suffixes are interpreted +/// as case-insensitive and capitalized suffixes are interpreted as case-sensitive. +/// type ObjectListFilter = | And of ObjectListFilter * ObjectListFilter | Or of ObjectListFilter * ObjectListFilter | Not of ObjectListFilter - | Equals of FieldFilter - | GreaterThan of FieldFilter - | GreaterThanOrEqual of FieldFilter - | LessThan of FieldFilter - | LessThanOrEqual of FieldFilter + | Equals of Filter : FieldFilter * Comparer : 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 : StringComparer + | EndsWith of Filter : FieldFilter * Comparer : StringComparer + | Contains of Filter : FieldFilter * Comparer : IComparer | OfTypes of Type list | FilterField of FieldFilter @@ -95,7 +106,7 @@ module ObjectListFilter = let ( ||| ) x y = Or (x, y) /// Creates a new ObjectListFilter representing an EQUALS operation between two comparable values. - let ( === ) fname value = Equals { FieldName = fname; Value = value } + let ( === ) fname value = Equals ({ FieldName = fname; Value = value }, null) /// Creates a new ObjectListFilter representing a GREATER THAN operation of a comparable value. let ( >>> ) fname value = GreaterThan { FieldName = fname; Value = value } @@ -110,13 +121,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 } @@ -124,9 +135,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 : string) = Equals ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) + + /// Creates a new ObjectListFilter representing a case-insensitive STARTS WITH operation on a string value. + let ( =@@~ ) fname (value : string) = StartsWith ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) + + /// Creates a new ObjectListFilter representing a case-insensitive ENDS WITH operation on a string value. + let ( @@=~ ) fname (value : string) = EndsWith ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) + + /// Creates a new ObjectListFilter representing a case-insensitive CONTAINS operation on a string value. + let ( @=@~ ) fname (value : string) = Contains ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) + let private genericWhereMethod = typeof.GetMethods () |> Seq.where (fun m -> m.Name = "Where") @@ -144,9 +167,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 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) @@ -205,6 +230,21 @@ module ObjectListFilter = |> Seq.where (fun m -> m.Name = "Equals") |> Seq.head + /// Maps an IComparer to a StringComparison value. + /// Returns ValueNone only when the comparer is null or is not a recognized StringComparer. + let private comparerToStringComparison (comparer : IComparer) = + match comparer with + | null -> ValueNone + | :? StringComparer as sc -> + if obj.ReferenceEquals (sc, StringComparer.OrdinalIgnoreCase) then ValueSome StringComparison.OrdinalIgnoreCase + elif obj.ReferenceEquals (sc, StringComparer.InvariantCultureIgnoreCase) then ValueSome StringComparison.InvariantCultureIgnoreCase + elif obj.ReferenceEquals (sc, StringComparer.CurrentCultureIgnoreCase) then ValueSome StringComparison.CurrentCultureIgnoreCase + elif obj.ReferenceEquals (sc, StringComparer.Ordinal) then ValueSome StringComparison.Ordinal + elif obj.ReferenceEquals (sc, StringComparer.InvariantCulture) then ValueSome StringComparison.InvariantCulture + elif obj.ReferenceEquals (sc, StringComparer.CurrentCulture) then ValueSome StringComparison.CurrentCulture + else ValueNone + | _ -> ValueNone + let rec buildFilterExpr isEnumerableQuery (param : SourceExpression) buildTypeDiscriminatorCheck filter : Expression = let build = buildFilterExpr isEnumerableQuery param buildTypeDiscriminatorCheck @@ -224,31 +264,41 @@ module ObjectListFilter = | _ -> Expression.Convert (``member``, stringType) match filter with - | Not (Equals f) -> + | Not (Equals (f, comparer)) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - let hasEqualityOperator = hasEqualityOperator ``member``.Type - match f.Value with - | NoCast when hasEqualityOperator -> Expression.NotEqual (``member``, Expression.Constant f.Value) - | NoCast - | NonEnumerableCast _ -> - Expression.NotEqual (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType)) - | Enumerable -> - let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value) - Expression.Not (Expression.Call (``const``, equalsMethod, ``member``)) + match comparerToStringComparison comparer with + | ValueSome comparison -> + let value = Helpers.unwrap (box f.Value) :?> string + Expression.Not (Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsMethod, Expression.Constant (value, typeof), Expression.Constant comparison)) :> Expression + | ValueNone -> + let hasEqualityOperator = hasEqualityOperator ``member``.Type + match f.Value with + | NoCast when hasEqualityOperator -> Expression.NotEqual (``member``, Expression.Constant f.Value) + | NoCast + | NonEnumerableCast _ -> + Expression.NotEqual (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType)) + | Enumerable -> + let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value) + Expression.Not (Expression.Call (``const``, equalsMethod, ``member``)) | Not f -> f |> build |> Expression.Not :> Expression | And (f1, f2) -> Expression.AndAlso (build f1, build f2) | Or (f1, f2) -> Expression.OrElse (build f1, build f2) - | Equals f -> + | Equals (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - let hasEqualityOperator = hasEqualityOperator ``member``.Type - match f.Value with - | NoCast when hasEqualityOperator -> Expression.Equal (``member``, Expression.Constant f.Value) - | NoCast - | NonEnumerableCast _ -> - Expression.Equal (Expression.Convert (``member``, objectType), Expression.Convert ((Expression.Constant f.Value), objectType)) - | Enumerable -> - let ``const`` = Expression.Constant (Values.normalizeOptional ``member``.Type f.Value) - Expression.Call (``const``, equalsMethod, ``member``) + match comparerToStringComparison comparer with + | ValueSome comparison -> + let value = Helpers.unwrap (box f.Value) :?> string + Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsMethod, Expression.Constant (value, typeof), 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 @@ -273,14 +323,16 @@ module ObjectListFilter = | NoCast -> Expression.LessThanOrEqual (``member``, Expression.Constant f.Value) | Enumerable -> Expression.LessThanOrEqual (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)) | NonEnumerableCast ``type`` -> Expression.LessThanOrEqual ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) - | StartsWith f -> + | StartsWith (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value) - | EndsWith f -> + let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.CurrentCulture + Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) + | EndsWith (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) - Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value) + let comparison = comparerToStringComparison (comparer :> IComparer) |> ValueOption.defaultValue StringComparison.CurrentCulture + Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) - | Contains f -> + | Contains (f, comparer) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) let isEnumerable (memberType : Type) = not (Type.(=) (memberType, stringType)) @@ -316,7 +368,8 @@ module ObjectListFilter = | :? FieldInfo as field when field.FieldType |> isEnumerable -> callContains field.FieldType | _ -> let unwrappedValue = Helpers.unwrap f.Value - Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant unwrappedValue) + let comparison = comparerToStringComparison comparer |> ValueOption.defaultValue StringComparison.CurrentCulture + Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant (unwrappedValue :?> string, typeof), Expression.Constant comparison) | In f when not (f.Value.IsEmpty) -> let ``member`` = Expression.PropertyOrField (param, f.FieldName) let enumerableContains = getEnumerableContainsMethod objectType diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index e8239cad..813c3b7b 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -9,9 +9,10 @@ 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 @@ -19,26 +20,40 @@ type private ComparisonOperator = | LessThanOrEqual of string | In of string + 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 FilterSuffixConstants.CS.EndsWithSuffix && s.Length > FilterSuffixConstants.CS.EndsWithSuffix.Length -> EndsWith (prefix FilterSuffixConstants.CS.EndsWithSuffix s, StringComparer.CurrentCulture) + | s when s.EndsWith FilterSuffixConstants.CS.EWSuffix && s.Length > FilterSuffixConstants.CS.EWSuffix.Length -> EndsWith (prefix FilterSuffixConstants.CS.EWSuffix s, StringComparer.CurrentCulture) + | s when s.EndsWith FilterSuffixConstants.CS.StartsWithSuffix && s.Length > FilterSuffixConstants.CS.StartsWithSuffix.Length -> StartsWith (prefix FilterSuffixConstants.CS.StartsWithSuffix s, StringComparer.CurrentCulture) + | s when s.EndsWith FilterSuffixConstants.CS.SWSuffix && s.Length > FilterSuffixConstants.CS.SWSuffix.Length -> StartsWith (prefix FilterSuffixConstants.CS.SWSuffix s, StringComparer.CurrentCulture) + | s when s.EndsWith FilterSuffixConstants.CS.ContainsSuffix && s.Length > FilterSuffixConstants.CS.ContainsSuffix.Length -> Contains (prefix FilterSuffixConstants.CS.ContainsSuffix s, StringComparer.CurrentCulture) + | s when s.EndsWith FilterSuffixConstants.CS.EqualsSuffix && s.Length > FilterSuffixConstants.CS.EqualsSuffix.Length -> StringEquals (prefix FilterSuffixConstants.CS.EqualsSuffix s, StringComparer.CurrentCulture) + | s when s.EndsWith FilterSuffixConstants.CS.EQSuffix && s.Length > FilterSuffixConstants.CS.EQSuffix.Length -> StringEquals (prefix FilterSuffixConstants.CS.EQSuffix s, StringComparer.CurrentCulture) + | _ -> + // 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") && 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 FilterSuffixConstants.CI.EndsWithSuffix && s.Length > FilterSuffixConstants.CI.EndsWithSuffix.Length -> EndsWith (prefix FilterSuffixConstants.CI.EndsWithSuffix s, StringComparer.CurrentCultureIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.EWSuffix && s.Length > FilterSuffixConstants.CI.EWSuffix.Length -> EndsWith (prefix FilterSuffixConstants.CI.EWSuffix s, StringComparer.CurrentCultureIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.StartsWithSuffix && s.Length > FilterSuffixConstants.CI.StartsWithSuffix.Length -> StartsWith (prefix FilterSuffixConstants.CI.StartsWithSuffix s, StringComparer.CurrentCultureIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.SWSuffix && s.Length > FilterSuffixConstants.CI.SWSuffix.Length -> StartsWith (prefix FilterSuffixConstants.CI.SWSuffix s, StringComparer.CurrentCultureIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.ContainsSuffix && s.Length > FilterSuffixConstants.CI.ContainsSuffix.Length -> Contains (prefix FilterSuffixConstants.CI.ContainsSuffix s, StringComparer.CurrentCultureIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.EqualsSuffix && s.Length > FilterSuffixConstants.CI.EqualsSuffix.Length -> StringEquals (prefix FilterSuffixConstants.CI.EqualsSuffix s, StringComparer.CurrentCultureIgnoreCase) + | s when s.EndsWith FilterSuffixConstants.CI.EQSuffix && s.Length > FilterSuffixConstants.CI.EQSuffix.Length -> StringEquals (prefix FilterSuffixConstants.CI.EQSuffix s, StringComparer.CurrentCultureIgnoreCase) + | 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 = @@ -96,15 +111,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 })) + | 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 })) @@ -165,7 +181,14 @@ 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." + (String.concat + " " + [ + "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." + ]) CoerceInput = (fun _ input variables -> match input with diff --git a/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs b/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs index 8859057e..0e177cae 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.CurrentCultureIgnoreCase))) 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.CurrentCultureIgnoreCase), 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.CurrentCultureIgnoreCase), 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.CurrentCultureIgnoreCase)) 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.CurrentCultureIgnoreCase))) 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.CurrentCultureIgnoreCase)) 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.CurrentCultureIgnoreCase)) 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.CurrentCultureIgnoreCase)) 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.CurrentCultureIgnoreCase)) 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.CurrentCultureIgnoreCase)) 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.CurrentCultureIgnoreCase)) 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..c8a39200 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,31 +191,55 @@ 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")""" +[] +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 () - 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")""" +[] +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 () - 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")""" +[] +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 () - 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 +262,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 +270,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 f1c06fa8..bdd14b70 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 @@ -526,3 +526,95 @@ 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 Equals case insensitive operator`` () = + 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 + let result = List.head filteredData + result.ID |> equals 2 + 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) + 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 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) + 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 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) + 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 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) + 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 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 2d94c67e143e06808f571c120ca36a5e06bf5271 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 9 Jul 2026 20:34:10 +0200 Subject: [PATCH 03/11] `ObjectListFilter` filter values to target type coercion (#589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored `ObjectListFilter`: modularized, added type coercion - Moved filter operators and LINQ logic to ObjectListFilterModule.fs - Added `TypeCoercion.fs` for automatic filter value coercion (`Guid`, `DateTime`, F# DUs, etc.) - Introduced `FilterValueCoercer` and extended `ObjectListFilterLinqOptions` for custom coercion - Centralized filter suffix constants in `FilterSuffixConstants.fs` - Updated `SchemaDefinitions.fs` to use new suffix constants - Added `vtryFind` and `vtryPick` utilities for arrays/lists in `Extensions.fs` - Improved code style, documentation, and function signatures * Refactor 'ObjectListFilter' to use 'System.Text.Json' coercion Replaces custom value coercers with 'System.Text.Json'-based coercion in 'ObjectListFilter', supporting advanced scenarios like F# DUs and CLR enums via 'JsonSerializerOptions'. Updates 'ObjectListFilterLinqOptions' to accept 'JsonSerializerOptions'. Refactors 'TypeCoercion' module to use JSON serialization/deserialization for all type conversions. Updates filter application logic and expands the test suite with new files to cover a wide range of coercion scenarios. Updates documentation and usage examples accordingly. * Updateв schema, add type coercion guide, bug report, tools * Added bug report for InputObject array type mismatch with analysis and test cases * Added type coercion guide for ObjectListFilter with usage and API docs * Introduced format-changed-files.ps1 to batch-format changed F# files via Fantomas * Updated schema snapshots for relay-style connections and new scalars * Refactored field_aliases.fsx for relay-style friends connection * Optimized TypeCoercion.fs to use Utf8JsonWriter for value coercion * Added prompt template for automated PR/issue description generation * Rebase fix * Update filters to use `CurrentCulture` string comparison Updated all string comparison operations in `ObjectListFilter` and filter parsing logic to use `StringComparer.CurrentCulture` or `StringComparer.CurrentCultureIgnoreCase` instead of `Ordinal`/`OrdinalIgnoreCase`. Adjusted related test expectations to match. This ensures string-based filters now respect the current culture's case rules. * Rebase fixes * Removed unnecessary `ObjectListFilterValidationException` * Added test traits * AI review fixes * Fix ObjectListFilter IN coercion behavior and add converter/no-converter tests --- ...harp.Data.GraphQL.Server.Middleware.fsproj | 10 +- .../ObjectListFilter.fs | 415 ++--------- .../ObjectListFilterModule.fs | 653 ++++++++++++++++++ .../SchemaDefinitions.fs | 3 +- .../TypeCoercion.fs | 259 +++++++ .../TypeSystemExtensions.fs | 2 - .../Helpers/Extensions.fs | 58 ++ .../FSharp.Data.GraphQL.Tests.fsproj | 10 +- .../ObjectListFilterComparerMappingTests.fs | 169 +++++ .../ObjectListFilterLinqGenerateTests.fs | 16 +- .../ObjectListFilterLinqTests.fs | 4 +- .../TypeCoercionFilterFieldEnumerableTests.fs | 414 +++++++++++ .../TypeCoercionFilterInOperatorTests.fs | 235 +++++++ ...TypeCoercionFilterOptionCollectionTests.fs | 106 +++ .../TypeCoercionFilterTests.fs | 231 +++++++ .../TypeCoercionTests.Common.fs | 136 ++++ .../TypeCoercionValueTests.fs | 210 ++++++ .../SelectLinqTests.fs | 1 + .../TestAttributes.fs | 18 + 19 files changed, 2577 insertions(+), 373 deletions(-) create mode 100644 src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilterModule.fs create mode 100644 src/FSharp.Data.GraphQL.Server.Middleware/TypeCoercion.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterComparerMappingTests.fs rename tests/FSharp.Data.GraphQL.Tests/{ => ObjectListFilter}/ObjectListFilterLinqGenerateTests.fs (96%) rename tests/FSharp.Data.GraphQL.Tests/{ => ObjectListFilter}/ObjectListFilterLinqTests.fs (99%) create mode 100644 tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterFieldEnumerableTests.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterInOperatorTests.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterOptionCollectionTests.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterTests.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionTests.Common.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionValueTests.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 9eddcf46..4838ed43 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 @@ -14,14 +14,22 @@ true--> + + + <_Parameter1>FSharp.Data.GraphQL.Tests + + + - + + + diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs index bb4f80e4..847165ac 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs @@ -2,7 +2,7 @@ namespace FSharp.Data.GraphQL.Server.Middleware open System open System.Collections -open FSharp.Data.GraphQL +open System.Text.Json /// A filter definition for a field value. type FieldFilter<'Val> = { FieldName : string; Value : 'Val } @@ -34,18 +34,27 @@ type ObjectListFilter = | OfTypes of Type list | FilterField of FieldFilter -open System.Linq open System.Linq.Expressions open System.Runtime.InteropServices -open System.Reflection -open System.Collections.Generic type private CompareDiscriminatorExpression<'T, 'D> = Expression> /// -/// Allows to specify discriminator comparison or discriminator getter -/// and a function that return discriminator value depending on entity type +/// Initializes a new instance of with optional +/// LINQ translation settings including discriminator handling and In-operator behavior. /// +/// Entity type. +/// Discriminator value type. +/// +/// Optional custom discriminator comparison expression. +/// +/// +/// Optional discriminator value resolver for OfTypes filtering. +/// +/// +/// Optional serializer settings used during filter value coercion. +/// + /// /// // discriminator custom condition /// let result () = @@ -72,378 +81,60 @@ type private CompareDiscriminatorExpression<'T, 'D> = Expression -[] type ObjectListFilterLinqOptions<'T, 'D> - ([] compareDiscriminator : CompareDiscriminatorExpression<'T, 'D> | null, [] getDiscriminatorValue : (Type -> 'D) | null) = - + ( + /// Optional custom discriminator comparison expression. + [] compareDiscriminator : CompareDiscriminatorExpression<'T, 'D> | null, + /// Optional discriminator value resolver used for OfTypes filtering. + [] getDiscriminatorValue : (Type -> 'D) | null, + /// Optional serializer settings used during filter value coercion. + [] jsonOptions : JsonSerializerOptions | null + ) = + + /// Gets the optional custom discriminator comparison expression. member _.CompareDiscriminator = compareDiscriminator |> ValueOption.ofObj + + /// Gets the optional discriminator value resolver. member _.GetDiscriminatorValue = getDiscriminatorValue |> ValueOption.ofObj - static member None = ObjectListFilterLinqOptions<'T, 'D> (null, null) + /// Gets optional serializer settings used during filter coercion. + member _.JsonOptions = jsonOptions |> ValueOption.ofObj + /// Default options with all features disabled. + static member None = ObjectListFilterLinqOptions<'T, 'D> (null, null, null) + + /// Creates a discriminator comparison expression from a discriminator selector. static member GetCompareDiscriminator (getDiscriminatorValue : Expression>) = let tParam = Expression.Parameter (typeof<'T>, "x") let dParam = Expression.Parameter (typeof<'D>, "d") let body = Expression.Equal (Expression.Invoke (getDiscriminatorValue, tParam), dParam) Expression.Lambda> (body, tParam, dParam) + /// Initializes options using a discriminator selector expression. new (getDiscriminator : Expression>) = - ObjectListFilterLinqOptions<'T, 'D> (ObjectListFilterLinqOptions.GetCompareDiscriminator getDiscriminator, null) - new (compareDiscriminator : CompareDiscriminatorExpression<'T, 'D>) = ObjectListFilterLinqOptions<'T, 'D> (compareDiscriminator, null) - new (getDiscriminatorValue : Type -> 'D) = - ObjectListFilterLinqOptions<'T, 'D> (compareDiscriminator = null, getDiscriminatorValue = getDiscriminatorValue) - new (getDiscriminator : Expression>, getDiscriminatorValue : Type -> 'D) = - ObjectListFilterLinqOptions<'T, 'D> (ObjectListFilterLinqOptions.GetCompareDiscriminator getDiscriminator, getDiscriminatorValue) - -/// Contains tooling for working with ObjectListFilter. -module ObjectListFilter = - /// Contains operators for building and comparing ObjectListFilter values. - module Operators = - /// Creates a new ObjectListFilter representing an AND operation between two existing ones. - let ( &&& ) x y = And (x, y) - - /// Creates a new ObjectListFilter representing an OR operation between two existing ones. - 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 }, null) - - /// Creates a new ObjectListFilter representing a GREATER THAN operation of a comparable value. - let ( >>> ) fname value = GreaterThan { FieldName = fname; Value = value } - - /// Creates a new ObjectListFilter representing a GREATER THAN OR EQUAL operation of a comparable value. - let ( ==> ) fname value = GreaterThanOrEqual { FieldName = fname; Value = value } - - /// Creates a new ObjectListFilter representing a LESS THAN operation of a comparable value. - let ( <<< ) fname value = LessThan { FieldName = fname; Value = value } - - /// Creates a new ObjectListFilter representing a LESS THAN OR EQUAL operation of a comparable value. - 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 }, null) - - /// Creates a new ObjectListFilter representing an ENDS WITH operation of a string 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 }, null) - - /// Creates a new ObjectListFilter representing a IN operation. - let ( =~= ) fname value = In { FieldName = fname; Value = value } - - /// Creates a new ObjectListFilter representing a field sub comparison. - let ( --> ) fname filter = FilterField { FieldName = fname; Value = filter } - - /// Creates a new ObjectListFilter representing a NOT operation for the existing one. - let ( !!! ) filter = Not filter - - /// Creates a new ObjectListFilter representing a case-insensitive EQUALS operation on a string value. - let ( ===~ ) fname (value : string) = Equals ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) - - /// Creates a new ObjectListFilter representing a case-insensitive STARTS WITH operation on a string value. - let ( =@@~ ) fname (value : string) = StartsWith ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) - - /// Creates a new ObjectListFilter representing a case-insensitive ENDS WITH operation on a string value. - let ( @@=~ ) fname (value : string) = EndsWith ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) - - /// Creates a new ObjectListFilter representing a case-insensitive CONTAINS operation on a string value. - let ( @=@~ ) fname (value : string) = Contains ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) - - let private genericWhereMethod = - typeof.GetMethods () - |> Seq.where (fun m -> m.Name = "Where") - |> Seq.find (fun m -> - let parameters = m.GetParameters () - parameters.Length = 2 - && parameters[1].ParameterType.GetGenericTypeDefinition () = typedefof>>) + ObjectListFilterLinqOptions<'T, 'D> (ObjectListFilterLinqOptions.GetCompareDiscriminator getDiscriminator, null, null) - // Helper to create Where expression - let whereExpr<'T> (query : IQueryable<'T>) (param : ParameterExpression) predicate = - let whereMethod = genericWhereMethod.MakeGenericMethod ([| typeof<'T> |]) - Expression.Call (whereMethod, [| query.Expression; Expression.Lambda> (predicate, param) |]) + /// Initializes options using a custom discriminator comparison expression. + new (compareDiscriminator : CompareDiscriminatorExpression<'T, 'D>) = + ObjectListFilterLinqOptions<'T, 'D> (compareDiscriminator, null, null) - let private objectType = typeof - let private stringType = typeof - let private genericIEnumerableType = typedefof> - - let private stringComparisonType = typeof - 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) - - let private getCollectionInstanceContainsMethod (memberType : Type) = - memberType - .GetMethods(BindingFlags.Instance ||| BindingFlags.Public) - .FirstOrDefault (fun m -> m.Name = "Contains" && m.GetParameters().Length = 1) - |> ValueOption.ofObj - - let private getEnumerableContainsMethod (itemType : Type) = - match - typeof - .GetMethods(BindingFlags.Static ||| BindingFlags.Public) - .FirstOrDefault (fun m -> m.Name = "Contains" && m.GetParameters().Length = 2) - with - | null -> raise (MissingMemberException "Static 'Contains' method with 2 parameters not found on 'Enumerable' class") - | containsGenericStaticMethod -> containsGenericStaticMethod.MakeGenericMethod ([| itemType |]) - - let private getEnumerableCastMethod (itemType : Type) = - match - typeof - .GetMethods(BindingFlags.Static ||| BindingFlags.Public) - .FirstOrDefault (fun m -> m.Name = "Cast" && m.GetParameters().Length = 1) - with - | null -> raise (MissingMemberException "Static 'Cast' method with 1 parameter not found on 'Enumerable' class") - | castGenericStaticMethod -> castGenericStaticMethod.MakeGenericMethod ([| itemType |]) - - let getField (param : ParameterExpression) fieldName = Expression.PropertyOrField (param, fieldName) - - let hasEqualityOperator (``type`` : Type) = - ``type``.GetMethods (BindingFlags.Public ||| BindingFlags.Static) - |> Seq.exists (fun m -> m.Name = " op_Equality") - - let hasInequalityOperator (``type`` : Type) = - ``type``.GetMethods (BindingFlags.Public ||| BindingFlags.Static) - |> Seq.exists (fun m -> m.Name = "op_Inequality") - - [] - type SourceExpression private (expression : Expression) = - new (parameter : ParameterExpression) = SourceExpression (parameter :> Expression) - new (``member`` : MemberExpression) = SourceExpression (``member`` :> Expression) - member _.Value = expression - static member op_Implicit (source : SourceExpression) = source.Value - static member op_Implicit (parameter : ParameterExpression) = SourceExpression (parameter :> Expression) - static member op_Implicit (``member`` : MemberExpression) = SourceExpression (``member`` :> Expression) - - let equalsMethod = - objectType - |> _.GetMethods(BindingFlags.Instance ||| BindingFlags.Public) - |> Seq.where (fun m -> m.Name = "Equals") - |> Seq.head - - let staticEqualsMethod = - objectType - |> _.GetMethods(BindingFlags.Static ||| BindingFlags.Public) - |> Seq.where (fun m -> m.Name = "Equals") - |> Seq.head - - /// Maps an IComparer to a StringComparison value. - /// Returns ValueNone only when the comparer is null or is not a recognized StringComparer. - let private comparerToStringComparison (comparer : IComparer) = - match comparer with - | null -> ValueNone - | :? StringComparer as sc -> - if obj.ReferenceEquals (sc, StringComparer.OrdinalIgnoreCase) then ValueSome StringComparison.OrdinalIgnoreCase - elif obj.ReferenceEquals (sc, StringComparer.InvariantCultureIgnoreCase) then ValueSome StringComparison.InvariantCultureIgnoreCase - elif obj.ReferenceEquals (sc, StringComparer.CurrentCultureIgnoreCase) then ValueSome StringComparison.CurrentCultureIgnoreCase - elif obj.ReferenceEquals (sc, StringComparer.Ordinal) then ValueSome StringComparison.Ordinal - elif obj.ReferenceEquals (sc, StringComparer.InvariantCulture) then ValueSome StringComparison.InvariantCulture - elif obj.ReferenceEquals (sc, StringComparer.CurrentCulture) then ValueSome StringComparison.CurrentCulture - else ValueNone - | _ -> ValueNone - - let rec buildFilterExpr isEnumerableQuery (param : SourceExpression) buildTypeDiscriminatorCheck filter : Expression = - - let build = buildFilterExpr isEnumerableQuery param buildTypeDiscriminatorCheck - - let (|NoCast|Enumerable|NonEnumerableCast|) value = - if obj.ReferenceEquals (value, null) then NoCast - else if isEnumerableQuery then Enumerable - else NonEnumerableCast (value.GetType ()) - - let unsafeConvertTo ``type`` ``member`` = Expression.Convert (Expression.Convert (``member``, objectType), ``type``) - - let normalizeStringMemberExpr (``member`` : MemberExpression) : Expression = - match ``member``.Type with - | t when t = stringType -> ``member`` - | _ when not isEnumerableQuery -> unsafeConvertTo stringType ``member`` - | _ when isEnumerableQuery -> Expression.Convert (Expression.Call (unwrapOptionMethod, ``member``), stringType) - | _ -> Expression.Convert (``member``, stringType) - - match filter with - | Not (Equals (f, comparer)) -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - match comparerToStringComparison comparer with - | ValueSome comparison -> - let value = Helpers.unwrap (box f.Value) :?> string - Expression.Not (Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsMethod, Expression.Constant (value, typeof), 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, comparer) -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - match comparerToStringComparison comparer with - | ValueSome comparison -> - let value = Helpers.unwrap (box f.Value) :?> string - Expression.Call (normalizeStringMemberExpr ``member``, StringEqualsMethod, 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 - | NoCast -> Expression.GreaterThan (``member``, Expression.Constant f.Value) - | Enumerable -> Expression.GreaterThan (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)) - | NonEnumerableCast ``type`` -> Expression.GreaterThan ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) - | LessThan f -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - match f.Value with - | NoCast -> Expression.LessThan (``member``, Expression.Constant f.Value) - | Enumerable -> Expression.LessThan (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)) - | NonEnumerableCast ``type`` -> Expression.LessThan ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) - | GreaterThanOrEqual f -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - match f.Value with - | NoCast -> Expression.GreaterThanOrEqual (``member``, Expression.Constant f.Value) - | Enumerable -> Expression.GreaterThanOrEqual (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)) - | NonEnumerableCast ``type`` -> Expression.GreaterThanOrEqual ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) - | LessThanOrEqual f -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - match f.Value with - | 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, comparer) -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - 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.CurrentCulture - Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) - - | Contains (f, comparer) -> - let ``member`` = Expression.PropertyOrField (param, f.FieldName) - let isEnumerable (memberType : Type) = - not (Type.(=) (memberType, stringType)) - && typeof.IsAssignableFrom (memberType) - && memberType.GetInterfaces().Any (fun i -> i.FullName.StartsWith "System.Collections.Generic.IEnumerable`1") - let normalizedValue = Values.normalizeOptional ``member``.Type f.Value - let callContains memberType = - let itemType = - if ``member``.Type.IsArray then - ``member``.Type.GetElementType () - else - ``member``.Type.GetGenericArguments()[0] - let valueType = - match normalizedValue with - | null -> itemType - | value -> value.GetType () - let castedMember = - if itemType = valueType then - ``member`` :> Expression - elif isEnumerableQuery then - let castMethod = getEnumerableCastMethod valueType - Expression.Call (castMethod, ``member``) - else - let castedEnumerableType = genericIEnumerableType.MakeGenericType ([| valueType |]) - unsafeConvertTo castedEnumerableType ``member`` - match getCollectionInstanceContainsMethod memberType with - | ValueNone -> - let enumerableContains = getEnumerableContainsMethod valueType - Expression.Call (enumerableContains, castedMember, Expression.Constant (normalizedValue)) - | ValueSome instanceContainsMethod -> Expression.Call (castedMember, instanceContainsMethod, Expression.Constant (normalizedValue)) - match ``member``.Member with - | :? PropertyInfo as prop when prop.PropertyType |> isEnumerable -> callContains prop.PropertyType - | :? FieldInfo as field when field.FieldType |> isEnumerable -> callContains field.FieldType - | _ -> - let unwrappedValue = Helpers.unwrap f.Value - 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) - let enumerableContains = getEnumerableContainsMethod objectType - Expression.Call (enumerableContains, (Expression.Constant f.Value), Expression.Convert (``member``, objectType)) - | In f -> Expression.Constant (false) - | OfTypes types -> - types - |> Seq.map (fun t -> buildTypeDiscriminatorCheck param t) - |> Seq.reduce (fun acc expr -> Expression.OrElse (acc, expr)) - | FilterField f -> - let paramExpr = Expression.PropertyOrField (param, f.FieldName) - buildFilterExpr isEnumerableQuery (SourceExpression paramExpr) buildTypeDiscriminatorCheck f.Value - - type private CompareDiscriminatorExpressionVisitor<'T, 'D> - (compareDiscriminator : CompareDiscriminatorExpression<'T, 'D>, param : SourceExpression, value : obj) = - inherit ExpressionVisitor () - override _.VisitParameter (node) = - if node = compareDiscriminator.Parameters.[0] then - param.Value - elif node = compareDiscriminator.Parameters.[1] then - Expression.Constant (value) :> Expression - else - node :> Expression - - let enumerableQueryType = typedefof> - - let apply (options : ObjectListFilterLinqOptions<'T, 'D>) (filter : ObjectListFilter) (query : IQueryable<'T>) = - let isEnumerableQuery = query.GetType().GetGenericTypeDefinition () = enumerableQueryType - // Helper for discriminator comparison - let buildTypeDiscriminatorCheck (param : SourceExpression) (t : Type) = - match options.CompareDiscriminator, options.GetDiscriminatorValue with - | ValueNone, ValueNone -> - Expression.Equal ( - // Default discriminator property - Expression.PropertyOrField (param, "__typename"), - // Default discriminator value - Expression.Constant (t.FullName) - ) - :> Expression - | ValueSome discExpr, ValueNone -> - // Replace parameters from the original expression with our new ones - let replacer = CompareDiscriminatorExpressionVisitor (discExpr, param, t.FullName) - replacer.Visit discExpr.Body - | ValueNone, ValueSome discValueFn -> - let discriminatorValue = discValueFn t - Expression.Equal ( - // Default discriminator property - Expression.PropertyOrField (param, "__typename"), - // Provided discriminator value gathered from type - Expression.Constant (discriminatorValue) - ) - :> Expression - | ValueSome discExpr, ValueSome discValueFn -> - let discriminatorValue = discValueFn t - // Replace parameters from the original expression with our new ones - let replacer = CompareDiscriminatorExpressionVisitor (discExpr, param, discriminatorValue) - replacer.Visit discExpr.Body - let queryExpr = - let param = Expression.Parameter (typeof<'T>, "x") - let body = buildFilterExpr isEnumerableQuery (SourceExpression param) buildTypeDiscriminatorCheck filter - whereExpr<'T> query param body - // Create and execute the final expression - query.Provider.CreateQuery<'T> (queryExpr) - -[] -module ObjectListFilterExtensions = + /// Initializes options using a discriminator value resolver. + new (getDiscriminatorValue : Type -> 'D) = + ObjectListFilterLinqOptions<'T, 'D> (null, getDiscriminatorValue, null) - open ObjectListFilter + /// Initializes options using both discriminator selector and value resolver. + new (getDiscriminator : Expression>, getDiscriminatorValue : Type -> 'D) = + ObjectListFilterLinqOptions<'T, 'D> (ObjectListFilterLinqOptions.GetCompareDiscriminator getDiscriminator, getDiscriminatorValue, null) - type ObjectListFilter with + /// Initializes options using serializer settings for filter coercion. + new (jsonOptions : JsonSerializerOptions) = + ObjectListFilterLinqOptions<'T, 'D> (null, null, jsonOptions) - member inline filter.ApplyTo<'T, 'D> (query : IQueryable<'T>, [] options : ObjectListFilterLinqOptions<'T, 'D>) = - apply options filter query + /// Initializes options using discriminator selector and serializer settings. + new (getDiscriminator : Expression>, jsonOptions : JsonSerializerOptions) = + ObjectListFilterLinqOptions<'T, 'D> (ObjectListFilterLinqOptions.GetCompareDiscriminator getDiscriminator, null, jsonOptions) - type IQueryable<'T> with + /// Initializes options using discriminator comparison and serializer settings. + new (compareDiscriminator : CompareDiscriminatorExpression<'T, 'D>, jsonOptions : JsonSerializerOptions) = + ObjectListFilterLinqOptions<'T, 'D> (compareDiscriminator, null, jsonOptions) - member inline query.Apply (filter : ObjectListFilter, [] options : ObjectListFilterLinqOptions<'T, 'D>) = apply options filter query diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilterModule.fs b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilterModule.fs new file mode 100644 index 00000000..11908b5f --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilterModule.fs @@ -0,0 +1,653 @@ +namespace FSharp.Data.GraphQL.Server.Middleware + +open System +open System.Collections +open System.Collections.Concurrent +open System.Collections.Generic +open System.Linq +open System.Linq.Expressions +open System.Reflection +open System.Runtime.InteropServices +open FSharp.Data.GraphQL + +/// Contains tooling for working with ObjectListFilter. +[] +module ObjectListFilter = + /// Contains operators for building and comparing ObjectListFilter values. + module Operators = + /// Creates a new ObjectListFilter representing an AND operation between two existing ones. + let (&&&) x y = And (x, y) + + /// Creates a new ObjectListFilter representing an OR operation between two existing ones. + 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 }, null) + + /// Creates a new ObjectListFilter representing a GREATER THAN operation of a comparable value. + let (>>>) fname value = GreaterThan { FieldName = fname; Value = value } + + /// Creates a new ObjectListFilter representing a GREATER THAN OR EQUAL operation of a comparable value. + let (==>) fname value = GreaterThanOrEqual { FieldName = fname; Value = value } + + /// Creates a new ObjectListFilter representing a LESS THAN operation of a comparable value. + let (<<<) fname value = LessThan { FieldName = fname; Value = value } + + /// Creates a new ObjectListFilter representing a LESS THAN OR EQUAL operation of a comparable value. + 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 }, null) + + /// Creates a new ObjectListFilter representing an ENDS WITH operation of a string 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 }, null) + + /// Creates a new ObjectListFilter representing a IN operation. + let (=~=) fname value = In { FieldName = fname; Value = value } + + /// Creates a new ObjectListFilter representing a field sub comparison. + let (-->) fname filter = FilterField { FieldName = fname; Value = filter } + + /// Creates a new ObjectListFilter representing a NOT operation for the existing one. + let (!!!) filter = Not filter + + /// Creates a new ObjectListFilter representing a case-insensitive EQUALS operation on a string value. + let (===~) fname (value : string) = + Equals ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) + + /// Creates a new ObjectListFilter representing a case-insensitive STARTS WITH operation on a string value. + let (=@@~) fname (value : string) = + StartsWith ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) + + /// Creates a new ObjectListFilter representing a case-insensitive ENDS WITH operation on a string value. + let (@@=~) fname (value : string) = + EndsWith ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) + + /// Creates a new ObjectListFilter representing a case-insensitive CONTAINS operation on a string value. + let (@=@~) fname (value : string) = + Contains ({ FieldName = fname; Value = value }, StringComparer.CurrentCultureIgnoreCase) + + let private genericWhereMethod = + typeof.GetMethods () + |> Seq.where (fun m -> m.Name = "Where") + |> Seq.find (fun m -> + let parameters = m.GetParameters () + parameters.Length = 2 + && parameters[1].ParameterType.GetGenericTypeDefinition () = typedefof>>) + + // Helper to create Where expression + let whereExpr<'T> (query : IQueryable<'T>) (param : ParameterExpression) predicate = + let whereMethod = genericWhereMethod.MakeGenericMethod ([| typeof<'T> |]) + Expression.Call (whereMethod, [| query.Expression; Expression.Lambda> (predicate, param) |]) + + let private objectType = typeof + let private stringType = typeof + let private genericIEnumerableType = typedefof> + let private enumerableType = typeof + let private iEnumerableType = typeof + + let private stringComparisonType = typeof + 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) + + /// Cache for MemberInfo (PropertyInfo or FieldInfo) lookups to avoid repeated reflection. + let private memberInfoCache = System.Collections.Concurrent.ConcurrentDictionary<(Type * string), MemberInfo voption> () + + /// Checks if a type is the generic IEnumerable interface using structural comparison. + let private isGenericIEnumerable (t : Type) : bool = + t.IsGenericType && t.GetGenericTypeDefinition () = genericIEnumerableType + + /// Gets MemberInfo (PropertyInfo or FieldInfo) from cache, performing reflection if not cached. + /// Mirrors the behavior of Expression.PropertyOrField which checks properties first, then fields. + let private getCachedMemberInfo (entityType : Type) (stripSuffix : string) : MemberInfo voption = + let key = (entityType, stripSuffix) + memberInfoCache.GetOrAdd( + key, + Func<(Type * string), MemberInfo voption> (fun _ -> + // Try property first (matches Expression.PropertyOrField behavior) + match + entityType.GetProperty ( + stripSuffix, + BindingFlags.Public + ||| BindingFlags.Instance + ||| BindingFlags.IgnoreCase + ) + with + | null -> + // Fall back to field if property not found + match + entityType.GetField ( + stripSuffix, + BindingFlags.Public + ||| BindingFlags.Instance + ||| BindingFlags.IgnoreCase + ) + with + | null -> ValueNone + | f -> ValueSome (f :> MemberInfo) + | p -> ValueSome (p :> MemberInfo) + ) + ) + + let private getCollectionInstanceContainsMethod (memberType : Type) = + memberType + .GetMethods(BindingFlags.Instance ||| BindingFlags.Public) + .FirstOrDefault (fun m -> m.Name = "Contains" && m.GetParameters().Length = 1) + |> ValueOption.ofObj + + let private getEnumerableContainsMethod (itemType : Type) = + match + enumerableType + .GetMethods(BindingFlags.Static ||| BindingFlags.Public) + .FirstOrDefault (fun m -> m.Name = "Contains" && m.GetParameters().Length = 2) + with + | null -> raise (MissingMemberException "Static 'Contains' method with 2 parameters not found on 'Enumerable' class") + | containsGenericStaticMethod -> containsGenericStaticMethod.MakeGenericMethod ([| itemType |]) + + let private getEnumerableCastMethod (itemType : Type) = + match + enumerableType + .GetMethods(BindingFlags.Static ||| BindingFlags.Public) + .FirstOrDefault (fun m -> m.Name = "Cast" && m.GetParameters().Length = 1) + with + | null -> raise (MissingMemberException "Static 'Cast' method with 1 parameter not found on 'Enumerable' class") + | castGenericStaticMethod -> castGenericStaticMethod.MakeGenericMethod ([| itemType |]) + + let getField (param : ParameterExpression) fieldName = Expression.PropertyOrField (param, fieldName) + + let hasEqualityOperator (``type`` : Type) = + ``type``.GetMethods (BindingFlags.Public ||| BindingFlags.Static) + |> Seq.exists (fun m -> m.Name = " op_Equality") + + let hasInequalityOperator (``type`` : Type) = + ``type``.GetMethods (BindingFlags.Public ||| BindingFlags.Static) + |> Seq.exists (fun m -> m.Name = "op_Inequality") + + [] + type SourceExpression private (expression : Expression) = + new (parameter : ParameterExpression) = SourceExpression (parameter :> Expression) + new (``member`` : MemberExpression) = SourceExpression (``member`` :> Expression) + member _.Value = expression + static member op_Implicit (source : SourceExpression) = source.Value + static member op_Implicit (parameter : ParameterExpression) = SourceExpression (parameter :> Expression) + static member op_Implicit (``member`` : MemberExpression) = SourceExpression (``member`` :> Expression) + + let equalsMethod = + objectType + |> _.GetMethods(BindingFlags.Instance ||| BindingFlags.Public) + |> Seq.where (fun m -> m.Name = "Equals") + |> Seq.head + + let staticEqualsMethod = + objectType + |> _.GetMethods(BindingFlags.Static ||| BindingFlags.Public) + |> Seq.where (fun m -> m.Name = "Equals") + |> Seq.head + + /// Maps an IComparer to a StringComparison value. + /// Returns ValueNone for null or unsupported comparers. + let internal comparerToStringComparison (comparer : IComparer) = + match comparer with + | null -> ValueNone + | :? StringComparer as sc -> + let mutable isOrdinalIgnoreCase = false + + if StringComparer.IsWellKnownOrdinalComparer (sc, &isOrdinalIgnoreCase) then + if isOrdinalIgnoreCase then + ValueSome StringComparison.OrdinalIgnoreCase + else + ValueSome StringComparison.Ordinal + else + let mutable compareInfo = Unchecked.defaultof + let mutable compareOptions = Globalization.CompareOptions.None + + if StringComparer.IsWellKnownCultureAwareComparer (sc, &compareInfo, &compareOptions) then + let isInvariantCulture = compareInfo.Equals Globalization.CultureInfo.InvariantCulture.CompareInfo + let isCurrentCulture = compareInfo.Equals Globalization.CultureInfo.CurrentCulture.CompareInfo + + match compareOptions with + | Globalization.CompareOptions.None when isInvariantCulture -> ValueSome StringComparison.InvariantCulture + | Globalization.CompareOptions.IgnoreCase when isInvariantCulture -> + ValueSome StringComparison.InvariantCultureIgnoreCase + | Globalization.CompareOptions.None when isCurrentCulture -> ValueSome StringComparison.CurrentCulture + | Globalization.CompareOptions.IgnoreCase when isCurrentCulture -> + ValueSome StringComparison.CurrentCultureIgnoreCase + | _ -> ValueNone + else + ValueNone + | _ -> ValueNone + + /// Gets the type from a MemberInfo (PropertyInfo or FieldInfo). + let private getMemberType (member' : MemberInfo) : Type = + match member' with + | :? PropertyInfo as p -> p.PropertyType + | :? FieldInfo as f -> f.FieldType + | _ -> invalidOp $"Unsupported member type: {member'.GetType().Name}" + + /// Resolves the field type within a given entity, stripping suffixes and unwrapping options. + let private getFieldTypeForEntity (entityType : Type) (fieldName : string) : Type voption = + let stripSuffix = TypeCoercion.stripOperatorSuffix fieldName + match getCachedMemberInfo entityType stripSuffix with + | ValueNone -> ValueNone + | ValueSome member' -> ValueSome (TypeCoercion.unwrapOption (getMemberType member')) + + /// Returns both the original member type and the unwrapped type. + /// Useful for detecting if we need to unwrap option expressions at runtime. + let private getFieldTypeAndOriginal (entityType : Type) (fieldName : string) : (Type * Type) voption = + let stripSuffix = TypeCoercion.stripOperatorSuffix fieldName + match getCachedMemberInfo entityType stripSuffix with + | ValueNone -> ValueNone + | ValueSome ``member`` -> + let originalType = getMemberType ``member`` + let unwrappedType = TypeCoercion.unwrapOption originalType + ValueSome (originalType, unwrappedType) + + /// Detects if a type is enumerable (but not string). + let private isEnumerableType (``type`` : Type) : bool = + not (Type.(=) (``type``, stringType)) + && iEnumerableType.IsAssignableFrom (``type``) + && ``type``.GetInterfaces().Any (fun i -> isGenericIEnumerable i) + + /// Unwraps the element type from an enumerable type. + let private tryGetEnumerableElementType (``type`` : Type) : Type voption = TypeCoercion.tryUnwrapEnumerableElement ``type`` + + /// Gets the closed generic method for the given element type. + let private getEnumerableAnyMethod (elementType : Type) : MethodInfo = + match + enumerableType + .GetMethods(BindingFlags.Static ||| BindingFlags.Public) + .FirstOrDefault (fun m -> m.Name = "Any" && m.GetParameters().Length = 2) + with + | null -> + let message = + $"Static 'Any' method with 2 parameters not found on '{enumerableType.FullName}' class. Expected signature: Any(IEnumerable, Func). " + raise (MissingMemberException message) + | anyGenericStaticMethod -> anyGenericStaticMethod.MakeGenericMethod ([| elementType |]) + + let private normalizeInValue (fieldType : Type) (value : obj) : obj = + let normalized = Values.normalizeOptional fieldType value + if obj.ReferenceEquals (normalized, null) then + null + elif fieldType.IsGenericType && fieldType.GetGenericTypeDefinition () = typedefof> then + let underlyingType = Nullable.GetUnderlyingType fieldType + if not (obj.ReferenceEquals (underlyingType, null)) && normalized.GetType () = underlyingType then + Activator.CreateInstance (fieldType, normalized) + else + normalized + else + normalized + + let private materializeTypedInArray (fieldType : Type) (values : obj list) : Array = + let array = Array.CreateInstance (fieldType, values.Length) + values + |> List.iteri (fun index value -> array.SetValue (normalizeInValue fieldType value, index)) + array + + let rec buildFilterExpr isEnumerableQuery (param : SourceExpression) buildTypeDiscriminatorCheck filter : Expression = + + let build = buildFilterExpr isEnumerableQuery param buildTypeDiscriminatorCheck + + let (|NoCast|Enumerable|NonEnumerableCast|) value = + if obj.ReferenceEquals (value, null) then NoCast + else if isEnumerableQuery then Enumerable + else NonEnumerableCast (value.GetType ()) + + let unsafeConvertTo ``type`` ``member`` = Expression.Convert (Expression.Convert (``member``, objectType), ``type``) + + let normalizeStringMemberExpr (``member`` : Expression) : Expression = + let memberType = ``member``.Type + match memberType with + | t when t = stringType -> ``member`` + | _ when not isEnumerableQuery -> unsafeConvertTo stringType ``member`` + | _ when isEnumerableQuery -> + // For ParameterExpression (from "_"), we can't call unwrapOptionMethod directly + if ``member`` :? ParameterExpression then + Expression.Convert (``member``, stringType) + else + match ``member`` with + | :? MemberExpression as me -> Expression.Convert (Expression.Call (unwrapOptionMethod, me), stringType) + | _ -> Expression.Convert (``member``, stringType) + | _ -> Expression.Convert (``member``, stringType) + + match filter with + | Not (Equals (f, comparer)) -> + let ``member`` = + // Special case: "_" means the element itself, not a field property + if f.FieldName = "_" then + param.Value + else + Expression.PropertyOrField (param, f.FieldName) + let unwrappedMemberType = TypeCoercion.unwrapOption ``member``.Type + match comparerToStringComparison comparer with + | ValueSome comparison when Type.(=) (unwrappedMemberType, stringType) -> + let value = Helpers.unwrap (box f.Value) :?> string + Expression.Not ( + Expression.Call ( + normalizeStringMemberExpr ``member``, + StringEqualsMethod, + Expression.Constant (value, stringType), + Expression.Constant comparison + ) + ) + :> Expression + | ValueSome _ + | 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 normalized = Values.normalizeOptional ``member``.Type f.Value + let ``const`` = Expression.Constant (normalized) + let boxedArg = Expression.Convert (``member``, objectType) + Expression.Not (Expression.Call (``const``, equalsMethod, boxedArg)) + | 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, comparer) -> + let ``member`` = + // Special case: "_" means the element itself, not a field property + if f.FieldName = "_" then + param.Value + else + Expression.PropertyOrField (param, f.FieldName) + let unwrappedMemberType = TypeCoercion.unwrapOption ``member``.Type + match comparerToStringComparison comparer with + | ValueSome comparison when Type.(=) (unwrappedMemberType, stringType) -> + let value = Helpers.unwrap (box f.Value) :?> string + Expression.Call ( + normalizeStringMemberExpr ``member``, + StringEqualsMethod, + Expression.Constant (value, stringType), + Expression.Constant comparison + ) + :> Expression + | ValueSome _ + | 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 normalized = Values.normalizeOptional ``member``.Type f.Value + let ``const`` = Expression.Constant (normalized) + let boxedArg = Expression.Convert (``member``, objectType) + Expression.Call (``const``, equalsMethod, boxedArg) + | GreaterThan f -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + match f.Value with + | NoCast -> Expression.GreaterThan (``member``, Expression.Constant f.Value) + | Enumerable -> Expression.GreaterThan (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)) + | NonEnumerableCast ``type`` -> Expression.GreaterThan ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) + | LessThan f -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + match f.Value with + | NoCast -> Expression.LessThan (``member``, Expression.Constant f.Value) + | Enumerable -> Expression.LessThan (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)) + | NonEnumerableCast ``type`` -> Expression.LessThan ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) + | GreaterThanOrEqual f -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + match f.Value with + | NoCast -> Expression.GreaterThanOrEqual (``member``, Expression.Constant f.Value) + | Enumerable -> Expression.GreaterThanOrEqual (``member``, Expression.Constant (Values.normalizeOptional ``member``.Type f.Value)) + | NonEnumerableCast ``type`` -> Expression.GreaterThanOrEqual ((unsafeConvertTo ``type`` ``member``), Expression.Constant f.Value) + | LessThanOrEqual f -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + match f.Value with + | 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, comparer) -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + let comparison = + comparerToStringComparison comparer + |> 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 + |> ValueOption.defaultValue StringComparison.CurrentCulture + Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value, Expression.Constant comparison) + + | Contains (f, comparer) -> + let ``member`` = Expression.PropertyOrField (param, f.FieldName) + let isEnumerable (memberType : Type) = + not (Type.(=) (memberType, stringType)) + && iEnumerableType.IsAssignableFrom (memberType) + && memberType.GetInterfaces().Any (fun i -> isGenericIEnumerable i) + let normalizedValue = Values.normalizeOptional ``member``.Type f.Value + let callContains memberType = + let itemType = + if ``member``.Type.IsArray then + ``member``.Type.GetElementType () + else + ``member``.Type.GetGenericArguments()[0] + let valueType = + match normalizedValue with + | null -> itemType + | value -> value.GetType () + let castedMember = + if itemType = valueType then + ``member`` :> Expression + elif isEnumerableQuery then + let castMethod = getEnumerableCastMethod valueType + Expression.Call (castMethod, ``member``) + else + let castedEnumerableType = genericIEnumerableType.MakeGenericType ([| valueType |]) + unsafeConvertTo castedEnumerableType ``member`` + match getCollectionInstanceContainsMethod memberType with + | ValueNone -> + let enumerableContains = getEnumerableContainsMethod valueType + Expression.Call (enumerableContains, castedMember, Expression.Constant (normalizedValue)) + | ValueSome instanceContainsMethod -> Expression.Call (castedMember, instanceContainsMethod, Expression.Constant (normalizedValue)) + match ``member``.Member with + | :? PropertyInfo as prop when prop.PropertyType |> isEnumerable -> callContains prop.PropertyType + | :? FieldInfo as field when field.FieldType |> isEnumerable -> callContains field.FieldType + | _ -> + let unwrappedValue = Helpers.unwrap f.Value + let comparison = + comparerToStringComparison comparer + |> ValueOption.defaultValue StringComparison.CurrentCulture + Expression.Call ( + normalizeStringMemberExpr ``member``, + StringContainsMethod, + Expression.Constant (unwrappedValue :?> string, stringType), + Expression.Constant comparison + ) + | In f when not (f.Value.IsEmpty) -> + let ``member`` = + // Special case: "_" means the element itself, not a field property + if f.FieldName = "_" then + param.Value + else + Expression.PropertyOrField (param, f.FieldName) + let fieldType = ``member``.Type + let typedValues = materializeTypedInArray fieldType f.Value + let enumerableContains = getEnumerableContainsMethod fieldType + Expression.Call (enumerableContains, Expression.Constant typedValues, ``member``) + | In f -> Expression.Constant (false) + | OfTypes types -> + types + |> Seq.map (fun t -> buildTypeDiscriminatorCheck param t) + |> Seq.reduce (fun acc expr -> Expression.OrElse (acc, expr)) + | FilterField f -> + let paramType = param.Value.Type + match getFieldTypeAndOriginal paramType f.FieldName with + | ValueNone -> + // Fallback: just recurse (may fail downstream) + let paramExpr = Expression.PropertyOrField (param, f.FieldName) + buildFilterExpr isEnumerableQuery (SourceExpression paramExpr) buildTypeDiscriminatorCheck f.Value + | ValueSome (originalFieldType, unwrappedFieldType) -> + // Check if the UNWRAPPED type is enumerable + let isCollection = isEnumerableType unwrappedFieldType + + // Check if this is an option-wrapped collection + let isOptionWrapped = not (Type.(=) (originalFieldType, unwrappedFieldType)) + + if isCollection then + let effectiveType = unwrappedFieldType + match tryGetEnumerableElementType effectiveType with + | ValueNone -> + // Should not happen for isEnumerableType, but fallback to direct traversal + let paramExpr = Expression.PropertyOrField (param, f.FieldName) + buildFilterExpr isEnumerableQuery (SourceExpression paramExpr) buildTypeDiscriminatorCheck f.Value + | ValueSome elementType -> + // Create lambda parameter for element + let elemParam = Expression.Parameter (elementType, "x") + // Recursively build inner filter over element type + let innerExpr = buildFilterExpr false (SourceExpression elemParam) buildTypeDiscriminatorCheck f.Value + // Lambda: x => innerExpr + let funcGenericDef = typeof>.GetGenericTypeDefinition () + let lambdaType = funcGenericDef.MakeGenericType ([| elementType; typeof |]) + let lambda = Expression.Lambda (lambdaType, innerExpr, elemParam) :> Expression + + let rawCollExpr = Expression.PropertyOrField (param, f.FieldName) + + if isOptionWrapped then + // Option-wrapped collection: e.g., some_field : option> + // Strategy: Access the wrapped collection via .Value and pass it to Any with the predicate. + // If the option is None, accessing .Value throws NullReferenceException. + // We wrap the entire Any call in try-catch to safely return false for None, + // effectively treating None collections as "no match". + let anyMethod = getEnumerableAnyMethod elementType + let valueExpr = Expression.PropertyOrField (rawCollExpr, "Value") + let anyCall = Expression.Call (anyMethod, valueExpr, lambda) + + // Wrap in try-catch: try { Any(opt.Value, pred) } catch (NullReferenceException) { false } + let catchBlock = Expression.Catch (typeof, Expression.Constant (false)) + let tryExpr = Expression.TryCatch (anyCall, catchBlock) + tryExpr :> Expression + else + // Direct collection (not wrapped in option): pass directly to Enumerable.Any + let anyMethod = getEnumerableAnyMethod elementType + Expression.Call (anyMethod, rawCollExpr, lambda) + else + // Not a collection, treat as scalar + let paramExpr = Expression.PropertyOrField (param, f.FieldName) + buildFilterExpr isEnumerableQuery (SourceExpression paramExpr) buildTypeDiscriminatorCheck f.Value + + + type private CompareDiscriminatorExpressionVisitor<'T, 'D> + (compareDiscriminator : CompareDiscriminatorExpression<'T, 'D>, param : SourceExpression, value : obj) = + inherit ExpressionVisitor () + override _.VisitParameter (node) = + if node = compareDiscriminator.Parameters.[0] then + param.Value + elif node = compareDiscriminator.Parameters.[1] then + Expression.Constant (value) :> Expression + else + node :> Expression + + let enumerableQueryType = typedefof> + + let apply (options : ObjectListFilterLinqOptions<'T, 'D>) (filter : ObjectListFilter) (query : IQueryable<'T>) = + let isEnumerableQuery = query.GetType().GetGenericTypeDefinition () = enumerableQueryType + // Helper for discriminator comparison + let buildTypeDiscriminatorCheck (param : SourceExpression) (t : Type) = + match options.CompareDiscriminator, options.GetDiscriminatorValue with + | ValueNone, ValueNone -> + Expression.Equal ( + // Default discriminator property + Expression.PropertyOrField (param, "__typename"), + // Default discriminator value + Expression.Constant (t.FullName) + ) + :> Expression + | ValueSome discExpr, ValueNone -> + // Replace parameters from the original expression with our new ones + let replacer = CompareDiscriminatorExpressionVisitor (discExpr, param, t.FullName) + replacer.Visit discExpr.Body + | ValueNone, ValueSome discValueFn -> + let discriminatorValue = discValueFn t + Expression.Equal ( + // Default discriminator property + Expression.PropertyOrField (param, "__typename"), + // Provided discriminator value gathered from type + Expression.Constant (discriminatorValue) + ) + :> Expression + | ValueSome discExpr, ValueSome discValueFn -> + let discriminatorValue = discValueFn t + // Replace parameters from the original expression with our new ones + let replacer = CompareDiscriminatorExpressionVisitor (discExpr, param, discriminatorValue) + replacer.Visit discExpr.Body + let queryExpr = + let param = Expression.Parameter (typeof<'T>, "x") + let body = buildFilterExpr isEnumerableQuery (SourceExpression param) buildTypeDiscriminatorCheck filter + whereExpr<'T> query param body + // Create and execute the final expression + query.Provider.CreateQuery<'T> (queryExpr) + +[] +module ObjectListFilterExtensions = + + open ObjectListFilter + + type ObjectListFilter with + + /// + /// Applies the filter to a queryable with automatic type coercion of JSON primitives to CLR types. Supports , + /// , , , and F# discriminated unions. Pass + /// via ObjectListFilterLinqOptions constructor for custom serialization. + /// + /// + /// + /// // Basic usage - automatic coercion of string to Guid + /// let filter = "id" === "550e8400-e29b-41d4-a716-446655440000" + /// let users = filter.ApplyTo query + /// + /// // With custom JsonSerializerOptions + /// let opts = JsonSerializerOptions(PropertyNameCaseInsensitive = true) + /// let options = ObjectListFilterLinqOptions(opts) + /// let events = filter.ApplyTo(query, options) + /// + /// + member inline filter.ApplyTo<'T, 'D> (query : IQueryable<'T>, [] options : ObjectListFilterLinqOptions<'T, 'D> | null) = + let options = + options + |> ValueOption.ofObj + |> ValueOption.defaultValue ObjectListFilterLinqOptions<'T, 'D>.None + let filter = TypeCoercion.coerceFilter options.JsonOptions typeof<'T> filter + apply options filter query + + type IQueryable<'T> with + + /// + /// Applies the filter with automatic type coercion of JSON primitives to CLR types. Supports , , + /// , , and F# discriminated unions. Pass via + /// ObjectListFilterLinqOptions constructor for custom serialization. + /// + /// + /// + /// // Basic usage - automatic coercion of string to Guid + /// let filter = "id" === "550e8400-e29b-41d4-a716-446655440000" + /// let users = query.Apply filter + /// + /// // With custom JsonSerializerOptions + /// let opts = JsonSerializerOptions(PropertyNameCaseInsensitive = true) + /// let options = ObjectListFilterLinqOptions(opts) + /// let events = query.Apply(filter, options) + /// + /// + member inline query.Apply (filter : ObjectListFilter, [] options : ObjectListFilterLinqOptions<'T, 'D> | null) = + filter.ApplyTo (query, options) diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs index 813c3b7b..2fd6951d 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/SchemaDefinitions.fs @@ -20,7 +20,6 @@ type private ComparisonOperator = | LessThanOrEqual of string | In of string - let rec private coerceObjectListFilterInput (variables : Variables) inputValue : Result = let parseFieldCondition (s : string) = @@ -184,7 +183,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." diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/TypeCoercion.fs b/src/FSharp.Data.GraphQL.Server.Middleware/TypeCoercion.fs new file mode 100644 index 00000000..78cc0f25 --- /dev/null +++ b/src/FSharp.Data.GraphQL.Server.Middleware/TypeCoercion.fs @@ -0,0 +1,259 @@ +namespace FSharp.Data.GraphQL.Server.Middleware + +open System +open System.Buffers +open System.Collections.Generic +open System.Reflection +open System.Text.Json +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Extensions + +[] +module TypeCoercion = + + /// Case-insensitive instance property lookup. The middleware lowercases field names during + /// parsing, so we must also ignore casing here. + let propertyBindFlags = + BindingFlags.Public + ||| BindingFlags.Instance + ||| BindingFlags.IgnoreCase + + // Cached type references + let private stringType = typeof + + /// + /// If is voption, option, or Skippable, returns the inner type; otherwise . + /// + let tryUnwrapOption (t : Type) : Type voption = + if t.IsGenericType then + let fullName = t.GetGenericTypeDefinition().FullName + if + fullName.StartsWith ReflectionHelper.ValueOptionTypeName + || fullName.StartsWith ReflectionHelper.OptionTypeName + || fullName.StartsWith ReflectionHelper.SkippableTypeName + then + ValueSome (t.GetGenericArguments().[0]) + else + ValueNone + else + ValueNone + + let unwrapOption (t : Type) : Type = + tryUnwrapOption t |> ValueOption.defaultValue t + + /// + /// If is a generic collection, returns the element type. Handles both concrete collections (where IEnumerable is an + /// implemented interface) and properties typed directly as IEnumerable<T>. + /// + let tryUnwrapEnumerableElement (t : Type) : Type voption = + let isEnumerableInterface (i : Type) = + i.IsGenericType + && Type.(=) (i.GetGenericTypeDefinition (), typedefof>) + if Type.(=) (t, stringType) then + ValueNone + elif t.IsArray then + t.GetElementType () |> ValueOption.ofObj + elif isEnumerableInterface t then + ValueSome (t.GetGenericArguments()[0]) + else + t.GetInterfaces () + |> Array.vtryFind isEnumerableInterface + |> ValueOption.map (fun i -> i.GetGenericArguments()[0]) + + /// + /// Suffixes the middleware's parser preserves on FieldFilter.FieldName for scalar operators (e.g. meetingId_eq, + /// validFrom_gte). They must be stripped before resolving the actual CLR property. + /// These correspond to the lowercase variants produced after Phase 2 parsing in SchemaDefinitions.parseFieldCondition. Longer suffixes are + /// listed first to prevent shorter ones (e.g. _gt) from incorrectly matching longer ones (e.g. _gte). + /// + let operatorSuffixes = + [| + // String operators (case-insensitive variants) + FilterSuffixConstants.CI.StartsWithSuffix + FilterSuffixConstants.CI.EndsWithSuffix + FilterSuffixConstants.CI.SWSuffix + FilterSuffixConstants.CI.EWSuffix + FilterSuffixConstants.CI.ContainsSuffix + FilterSuffixConstants.CI.EqualsSuffix + FilterSuffixConstants.CI.EQSuffix + // String operators (case-sensitive variants) + FilterSuffixConstants.CS.StartsWithSuffix + FilterSuffixConstants.CS.EndsWithSuffix + FilterSuffixConstants.CS.SWSuffix + FilterSuffixConstants.CS.EWSuffix + FilterSuffixConstants.CS.ContainsSuffix + FilterSuffixConstants.CS.EqualsSuffix + FilterSuffixConstants.CS.EQSuffix + // Numeric/comparison operators (from root) + FilterSuffixConstants.GreaterThanOrEqualSuffix + FilterSuffixConstants.LessThanOrEqualSuffix + FilterSuffixConstants.GreaterThanSuffix + FilterSuffixConstants.LessThanSuffix + FilterSuffixConstants.GTESuffix + FilterSuffixConstants.LTESuffix + FilterSuffixConstants.GTSuffix + FilterSuffixConstants.LTSuffix + FilterSuffixConstants.InSuffix + |] + + let stripOperatorSuffix (fieldName : string) : string = + operatorSuffixes + |> Array.vtryFind (fun s -> fieldName.EndsWith (s, StringComparison.OrdinalIgnoreCase)) + |> ValueOption.map (fun s -> fieldName.Substring (0, fieldName.Length - s.Length)) + |> ValueOption.defaultValue fieldName + + /// + /// Writes a boxed GraphQL scalar primitive as a JSON token directly into . Strings become JSON strings; numbers and + /// booleans become raw JSON tokens. Returns true if the value was written; false if the type is unsupported. + /// + let private writeJsonValue (value : obj) (writer : Utf8JsonWriter) : bool = + match value with + | :? string as s -> + writer.WriteStringValue s + true + | :? bool as b -> + writer.WriteBooleanValue b + true + | :? int64 as n -> + writer.WriteNumberValue n + true + | :? int as n -> + writer.WriteNumberValue n + true + | :? double as n -> + writer.WriteNumberValue n + true + | :? float32 as n -> + writer.WriteNumberValue n + true + | :? decimal as n -> + writer.WriteNumberValue n + true + | _ -> + false + + // Suppress nullness warnings for the obj / objnull mixture. +#nowarn "3261" + /// + /// Tries to coerce a value into using STJ deserialization. Primitives are written directly as JSON bytes via + /// into an , then deserialized from ReadOnlySpan<byte>. + /// Already-correct values pass through unchanged. No intermediate string or is allocated. + /// + let tryCoerceValue (jsonOptions : JsonSerializerOptions voption) (targetType : Type) (value : objnull) : obj voption = + if isNull value then + ValueNone + elif targetType.IsInstanceOfType value then + ValueSome value + else + let buffer = ArrayBufferWriter 64 + use writer = new Utf8JsonWriter (buffer) + if not (writeJsonValue value writer) then + ValueNone + else + writer.Flush () + try + let opts = jsonOptions |> ValueOption.defaultValue JsonSerializerOptions.Default + JsonSerializer.Deserialize (buffer.WrittenSpan, targetType, opts) |> ValueSome + with _ -> + ValueNone + + /// + /// Coerces an entire tree recursively by resolving the entities's properties and converting filter values into the + /// property's CLR type. + /// + let rec coerceFilter (jsonOptions : JsonSerializerOptions voption) (entityType : Type) (filter : ObjectListFilter) : ObjectListFilter = + match filter with + | And (l, r) -> And (coerceFilter jsonOptions entityType l, coerceFilter jsonOptions entityType r) + | Or (l, r) -> Or (coerceFilter jsonOptions entityType l, coerceFilter jsonOptions entityType r) + | Not f -> Not (coerceFilter jsonOptions entityType f) + | OfTypes _ -> filter + | Equals (ff, cmp) -> + match entityType.GetProperty (stripOperatorSuffix ff.FieldName, propertyBindFlags) with + | null -> filter + | prop -> + let unwrapped = unwrapOption prop.PropertyType + match tryCoerceValue jsonOptions unwrapped (box ff.Value) with + | ValueNone -> filter + | ValueSome (:? IComparable as coerced) -> Equals ({ ff with Value = coerced }, cmp) + | ValueSome _ -> filter + | GreaterThan ff + | GreaterThanOrEqual ff + | LessThan ff + | LessThanOrEqual ff as originalFilter -> + match entityType.GetProperty (stripOperatorSuffix ff.FieldName, propertyBindFlags) with + | null -> filter + | prop -> + let unwrapped = unwrapOption prop.PropertyType + match tryCoerceValue jsonOptions unwrapped (box ff.Value) with + | ValueNone -> filter + | ValueSome (:? IComparable as coerced) -> + let coercedField = { ff with Value = coerced } + match originalFilter with + | GreaterThan _ -> GreaterThan coercedField + | GreaterThanOrEqual _ -> GreaterThanOrEqual coercedField + | LessThan _ -> LessThan coercedField + | LessThanOrEqual _ -> LessThanOrEqual coercedField + | _ -> filter + | ValueSome _ -> filter + | In ff -> + match entityType.GetProperty (stripOperatorSuffix ff.FieldName, propertyBindFlags) with + | null -> filter + | prop -> + let unwrapped = unwrapOption prop.PropertyType + + let struct (coercedValues, failedValues) = + ff.Value + |> List.fold + (fun struct (coerced, failed) value -> + match tryCoerceValue jsonOptions unwrapped value with + | ValueSome coercedValue -> (coercedValue :: coerced, failed) + | ValueNone -> struct (coerced, value :: failed)) + ([], []) + + match failedValues with + | [] -> In { ff with Value = List.rev coercedValues } + | _ -> + let failedValuesText = + failedValues + |> Seq.rev + |> Seq.map (sprintf "%A") + |> String.concat ", " + + invalidArg + (nameof filter) + ($"Unable to coerce one or more values for '{ff.FieldName}' to '{unwrapped.FullName}'. Uncoerced values: [{failedValuesText}]") + | StartsWith (ff, cmp) + | EndsWith (ff, cmp) as originalFilter -> + match entityType.GetProperty (stripOperatorSuffix ff.FieldName, propertyBindFlags) with + | null -> filter + | _ -> + match tryCoerceValue jsonOptions stringType (box ff.Value) with + | ValueNone -> filter + | ValueSome coerced -> + let coercedField = { ff with Value = coerced :?> string } + match originalFilter with + | StartsWith (_, cmp) -> StartsWith (coercedField, cmp) + | EndsWith (_, cmp) -> EndsWith (coercedField, cmp) + | _ -> filter + | Contains (ff, cmp) -> + match entityType.GetProperty (stripOperatorSuffix ff.FieldName, propertyBindFlags) with + | null -> filter + | prop -> + let unwrapped = unwrapOption prop.PropertyType + let coercionTarget = + match tryUnwrapEnumerableElement unwrapped with + | ValueSome elementType -> elementType + | ValueNone -> stringType + match tryCoerceValue jsonOptions coercionTarget (box ff.Value) with + | ValueNone -> filter + | ValueSome (:? IComparable as coerced) -> Contains ({ ff with Value = coerced }, cmp) + | ValueSome _ -> filter + | FilterField ff -> + match entityType.GetProperty (ff.FieldName, propertyBindFlags) with + | null -> filter + | prop -> + let unwrapped = unwrapOption prop.PropertyType + let nestedType = + tryUnwrapEnumerableElement unwrapped + |> ValueOption.defaultValue unwrapped + FilterField { FieldName = ff.FieldName; Value = coerceFilter jsonOptions nestedType ff.Value } diff --git a/src/FSharp.Data.GraphQL.Server.Middleware/TypeSystemExtensions.fs b/src/FSharp.Data.GraphQL.Server.Middleware/TypeSystemExtensions.fs index 955684e1..215e321f 100644 --- a/src/FSharp.Data.GraphQL.Server.Middleware/TypeSystemExtensions.fs +++ b/src/FSharp.Data.GraphQL.Server.Middleware/TypeSystemExtensions.fs @@ -3,9 +3,7 @@ namespace FSharp.Data.GraphQL.Server.Middleware open System open System.Collections.Immutable open System.Linq -open FsToolkit.ErrorHandling -open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types /// Contains extensions for the type system. diff --git a/src/FSharp.Data.GraphQL.Shared/Helpers/Extensions.fs b/src/FSharp.Data.GraphQL.Shared/Helpers/Extensions.fs index 603ba60f..b1a26099 100644 --- a/src/FSharp.Data.GraphQL.Shared/Helpers/Extensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/Helpers/Extensions.fs @@ -85,6 +85,35 @@ module Array = i <- i + 1 Array.sub temp 0 i + /// + /// Attempts to find the first element in an array that satisfies the given predicate. + /// + /// Function to test each element. + /// The input array. + /// ValueSome of the first matching element, or ValueNone if no match is found. + let vtryFind predicate (source : 'T array) = + let mutable result = ValueNone + let mutable i = 0 + while i < source.Length && result.IsNone do + if predicate source[i] then + result <- ValueSome source[i] + i <- i + 1 + result + + /// + /// Applies a function to each element of an array and returns the first result where the function returns ValueSome. + /// + /// Function to apply to each element. + /// The input array. + /// ValueSome of the first successful mapping result, or ValueNone if no match is found. + let vtryPick mapping (source : 'T array) = + let mutable result = ValueNone + let mutable i = 0 + while i < source.Length && result.IsNone do + result <- mapping source[i] + i <- i + 1 + result + module List = /// @@ -99,6 +128,35 @@ module List = |> List.filter (fun x -> not <| List.exists(fun y -> f(x) = f(y)) listy) uniqx @ listy + /// + /// Attempts to find the first element in a list that satisfies the given predicate. + /// + /// Function to test each element. + /// The input list. + /// ValueSome of the first matching element, or ValueNone if no match is found. + let rec vtryFind predicate (source : 'T list) = + match source with + | [] -> ValueNone + | head :: tail -> + if predicate head then + ValueSome head + else + vtryFind predicate tail + + /// + /// Applies a function to each element of a list and returns the first result where the function returns ValueSome. + /// + /// Function to apply to each element. + /// The input list. + /// ValueSome of the first successful mapping result, or ValueNone if no match is found. + let rec vtryPick mapping (source : 'T list) = + match source with + | [] -> ValueNone + | head :: tail -> + match mapping head with + | ValueSome result -> ValueSome result + | ValueNone -> vtryPick mapping tail + module Set = /// diff --git a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj index 1bc22455..7242322a 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -75,8 +75,14 @@ - - + + + + + + + + diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterComparerMappingTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterComparerMappingTests.fs new file mode 100644 index 00000000..c1ab6826 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterComparerMappingTests.fs @@ -0,0 +1,169 @@ +[] +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.ComparerMapping.Tests + +open System +open System.Collections +open System.Globalization +open Xunit +open FSharp.Data.GraphQL.Server.Middleware + +// ───────────────────────────────────────────────────────────────────────────── +// Singleton reference-equality branch +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``comparerToStringComparison maps well-known StringComparer instances`` () = + let currentCultureIsInvariant = + CultureInfo.CurrentCulture.CompareInfo.Equals CultureInfo.InvariantCulture.CompareInfo + + let testCases = + [ + yield (StringComparer.OrdinalIgnoreCase :> IComparer, StringComparison.OrdinalIgnoreCase) + yield (StringComparer.InvariantCultureIgnoreCase :> IComparer, StringComparison.InvariantCultureIgnoreCase) + yield (StringComparer.Ordinal :> IComparer, StringComparison.Ordinal) + yield (StringComparer.InvariantCulture :> IComparer, StringComparison.InvariantCulture) + // On environments where CurrentCulture == InvariantCulture (e.g. Ubuntu CI with no locale), + // StringComparer.Current* singletons ARE the same objects as StringComparer.Invariant*, + // so they can only map to Invariant* values. Skip those cases in such environments. + if not currentCultureIsInvariant then + yield (StringComparer.CurrentCultureIgnoreCase :> IComparer, StringComparison.CurrentCultureIgnoreCase) + yield (StringComparer.CurrentCulture :> IComparer, StringComparison.CurrentCulture) + ] + + for comparer, expected in testCases do + let actual = ObjectListFilter.comparerToStringComparison comparer |> wantValueSome + actual |> equals expected + +// ───────────────────────────────────────────────────────────────────────────── +// Each singleton must map to a distinct StringComparison value +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``comparerToStringComparison singleton mappings are all distinct`` () = + let currentCultureIsInvariant = + CultureInfo.CurrentCulture.CompareInfo.Equals CultureInfo.InvariantCulture.CompareInfo + + // On environments where CurrentCulture == InvariantCulture, Current* singletons are + // the same objects as Invariant* ones, so distinctness can only be checked for the + // remaining four singletons. + let singletons : IComparer list = + [ + yield StringComparer.OrdinalIgnoreCase + yield StringComparer.InvariantCultureIgnoreCase + yield StringComparer.Ordinal + yield StringComparer.InvariantCulture + if not currentCultureIsInvariant then + yield StringComparer.CurrentCultureIgnoreCase + yield StringComparer.CurrentCulture + ] + + let results = + singletons + |> List.map (fun c -> ObjectListFilter.comparerToStringComparison c |> wantValueSome) + + let distinct = results |> List.distinct + List.length distinct |> equals (List.length results) + +// ───────────────────────────────────────────────────────────────────────────── +// IsWellKnownCultureAwareComparer fallback path +// StringComparer.Create produces a non-singleton comparer; the singleton +// ReferenceEquals fast path is skipped and IsWellKnownCultureAwareComparer +// is used instead. +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``comparerToStringComparison maps non-singleton InvariantCulture comparer`` () = + let comparer = StringComparer.Create (CultureInfo.InvariantCulture, false) :> IComparer + // must NOT be the same object as the singleton + Assert.False (obj.ReferenceEquals (comparer, StringComparer.InvariantCulture :> obj)) + let result = ObjectListFilter.comparerToStringComparison comparer |> wantValueSome + result |> equals StringComparison.InvariantCulture + +[] +let ``comparerToStringComparison maps non-singleton InvariantCultureIgnoreCase comparer`` () = + let comparer = StringComparer.Create (CultureInfo.InvariantCulture, true) :> IComparer + Assert.False (obj.ReferenceEquals (comparer, StringComparer.InvariantCultureIgnoreCase :> obj)) + let result = ObjectListFilter.comparerToStringComparison comparer |> wantValueSome + result |> equals StringComparison.InvariantCultureIgnoreCase + +[] +let ``comparerToStringComparison maps non-singleton CurrentCulture comparer`` () = + // When CurrentCulture == InvariantCulture (e.g. Ubuntu CI), a non-singleton comparer + // created from CurrentCulture is indistinguishable from InvariantCulture and will + // legitimately map to InvariantCulture. + let comparer = StringComparer.Create (CultureInfo.CurrentCulture, false) :> IComparer + Assert.False (obj.ReferenceEquals (comparer, StringComparer.CurrentCulture :> obj)) + let result = ObjectListFilter.comparerToStringComparison comparer |> wantValueSome + let currentCultureIsInvariant = + CultureInfo.CurrentCulture.CompareInfo.Equals CultureInfo.InvariantCulture.CompareInfo + let expected = + if currentCultureIsInvariant then StringComparison.InvariantCulture + else StringComparison.CurrentCulture + result |> equals expected + +[] +let ``comparerToStringComparison maps non-singleton CurrentCultureIgnoreCase comparer`` () = + let comparer = StringComparer.Create (CultureInfo.CurrentCulture, true) :> IComparer + Assert.False (obj.ReferenceEquals (comparer, StringComparer.CurrentCultureIgnoreCase :> obj)) + let result = ObjectListFilter.comparerToStringComparison comparer |> wantValueSome + let currentCultureIsInvariant = + CultureInfo.CurrentCulture.CompareInfo.Equals CultureInfo.InvariantCulture.CompareInfo + let expected = + if currentCultureIsInvariant then StringComparison.InvariantCultureIgnoreCase + else StringComparison.CurrentCultureIgnoreCase + result |> equals expected + +// ───────────────────────────────────────────────────────────────────────────── +// Unknown / unsupported cases → ValueNone +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``comparerToStringComparison returns ValueNone for null`` () = + ObjectListFilter.comparerToStringComparison null |> wantValueNone + +[] +let ``comparerToStringComparison returns ValueNone for non-StringComparer IComparer`` () = + let customComparer = + { new IComparer with + member _.Compare (_, _) = 0 + } + ObjectListFilter.comparerToStringComparison customComparer |> wantValueNone + +[] +let ``comparerToStringComparison returns ValueNone for non-standard culture comparer`` () = + // A comparer for a specific non-current, non-invariant culture — the + // IsWellKnownCultureAwareComparer fallback cannot map it to any of the six + // StringComparison values, so it must return ValueNone. + let trCulture = CultureInfo.GetCultureInfo "tr-TR" + // Only run this test when the test host is not Turkish (otherwise CurrentCulture == tr-TR + // and the result would legitimately be CurrentCulture). + if not (CultureInfo.CurrentCulture.Name.StartsWith "tr") then + let comparer = StringComparer.Create (trCulture, false) :> IComparer + ObjectListFilter.comparerToStringComparison comparer |> wantValueNone + +// ───────────────────────────────────────────────────────────────────────────── +// Determinism: calling comparerToStringComparison twice on the same instance +// must return the same result +// ───────────────────────────────────────────────────────────────────────────── + +[] +let ``comparerToStringComparison is deterministic for singletons`` () = + let currentCultureIsInvariant = + CultureInfo.CurrentCulture.CompareInfo.Equals CultureInfo.InvariantCulture.CompareInfo + + let singletons : IComparer list = + [ + yield StringComparer.OrdinalIgnoreCase + yield StringComparer.InvariantCultureIgnoreCase + yield StringComparer.Ordinal + yield StringComparer.InvariantCulture + if not currentCultureIsInvariant then + yield StringComparer.CurrentCultureIgnoreCase + yield StringComparer.CurrentCulture + ] + + for comparer in singletons do + let first = ObjectListFilter.comparerToStringComparison comparer + let second = ObjectListFilter.comparerToStringComparison comparer + first |> equals second diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqGenerateTests.fs similarity index 96% rename from tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs rename to tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqGenerateTests.fs index c8a39200..27d5332d 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqGenerateTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqGenerateTests.fs @@ -1,4 +1,6 @@ -module FSharp.Data.GraphQL.Tests.ObjectListFilterLinqGenerateTests +[] +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.Linq.GenerateTests open Xunit open System @@ -99,6 +101,7 @@ let cosmosClient = new CosmosClient ("https://localhost:8081/", "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", options) let container = cosmosClient.GetContainer ("database", "container") let filterOptions = ObjectListFilterLinqOptions.None +let filterOptionsWithConverters = ObjectListFilterLinqOptions (jsonOptions) [] let ``ObjectListFilter works with Equals operator for ValidStringStruct`` () = @@ -245,13 +248,20 @@ let ``ObjectListFilter works with Contains operator for ValidStringStruct list`` equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE ARRAY_CONTAINS(root["validStringStructList"], "athan")""" [] -let ``ObjectListFilter works with In operator for ValidStringStruct list`` () = +let ``ObjectListFilter works with In operator for ValidStringStruct list when converters are provided`` () = let queryable = container.GetItemLinqQueryable () let filter = In { FieldName = "validStringStruct"; Value = [ "athan"; "gaja" ] } - let filterQuery = queryable.Apply (filter, filterOptions) + let filterQuery = queryable.Apply (filter, filterOptionsWithConverters) let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE ARRAY_CONTAINS([ "athan", "gaja" ], root["validStringStruct"])""" +[] +let ``ObjectListFilter works with In operator for ValidStringStruct list when converters are not provided`` () = + let queryable = container.GetItemLinqQueryable () + let filter = In { FieldName = "validStringStruct"; Value = [ "athan"; "gaja" ] } + let ex = Assert.Throws(fun () -> queryable.Apply (filter, filterOptions) |> ignore) + Assert.Contains ("Uncoerced values", ex.Message) + [] let ``ObjectListFilter works with In operator for empty ValidStringStruct list`` () = let queryable = container.GetItemLinqQueryable () diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqTests.fs similarity index 99% rename from tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs rename to tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqTests.fs index bdd14b70..6990aefe 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilterLinqTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqTests.fs @@ -1,4 +1,6 @@ -module FSharp.Data.GraphQL.Tests.ObjectListFilterLinqTests +[] +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.Linq.Tests open Xunit open System diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterFieldEnumerableTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterFieldEnumerableTests.fs new file mode 100644 index 00000000..31bc7e0a --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterFieldEnumerableTests.fs @@ -0,0 +1,414 @@ +[] +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.FieldEnumerableTests + +open System +open System.Linq +open System.Text.Json +open Xunit +open FSharp.Data.GraphQL.Shared +open FSharp.Data.GraphQL.Server.Middleware + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Test types for FilterField over enumerables +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +/// Entity with array collection. +type EntityWithArray = { + Id : int + Name : string + Tags : string array +} + +/// Entity with IEnumerable collection (via seq). +type EntityWithIEnumerable = { + Id : int + Name : string + Categories : string list +} + +/// Entity with option-wrapped array. +type EntityWithOptionalArray = { + Id : int + Name : string + OptionalTags : string array option +} + +/// Entity with nested object properties. +type NestedScore = { + Subject : string + Value : int +} + +/// Entity with nested collections. +type EntityWithNestedCollection = { + Id : int + Name : string + Scores : NestedScore array +} + +/// Entity using public fields instead of properties (to test field support in cache). +type EntityWithFields = + val Id : int + val mutable Name : string + val mutable Tags : string array + + new (id, name, tags) = { + Id = id + Name = name + Tags = tags + } + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Test data +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +let filterOptions = ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) +let filterOptionsIEnum = ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) +let filterOptionsOptArray = ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) +let filterOptionsNested = ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) +let filterOptionsFields = ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Test data: arrays +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +let arrayTestData = [| + { Id = 1; Name = "Alice"; Tags = [| "admin"; "user" |] } + { Id = 2; Name = "Bob"; Tags = [| "user" |] } + { Id = 3; Name = "Charlie"; Tags = [||] } + { Id = 4; Name = "Diana"; Tags = [| "moderator"; "user" |] } +|] + +let applyFilterArray (filter : ObjectListFilter) = + filter.ApplyTo (arrayTestData.AsQueryable (), filterOptions) + |> Seq.toList + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Test data: IEnumerable (list) +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +let ienumeTestData = [| + { Id = 1; Name = "Alice"; Categories = [ "Books"; "Movies" ] } + { Id = 2; Name = "Bob"; Categories = [ "Sports" ] } + { Id = 3; Name = "Charlie"; Categories = [] } + { Id = 4; Name = "Diana"; Categories = [ "Music"; "Sports" ] } +|] + +let applyFilterIEnum (filter : ObjectListFilter) = + filter.ApplyTo (ienumeTestData.AsQueryable (), filterOptionsIEnum) + |> Seq.toList + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Test data: option-wrapped arrays +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +let optArrayTestData = [| + { Id = 1; Name = "Alice"; OptionalTags = Some [| "admin"; "user" |] } + { Id = 2; Name = "Bob"; OptionalTags = Some [| "user" |] } + { Id = 3; Name = "Charlie"; OptionalTags = None } + { Id = 4; Name = "Diana"; OptionalTags = Some [||] } +|] + +let applyFilterOptArray (filter : ObjectListFilter) = + filter.ApplyTo (optArrayTestData.AsQueryable (), filterOptionsOptArray) + |> Seq.toList + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Test data: nested collections +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +let nestedTestData = [| + { Id = 1; Name = "Alice"; Scores = [| { Subject = "Math"; Value = 95 }; { Subject = "Science"; Value = 88 } |] } + { Id = 2; Name = "Bob"; Scores = [| { Subject = "Math"; Value = 75 }; { Subject = "Science"; Value = 82 } |] } + { Id = 3; Name = "Charlie"; Scores = [| { Subject = "English"; Value = 90 } |] } + { Id = 4; Name = "Diana"; Scores = [||] } +|] + +let applyFilterNested (filter : ObjectListFilter) = + filter.ApplyTo (nestedTestData.AsQueryable (), filterOptionsNested) + |> Seq.toList + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Test data: public fields (not properties) +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +let fieldsTestData = [| + EntityWithFields(1, "Alice", [| "admin"; "user" |]) + EntityWithFields(2, "Bob", [| "user" |]) + EntityWithFields(3, "Charlie", [||]) + EntityWithFields(4, "Diana", [| "moderator"; "user" |]) +|] + +let applyFilterFields (filter : ObjectListFilter) = + filter.ApplyTo (fieldsTestData.AsQueryable (), filterOptionsFields) + |> Seq.toList + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Helpers for scalar collection filters +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +/// Creates a filter for scalar collections (array, list, etc.) with a nested operator. +/// +/// IMPORTANT: When filtering scalar collection elements (e.g., string array or IEnumerable), +/// the inner filter's FieldName must use "_" as a placeholder, since scalar elements don't have properties. +/// +/// Example: +/// scalarCollectionFilter "Tags" (In { FieldName = "_"; Value = [box "admin"] }) +/// // Filters entities where the Tags collection contains "admin" +/// +/// The middleware's Enumerable.Any operator treats "_" as a no-op and evaluates the scalar element directly. +let scalarCollectionFilter (fieldName : string) (innerFilter : ObjectListFilter) : ObjectListFilter = + FilterField { + FieldName = fieldName + Value = innerFilter + } + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Tests: FilterField over arrays +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +[] +let ``FilterField over array with In finds matching elements`` () = + let filter = + scalarCollectionFilter + "Tags" + (In { FieldName = "_"; Value = [ box "admin" ] }) + let result = applyFilterArray filter + + Assert.Equal (1, result.Length) + Assert.Equal ("Alice", result[0].Name) + +[] +let ``FilterField over array with In finds multiple matching values`` () = + let filter = + scalarCollectionFilter + "Tags" + (In { FieldName = "_"; Value = [ box "user"; box "moderator" ] }) + let result = applyFilterArray filter + + Assert.Equal (3, result.Length) + let names = result |> List.map (fun e -> e.Name) |> List.sort |> List.toSeq + Assert.Equal> (seq { "Alice"; "Bob"; "Diana" }, names) + +[] +let ``FilterField over array with In excludes empty arrays`` () = + let filter = + scalarCollectionFilter + "Tags" + (In { FieldName = "_"; Value = [ box "user" ] }) + let result = applyFilterArray filter + + let names = result |> List.map (fun e -> e.Name) + Assert.False (names.Contains "Charlie") + +[] +let ``FilterField over array with Equals finds exact match`` () = + let filter = + scalarCollectionFilter + "Tags" + (Equals ({ FieldName = "_"; Value = "admin" }, null)) + let result = applyFilterArray filter + + Assert.Equal (1, result.Length) + Assert.Equal ("Alice", result[0].Name) + +[] +let ``FilterField over array with StartsWith finds prefix matches`` () = + // Note: StartsWith only works on string members, not on the element itself (_) + // This test documents that FilterField with StartsWith requires a named property + // and won't work with scalar element filters + let filter = + scalarCollectionFilter + "Tags" + (In { FieldName = "_"; Value = [ box "user"; box "admin" ] }) + let result = applyFilterArray filter + + Assert.Equal (3, result.Length) + let names = result |> List.map (fun e -> e.Name) |> List.sort |> List.toSeq + Assert.Equal> (seq { "Alice"; "Bob"; "Diana" }, names) + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Tests: FilterField over IEnumerable (list) +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +[] +let ``FilterField over IEnumerable list with In finds matching elements`` () = + let filter = + scalarCollectionFilter + "Categories" + (In { FieldName = "_"; Value = [ box "Sports" ] }) + let result = applyFilterIEnum filter + + Assert.Equal (2, result.Length) + let names = result |> List.map (fun e -> e.Name) |> List.sort |> List.toSeq + Assert.Equal> (seq { "Bob"; "Diana" }, names) + +[] +let ``FilterField over IEnumerable with Equals finds exact match`` () = + let filter = + scalarCollectionFilter + "Categories" + (Equals ({ FieldName = "_"; Value = "Books" }, null)) + let result = applyFilterIEnum filter + + Assert.Equal (1, result.Length) + Assert.Equal ("Alice", result[0].Name) + +[] +let ``FilterField over IEnumerable excludes empty collections`` () = + let filter = + scalarCollectionFilter + "Categories" + (In { FieldName = "_"; Value = [ box "Books" ] }) + let result = applyFilterIEnum filter + + let names = result |> List.map (fun e -> e.Name) + Assert.False (names.Contains "Charlie") + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Tests: FilterField over option-wrapped arrays +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +[] +let ``FilterField over optional array with In finds matching elements`` () = + let filter = + scalarCollectionFilter + "OptionalTags" + (In { FieldName = "_"; Value = [ box "admin" ] }) + let result = applyFilterOptArray filter + + Assert.Equal (1, result.Length) + Assert.Equal ("Alice", result[0].Name) + +[] +let ``FilterField over optional array skips None values`` () = + let filter = + scalarCollectionFilter + "OptionalTags" + (In { FieldName = "_"; Value = [ box "user" ] }) + let result = applyFilterOptArray filter + + let names = result |> List.map (fun e -> e.Name) + Assert.False (names.Contains "Charlie") // Charlie has None + +[] +let ``FilterField over optional array skips empty inner arrays`` () = + let filter = + scalarCollectionFilter + "OptionalTags" + (In { FieldName = "_"; Value = [ box "admin" ] }) + let result = applyFilterOptArray filter + + let names = result |> List.map (fun e -> e.Name) + Assert.False (names.Contains "Diana") // Diana has Some [||] + +[] +let ``FilterField over optional array with Equals finds exact match in Some`` () = + let filter = + scalarCollectionFilter + "OptionalTags" + (Equals ({ FieldName = "_"; Value = "user" }, null)) + let result = applyFilterOptArray filter + + Assert.Equal (2, result.Length) + let names = result |> List.map (fun e -> e.Name) |> List.sort |> List.toSeq + Assert.Equal> (seq { "Alice"; "Bob" }, names) + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Tests: FilterField with nested collection properties +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +[] +let ``FilterField over nested collection with nested Equals finds by nested property`` () = + let filter = + FilterField { + FieldName = "Scores" + Value = FilterField { + FieldName = "Subject" + Value = Equals ({ FieldName = "_"; Value = "Math" }, null) + } + } + let result = applyFilterNested filter + + Assert.Equal (2, result.Length) + let names = result |> List.map (fun e -> e.Name) |> List.sort |> List.toSeq + Assert.Equal> (seq { "Alice"; "Bob" }, names) + +[] +let ``FilterField over nested collection with GreaterThan`` () = + let filter = + FilterField { + FieldName = "Scores" + Value = GreaterThan { FieldName = "Value"; Value = 88 } + } + let result = applyFilterNested filter + + Assert.Equal (2, result.Length) + let names = result |> List.map (fun e -> e.Name) |> List.sort |> List.toSeq + Assert.Equal> (seq { "Alice"; "Charlie" }, names) + +[] +let ``FilterField over nested collection excludes empty nested arrays`` () = + let filter = + FilterField { + FieldName = "Scores" + Value = Equals ({ FieldName = "Subject"; Value = "Math" }, null) + } + let result = applyFilterNested filter + + let names = result |> List.map (fun e -> e.Name) + Assert.False (names.Contains "Diana") // Diana has empty Scores + +[] +let ``FilterField over nested collection with multiple criteria`` () = + let filter = + FilterField { + FieldName = "Scores" + Value = And ( + GreaterThan { FieldName = "Value"; Value = 70 }, + Equals ({ FieldName = "Subject"; Value = "Math" }, null) + ) + } + let result = applyFilterNested filter + + Assert.Equal (2, result.Length) + let names = result |> List.map (fun e -> e.Name) |> List.sort |> List.toSeq + Assert.Equal> (seq { "Alice"; "Bob" }, names) + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Tests: FilterField over public fields (verifying field resolution in cache) +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +[] +let ``FilterField over public field array with In finds matching elements`` () = + let filter = + scalarCollectionFilter + "Tags" + (In { FieldName = "_"; Value = [ box "admin" ] }) + let result = applyFilterFields filter + + Assert.Equal (1, result.Length) + Assert.Equal ("Alice", result[0].Name) + +[] +let ``FilterField over public field array with Equals finds exact match`` () = + let filter = + scalarCollectionFilter + "Tags" + (Equals ({ FieldName = "_"; Value = "moderator" }, null)) + let result = applyFilterFields filter + + Assert.Equal (1, result.Length) + Assert.Equal ("Diana", result[0].Name) + +[] +let ``FilterField over public field array excludes empty fields`` () = + let filter = + scalarCollectionFilter + "Tags" + (In { FieldName = "_"; Value = [ box "user" ] }) + let result = applyFilterFields filter + + let names = result |> List.map (fun e -> e.Name) + Assert.False (names.Contains "Charlie") diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterInOperatorTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterInOperatorTests.fs new file mode 100644 index 00000000..400f04a6 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterInOperatorTests.fs @@ -0,0 +1,235 @@ +[] +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.InOperatorTests + +open System +open System.Linq +open System.Linq.Expressions +open Xunit +open FSharp.Data.GraphQL.Shared +open FSharp.Data.GraphQL.Server.Middleware +open FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.Common + +// ────────────────────────────────────────────────────────────────────────────── +// In operator test cases +// Covers all coercion types: CLR enum, DU-as-enum, Guid, single-case DU, and primitives +// ────────────────────────────────────────────────────────────────────────────── + +/// Minimal entity model used to validate `In` translation for nullable fields. +type NullableEntity = { + /// Primary identifier used in test assertions. + Id: int + /// Nullable scalar field used to assert typed `Contains>` expression generation. + MaybeId: Nullable +} + +/// Query options for nullable-expression tests with JSON coercion enabled. +let private nullableOptions = + ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) + +/// In-memory source for nullable-field `In` tests. +let private nullableData = + [| + { Id = 1; MaybeId = Nullable 1 } + { Id = 2; MaybeId = Nullable() } + { Id = 3; MaybeId = Nullable 3 } + |] + +/// Applies an `ObjectListFilter` to the nullable-field test dataset. +let private applyNullableFilter (filter : ObjectListFilter) = + filter.ApplyTo (nullableData.AsQueryable (), nullableOptions) |> Seq.toList + +/// Traverses an expression tree and returns the first `Enumerable.Contains` call node. +let private tryFindEnumerableContainsCall (expr : Expression) : MethodCallExpression option = + let rec find (node : Expression) = + match node with + | :? MethodCallExpression as call + when call.Method.Name = "Contains" + && call.Method.DeclaringType = typeof + && call.Arguments.Count = 2 -> + Some call + | :? MethodCallExpression as call -> + let fromObject = + if isNull call.Object then None else find call.Object + match fromObject with + | Some found -> Some found + | None -> call.Arguments |> Seq.tryPick find + | :? UnaryExpression as unary -> find unary.Operand + | :? LambdaExpression as lambda -> find lambda.Body + | :? BinaryExpression as binary -> + match find binary.Left with + | Some found -> Some found + | None -> find binary.Right + | :? MemberExpression as memberExpr -> + if isNull memberExpr.Expression then None else find memberExpr.Expression + | _ -> None + find expr + +/// Builds a filtered query and extracts the generated `Enumerable.Contains` call from its expression tree. +let private findEnumerableContainsCall<'T, 'D> (options : ObjectListFilterLinqOptions<'T, 'D>) (filter : ObjectListFilter) = + let query = Enumerable.Empty<'T>().AsQueryable() + let result = query.Apply (filter, options) + match tryFindEnumerableContainsCall result.Expression with + | Some call -> call + | None -> + fail "Expected to find Enumerable.Contains call in generated expression tree" + Unchecked.defaultof + +/// Asserts that `In` is translated to a strongly typed `Enumerable.Contains` expression. +/// Validates method generic argument, typed values container, and non-boxed member argument. +let private assertTypedInExpression<'T, 'D> + (options : ObjectListFilterLinqOptions<'T, 'D>) + (filter : ObjectListFilter) + (expectedElementType : Type) + = + let containsCall = findEnumerableContainsCall options filter + containsCall.Method.GetGenericArguments().[0] |> equals expectedElementType + + let valuesArgType = containsCall.Arguments.[0].Type + if valuesArgType = typeof || valuesArgType = typeof then + fail $"Expected a strongly typed values argument, but got {valuesArgType.FullName}" + + let actualElementType = + if valuesArgType.IsArray then + valuesArgType.GetElementType () + elif valuesArgType.IsGenericType then + valuesArgType.GetGenericArguments().[0] + else + fail $"Expected array or generic collection values argument, got {valuesArgType.FullName}" + Unchecked.defaultof + + actualElementType |> equals expectedElementType + + match containsCall.Arguments.[1] with + | :? UnaryExpression as unary when unary.NodeType = ExpressionType.Convert && unary.Type = typeof -> + fail "Expected In member argument to remain strongly typed without boxing to object" + | _ -> () + +[] +let ``In operator coerces string primitives`` () = + let filter = In { FieldName = "name"; Value = [ box "Alice"; box "Bob" ] } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Bob" ] + +[] +let ``In operator coerces int primitives`` () = + let filter = In { FieldName = "id"; Value = [ box 1; box 3 ] } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Id) |> List.sort |> equals [ 1; 3 ] + +[] +let ``In operator coerces CLR enum`` () = + let filter = In { FieldName = "color"; Value = [ box "Red"; box "Blue" ] } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Charlie" ] + +[] +let ``In operator coerces DU-as-enum`` () = + let filter = In { FieldName = "status"; Value = [ box "Active"; box "Pending" ] } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Charlie" ] + +[] +let ``In operator coerces Guid`` () = + let filter = + In { + FieldName = "guidField" + Value = [ + box "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + box "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + ] + } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Bob" ] + +[] +let ``In operator coerces single-case DU wrapping string`` () = + let filter = In { FieldName = "wrappedName"; Value = [ box "Alice"; box "Charlie" ] } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Charlie" ] + +[] +let ``In operator coerces single-case DU wrapping int`` () = + let filter = In { FieldName = "wrappedScore"; Value = [ box 10; box 30 ] } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Charlie" ] + +[] +let ``In operator coerces single-case DU wrapping Guid`` () = + let filter = + In { + FieldName = "wrappedGuid" + Value = [ box "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; box "cccccccc-cccc-cccc-cccc-cccccccccccc" ] + } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Charlie" ] + +[] +let ``In operator coerces Nullable int primitives`` () = + let filter = In { FieldName = "maybeId"; Value = [ box 1; box 3 ] } + let result = applyNullableFilter filter + result |> List.map (fun e -> e.Id) |> List.sort |> equals [ 1; 3 ] + +[] +let ``In operator expression uses typed contains for string primitive field`` () = + let filter = In { FieldName = "name"; Value = [ box "Alice"; box "Bob" ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for int primitive field`` () = + let filter = In { FieldName = "id"; Value = [ box 1; box 3 ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for CLR enum field`` () = + let filter = In { FieldName = "color"; Value = [ box "Red"; box "Blue" ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for fieldless DU field`` () = + let filter = In { FieldName = "status"; Value = [ box "Active"; box "Pending" ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for Guid field`` () = + let filter = In { FieldName = "guidField"; Value = [ box "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for single-case DU string field`` () = + let filter = In { FieldName = "wrappedName"; Value = [ box "Alice"; box "Charlie" ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for single-case DU int field`` () = + let filter = In { FieldName = "wrappedScore"; Value = [ box 10; box 30 ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for single-case DU Guid field`` () = + let filter = In { FieldName = "wrappedGuid"; Value = [ box "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for option field`` () = + let filter = In { FieldName = "optionName"; Value = [ box "Alice"; box "Charlie" ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for voption field`` () = + let filter = In { FieldName = "vOptionId"; Value = [ box 1; box 3 ] } + assertTypedInExpression filterOptions filter typeof + +[] +let ``In operator expression uses typed contains for Nullable field`` () = + let filter = In { FieldName = "maybeId"; Value = [ box 1; box 3 ] } + assertTypedInExpression nullableOptions filter typeof> + diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterOptionCollectionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterOptionCollectionTests.fs new file mode 100644 index 00000000..d8b32768 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterOptionCollectionTests.fs @@ -0,0 +1,106 @@ +[] +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.OptionCollectionTests + +open System +open System.Linq +open System.Text.Json +open Xunit +open FSharp.Data.GraphQL.Shared +open FSharp.Data.GraphQL.Server.Middleware + +// ──────────────────────────────────────────────────────────────────────────────────── +// Test types for option collection filters +// ──────────────────────────────────────────────────────────────────────────────────── + +/// Tags wrapped in an option — tests FilterField with optional collection. +type OptionalTagsEntity = { + Id : int + Name : string + /// Optional list of tags — tests the edge case where the field itself is optional + OptionalTags : string list option +} + +// ──────────────────────────────────────────────────────────────────────────────────── +// Test data +// ──────────────────────────────────────────────────────────────────────────────────── + +let filterOptions = + ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) + +let testData = [| + { Id = 1; Name = "Alice"; OptionalTags = Some [ "admin"; "user" ] } + { Id = 2; Name = "Bob"; OptionalTags = Some [ "user" ] } + { Id = 3; Name = "Charlie"; OptionalTags = None } + { Id = 4; Name = "Diana"; OptionalTags = Some [] } +|] + +let applyFilter (filter : ObjectListFilter) = + filter.ApplyTo (testData.AsQueryable (), filterOptions) + |> Seq.toList + +// ──────────────────────────────────────────────────────────────────────────────────── +// Tests for FilterField with optional collections +// ──────────────────────────────────────────────────────────────────────────────────── + +[] +let ``FilterField on optional collection with In operator returns entities where tag matches`` () = + // Regression test: FilterField on optional collection should unwrap the option + // and correctly apply the nested filter to the contained collection + let filter = + FilterField { + FieldName = "OptionalTags" + Value = In { FieldName = "_"; Value = [ box "admin" ] } + } + let result = applyFilter filter + + Assert.Equal (1, result.Length) + Assert.Equal> ([| "Alice" |] :> seq<_>, result |> List.map (fun e -> e.Name) |> List.toSeq) + +[] +let ``FilterField on optional collection with multiple values`` () = + let filter = + FilterField { + FieldName = "OptionalTags" + Value = In { FieldName = "_"; Value = [ box "admin"; box "moderator" ] } + } + let result = applyFilter filter + + Assert.Equal (1, result.Length) + Assert.Equal> ([| "Alice" |] :> seq<_>, result |> List.map (fun e -> e.Name) |> List.toSeq) + +[] +let ``FilterField on optional collection skips None values`` () = + let filter = + FilterField { + FieldName = "OptionalTags" + Value = In { FieldName = "_"; Value = [ box "user" ] } + } + let result = applyFilter filter + + Assert.Equal (2, result.Length) + Assert.Equal> ([| "Alice"; "Bob" |] :> seq<_>, result |> Seq.map (fun e -> e.Name) |> Seq.sort) + +[] +let ``FilterField on optional collection with empty list`` () = + let filter = + FilterField { + FieldName = "OptionalTags" + Value = In { FieldName = "_"; Value = [ box "user" ] } + } + let result = applyFilter filter + + let names = result |> List.map (fun e -> e.Name) + Assert.False (names.Contains "Diana") + +[] +let ``FilterField on optional collection with Equals operator`` () = + let filter = + FilterField { + FieldName = "OptionalTags" + Value = Equals ({ FieldName = "_"; Value = "user" }, null) + } + let result = applyFilter filter + + Assert.Equal (2, result.Length) + Assert.Equal> ([| "Alice"; "Bob" |] :> seq<_>, result |> Seq.map (fun e -> e.Name) |> Seq.sort) diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterTests.fs new file mode 100644 index 00000000..e37fe18a --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionFilterTests.fs @@ -0,0 +1,231 @@ +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.FilterTests + +open Xunit +open System +open FSharp.Data.GraphQL.Server.Middleware +open FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.Common + +// ────────────────────────────────────────────────────────────────────────────── +// Equals operator +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``coerceFilter coerces string to Guid for Equals`` () = + let filter = Equals ({ FieldName = "guidField"; Value = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Alice" + +[] +let ``coerceFilter coerces string to CLR enum for Equals`` () = + let filter = Equals ({ FieldName = "color"; Value = "Green" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Bob" + +[] +let ``coerceFilter coerces int to CLR enum for Equals`` () = + let filter = Equals ({ FieldName = "color"; Value = 2 }, null) // Blue = 2 + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Charlie" + +[] +let ``coerceFilter coerces string to DU-as-enum for Equals`` () = + let filter = Equals ({ FieldName = "status"; Value = "Active" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Alice" + +[] +let ``coerceFilter coerces string to single-case DU wrapping string for Equals`` () = + let filter = Equals ({ FieldName = "wrappedName"; Value = "Bob" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Bob" + +[] +let ``coerceFilter coerces int to single-case DU wrapping int for Equals`` () = + let filter = Equals ({ FieldName = "wrappedScore"; Value = 30 }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Charlie" + +[] +let ``coerceFilter coerces int64 to single-case DU wrapping int64 for Equals`` () = + let filter = Equals ({ FieldName = "wrappedLong"; Value = 200L }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Bob" + +[] +let ``coerceFilter coerces string to single-case DU wrapping Guid for Equals`` () = + let filter = Equals ({ FieldName = "wrappedGuid"; Value = "cccccccc-cccc-cccc-cccc-cccccccccccc" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Charlie" + +[] +let ``coerceFilter passes through bool for Equals`` () = + let filter = Equals ({ FieldName = "isActive"; Value = true }, null) + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.forall (fun e -> e.IsActive) |> equals true + +// ────────────────────────────────────────────────────────────────────────────── +// comparison operators (GreaterThan / LessThan) +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``coerceFilter coerces int to decimal for GreaterThanOrEqual`` () = + // Score: Alice=100, Bob=200, Charlie=300. >= 200 -> Bob and Charlie + let filter = GreaterThanOrEqual { FieldName = "score"; Value = 200 } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Bob"; "Charlie" ] + +[] +let ``coerceFilter coerces string to DateTime for GreaterThan`` () = + // CreatedAt: Alice=2024-01-01, Bob=2024-02-01, Charlie=2024-03-01. > 2024-01-15 + let filter = GreaterThan { FieldName = "createdAt"; Value = "2024-01-15T00:00:00" } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Bob"; "Charlie" ] + +[] +let ``coerceFilter coerces string to DateOnly for LessThan`` () = + // BirthDate: Alice=1990-05-15, Bob=1985-08-20, Charlie=2000-12-31. < 2000-01-01 + let filter = LessThan { FieldName = "birthDate"; Value = "2000-01-01" } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Bob" ] + +[] +let ``coerceFilter coerces string to TimeOnly for GreaterThan`` () = + // AlarmTime: Alice=08:00, Bob=09:00, Charlie=10:00. > 08:30 -> Bob and Charlie + let filter = GreaterThan { FieldName = "alarmTime"; Value = "08:30:00" } + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Bob"; "Charlie" ] + +// ────────────────────────────────────────────────────────────────────────────── +// option / voption field unwrapping +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``coerceFilter coerces string through option wrapper for Equals`` () = + let filter = Equals ({ FieldName = "optionName"; Value = "Alice" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Alice" + +[] +let ``coerceFilter coerces int through voption wrapper for Equals`` () = + let filter = Equals ({ FieldName = "vOptionId"; Value = 3 }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Charlie" + +// ────────────────────────────────────────────────────────────────────────────── +// string operators +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``coerceFilter handles StartsWith for plain string field`` () = + let filter = StartsWith ({ FieldName = "name"; Value = "Al" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Alice" + +[] +let ``coerceFilter handles EndsWith for plain string field`` () = + let filter = EndsWith ({ FieldName = "name"; Value = "ie" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Charlie" + +[] +let ``coerceFilter handles Contains for plain string field`` () = + let filter = Contains ({ FieldName = "name"; Value = "ob" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Bob" + +[] +let ``coerceFilter handles Contains for list field (element membership)`` () = + // Tags: Alice=["admin";"user"], Bob=["user"], Charlie=["moderator";"user"] + let filter = Contains ({ FieldName = "tags"; Value = "admin" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Alice" + +[] +let ``coerceFilter handles Contains for list field matching multiple entities`` () = + let filter = Contains ({ FieldName = "tags"; Value = "user" }, null) + let result = applyFilter filter + result |> List.length |> equals 3 + +// ────────────────────────────────────────────────────────────────────────────── +// AND / OR / NOT combinators +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``coerceFilter coerces values inside AND`` () = + let filter = + And ( + Equals ({ FieldName = "color"; Value = "Red" }, null), + Equals ({ FieldName = "status"; Value = "Active" }, null) + ) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Alice" + +[] +let ``coerceFilter coerces values inside OR`` () = + let filter = + Or ( + Equals ({ FieldName = "color"; Value = "Red" }, null), + Equals ({ FieldName = "color"; Value = "Blue" }, null) + ) + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Charlie" ] + +[] +let ``coerceFilter coerces values inside NOT`` () = + let filter = Not (Equals ({ FieldName = "status"; Value = "Active" }, null)) + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Bob"; "Charlie" ] + +// ────────────────────────────────────────────────────────────────────────────── +// StringComparer on non-string field must not throw InvalidCastException +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``Equals with StringComparer on non-string field does not throw InvalidCastException`` () = + // WrappedGuid is not a string; passing StringComparer.OrdinalIgnoreCase used to + // crash with InvalidCastException because the code unconditionally cast f.Value + // to string when a StringComparer was present. + let filter = + Equals ( + { FieldName = "wrappedGuid"; Value = WrappedGuid (Guid.Parse "cccccccc-cccc-cccc-cccc-cccccccccccc") }, + StringComparer.OrdinalIgnoreCase + ) + let result = applyFilter filter + result |> List.length |> equals 1 + (List.head result).Name |> equals "Charlie" + +[] +let ``Not Equals with StringComparer on non-string field does not throw InvalidCastException`` () = + let filter = + Not ( + Equals ( + { FieldName = "wrappedGuid"; Value = WrappedGuid (Guid.Parse "cccccccc-cccc-cccc-cccc-cccccccccccc") }, + StringComparer.OrdinalIgnoreCase + ) + ) + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Bob" ] diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionTests.Common.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionTests.Common.fs new file mode 100644 index 00000000..f78aa54c --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionTests.Common.fs @@ -0,0 +1,136 @@ +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.Common + +open System +open System.Linq +open System.Text.Json +open FSharp.Data.GraphQL.Shared +open FSharp.Data.GraphQL.Server.Middleware + +// ────────────────────────────────────────────────────────────────────────────── +// Test types +// ────────────────────────────────────────────────────────────────────────────── + +/// CLR enum — coerced from string via JsonStringEnumConverter or from int via default STJ. +type Color = + | Red = 0 + | Green = 1 + | Blue = 2 + +/// Multi-case fieldless DU (DU-as-enum) — coerced from string via +/// FSharp.SystemTextJson UnwrapFieldlessTags. +type Status = + | Active + | Inactive + | Pending + +/// Single-case DU wrapping a string — coerced via FSharp.SystemTextJson UnwrapSingleCaseUnions. +type WrappedString = WrappedString of string + +/// Single-case DU wrapping an int. +type WrappedInt = WrappedInt of int + +/// Single-case DU wrapping an int64. +type WrappedInt64 = WrappedInt64 of int64 + +/// Single-case DU wrapping a Guid. +type WrappedGuid = WrappedGuid of Guid + +/// Entity used in coerceFilter integration tests. +type CoercionEntity = { + Id: int + Name: string + Status: Status + Color: Color + GuidField: Guid + WrappedName: WrappedString + WrappedScore: WrappedInt + WrappedLong: WrappedInt64 + WrappedGuid: WrappedGuid + CreatedAt: DateTime + BirthDate: DateOnly + AlarmTime: TimeOnly + Score: decimal + IsActive: bool + Tags: string list + OptionName: string option + VOptionId: int voption +} + +// ────────────────────────────────────────────────────────────────────────────── +// Shared test infrastructure +// ────────────────────────────────────────────────────────────────────────────── + +/// Full serializer options including FSharp.SystemTextJson (DU coercion) and +/// JsonStringEnumConverter (CLR enum coercion). +let jsonOptions = ValueSome (Json.getSerializerOptions Seq.empty) + +/// No options — uses STJ defaults; sufficient for primitives, Guid, date/time. +let noOptions : JsonSerializerOptions voption = ValueNone + +let filterOptions = + ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) + +let testData = + [| + { + Id = 1 + Name = "Alice" + Status = Active + Color = Color.Red + GuidField = Guid.Parse "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + WrappedName = WrappedString "Alice" + WrappedScore = WrappedInt 10 + WrappedLong = WrappedInt64 100L + WrappedGuid = WrappedGuid (Guid.Parse "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + CreatedAt = DateTime (2024, 1, 1) + BirthDate = DateOnly (1990, 5, 15) + AlarmTime = TimeOnly (8, 0) + Score = 100m + IsActive = true + Tags = [ "admin"; "user" ] + OptionName = Some "Alice" + VOptionId = ValueSome 1 + } + { + Id = 2 + Name = "Bob" + Status = Inactive + Color = Color.Green + GuidField = Guid.Parse "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + WrappedName = WrappedString "Bob" + WrappedScore = WrappedInt 20 + WrappedLong = WrappedInt64 200L + WrappedGuid = WrappedGuid (Guid.Parse "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + CreatedAt = DateTime (2024, 2, 1) + BirthDate = DateOnly (1985, 8, 20) + AlarmTime = TimeOnly (9, 0) + Score = 200m + IsActive = false + Tags = [ "user" ] + OptionName = None + VOptionId = ValueNone + } + { + Id = 3 + Name = "Charlie" + Status = Pending + Color = Color.Blue + GuidField = Guid.Parse "cccccccc-cccc-cccc-cccc-cccccccccccc" + WrappedName = WrappedString "Charlie" + WrappedScore = WrappedInt 30 + WrappedLong = WrappedInt64 300L + WrappedGuid = WrappedGuid (Guid.Parse "cccccccc-cccc-cccc-cccc-cccccccccccc") + CreatedAt = DateTime (2024, 3, 1) + BirthDate = DateOnly (2000, 12, 31) + AlarmTime = TimeOnly (10, 0) + Score = 300m + IsActive = true + Tags = [ "moderator"; "user" ] + OptionName = Some "Charlie" + VOptionId = ValueSome 3 + } + |] + +let applyFilter (filter : ObjectListFilter) = + filter.ApplyTo (testData.AsQueryable (), filterOptions) |> Seq.toList diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionValueTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionValueTests.fs new file mode 100644 index 00000000..123d354c --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/TypeCoercionValueTests.fs @@ -0,0 +1,210 @@ +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.ValueTests + +open Xunit +open System +open FSharp.Data.GraphQL.Server.Middleware +open FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.Common + +// ────────────────────────────────────────────────────────────────────────────── +// pass-through (value already has the target type) +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue passes through string`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box "hello") + |> wantValueSome |> equals (box "hello") + +[] +let ``tryCoerceValue passes through int`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box 42) + |> wantValueSome |> equals (box 42) + +[] +let ``tryCoerceValue passes through bool`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box true) + |> wantValueSome |> equals (box true) + +[] +let ``tryCoerceValue passes through Guid`` () = + let g = Guid.NewGuid () + TypeCoercion.tryCoerceValue noOptions typeof (box g) + |> wantValueSome |> equals (box g) + +[] +let ``tryCoerceValue passes through decimal`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box 9.99m) + |> wantValueSome |> equals (box 9.99m) + +[] +let ``tryCoerceValue passes through DateTime`` () = + let dt = DateTime (2024, 6, 1) + TypeCoercion.tryCoerceValue noOptions typeof (box dt) + |> wantValueSome |> equals (box dt) + +// ────────────────────────────────────────────────────────────────────────────── +// null → ValueNone +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue returns ValueNone for null`` () = + TypeCoercion.tryCoerceValue noOptions typeof null + |> wantValueNone + +// ────────────────────────────────────────────────────────────────────────────── +// numeric widening / narrowing +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue coerces int to int64`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box 42) + |> wantValueSome |> equals (box 42L) + +[] +let ``tryCoerceValue returns ValueNone for string-to-int (STJ rejects quoted number)`` () = + // STJ does not coerce quoted strings to numbers without JsonNumberHandling.AllowReadingFromString + TypeCoercion.tryCoerceValue noOptions typeof (box "99") + |> wantValueNone + +[] +let ``tryCoerceValue returns ValueNone for string-to-int64 (STJ rejects quoted number)`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box "123456789") + |> wantValueNone + +[] +let ``tryCoerceValue coerces double to decimal`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box 3.14) + |> wantValueSome |> equals (box 3.14m) + +[] +let ``tryCoerceValue coerces int to decimal`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box 200) + |> wantValueSome |> equals (box 200m) + +[] +let ``tryCoerceValue passes through bool (already correct type)`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box true) + |> wantValueSome |> equals (box true) + +// ────────────────────────────────────────────────────────────────────────────── +// string → Guid +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue coerces string to Guid`` () = + let g = Guid.Parse "550e8400-e29b-41d4-a716-446655440000" + TypeCoercion.tryCoerceValue noOptions typeof (box "550e8400-e29b-41d4-a716-446655440000") + |> wantValueSome |> equals (box g) + +[] +let ``tryCoerceValue returns ValueNone for invalid Guid string`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box "not-a-guid") + |> wantValueNone + +// ────────────────────────────────────────────────────────────────────────────── +// string → date/time types (native STJ support) +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue coerces ISO string to DateTime`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box "2024-06-01T00:00:00") + |> wantValueSome |> equals (box (DateTime (2024, 6, 1, 0, 0, 0))) + +[] +let ``tryCoerceValue coerces ISO string to DateTimeOffset`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box "2024-06-01T12:00:00+00:00") + |> wantValueSome |> ignore + +[] +let ``tryCoerceValue coerces ISO string to DateOnly`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box "2024-06-01") + |> wantValueSome |> equals (box (DateOnly (2024, 6, 1))) + +[] +let ``tryCoerceValue coerces ISO string to TimeOnly`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box "14:30:00") + |> wantValueSome |> equals (box (TimeOnly (14, 30, 0))) + +// ────────────────────────────────────────────────────────────────────────────── +// CLR enum +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue coerces int to CLR enum without jsonOptions`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box 2) + |> wantValueSome |> equals (box Color.Blue) + +[] +let ``tryCoerceValue coerces string to CLR enum with jsonOptions (JsonStringEnumConverter)`` () = + TypeCoercion.tryCoerceValue jsonOptions typeof (box "Green") + |> wantValueSome |> equals (box Color.Green) + +[] +let ``tryCoerceValue returns ValueNone for string CLR enum without jsonOptions`` () = + // Without JsonStringEnumConverter, STJ rejects string enum tokens by default + TypeCoercion.tryCoerceValue noOptions typeof (box "Red") + |> wantValueNone + +// ────────────────────────────────────────────────────────────────────────────── +// single-case DU (requires FSharp.SystemTextJson UnwrapSingleCaseUnions) +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue coerces string to single-case DU wrapping string`` () = + TypeCoercion.tryCoerceValue jsonOptions typeof (box "hello") + |> wantValueSome |> equals (box (WrappedString "hello")) + +[] +let ``tryCoerceValue coerces int to single-case DU wrapping int`` () = + TypeCoercion.tryCoerceValue jsonOptions typeof (box 42) + |> wantValueSome |> equals (box (WrappedInt 42)) + +[] +let ``tryCoerceValue coerces int64 to single-case DU wrapping int64`` () = + TypeCoercion.tryCoerceValue jsonOptions typeof (box 999L) + |> wantValueSome |> equals (box (WrappedInt64 999L)) + +[] +let ``tryCoerceValue coerces string to single-case DU wrapping Guid`` () = + let g = Guid.Parse "550e8400-e29b-41d4-a716-446655440000" + TypeCoercion.tryCoerceValue jsonOptions typeof (box "550e8400-e29b-41d4-a716-446655440000") + |> wantValueSome |> equals (box (WrappedGuid g)) + +[] +let ``tryCoerceValue returns ValueNone for single-case DU without jsonOptions`` () = + // Without FSharp.SystemTextJson, STJ doesn't know how to deserialize DUs + TypeCoercion.tryCoerceValue noOptions typeof (box "hello") + |> wantValueNone + +// ────────────────────────────────────────────────────────────────────────────── +// multi-case fieldless DU / DU-as-enum (requires FSharp.SystemTextJson UnwrapFieldlessTags) +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue coerces string to multi-case fieldless DU - Active`` () = + TypeCoercion.tryCoerceValue jsonOptions typeof (box "Active") + |> wantValueSome |> equals (box Active) + +[] +let ``tryCoerceValue coerces string to multi-case fieldless DU - Inactive`` () = + TypeCoercion.tryCoerceValue jsonOptions typeof (box "Inactive") + |> wantValueSome |> equals (box Inactive) + +[] +let ``tryCoerceValue coerces string to multi-case fieldless DU - Pending`` () = + TypeCoercion.tryCoerceValue jsonOptions typeof (box "Pending") + |> wantValueSome |> equals (box Pending) + +[] +let ``tryCoerceValue returns ValueNone for unknown multi-case DU case name`` () = + TypeCoercion.tryCoerceValue jsonOptions typeof (box "Unknown") + |> wantValueNone + +// ────────────────────────────────────────────────────────────────────────────── +// unsupported conversion +// ────────────────────────────────────────────────────────────────────────────── + +[] +let ``tryCoerceValue returns ValueNone when source type has no JSON representation`` () = + TypeCoercion.tryCoerceValue noOptions typeof (box (obj ())) + |> wantValueNone diff --git a/tests/FSharp.Data.GraphQL.Tests/SelectLinqTests.fs b/tests/FSharp.Data.GraphQL.Tests/SelectLinqTests.fs index dcad5973..409c0a5a 100644 --- a/tests/FSharp.Data.GraphQL.Tests/SelectLinqTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/SelectLinqTests.fs @@ -1,5 +1,6 @@ // The MIT License (MIT) // Copyright (c) 2016 Bazinga Technologies Inc +[] module FSharp.Data.GraphQL.Tests.LinqTests open Xunit diff --git a/tests/FSharp.Data.GraphQL.Tests/TestAttributes.fs b/tests/FSharp.Data.GraphQL.Tests/TestAttributes.fs index 4d3cdc49..90d8da7e 100644 --- a/tests/FSharp.Data.GraphQL.Tests/TestAttributes.fs +++ b/tests/FSharp.Data.GraphQL.Tests/TestAttributes.fs @@ -20,3 +20,21 @@ type UseInvariantCultureAttribute() = override _.After (methodUnderTest) = CultureInfo.CurrentUICulture <- _originalUICulture CultureInfo.CurrentCulture <- _originalCulture + +namespace Tests + +module TraitType = + + [] + let Category = "Category" + + [] + let ObjectListFilterOperator = "ObjectListFilter operator" + +module TraitName = + + [] + let Linq = "LINQ" + + [] + let ObjectListFilter = "ObjectListFilter" From 454a9e0a26c519b5f04a03446eb95e4a4dc94da1 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 16 Jul 2026 20:30:47 +0200 Subject: [PATCH 04/11] Migrated to modern `InternalsVisibleTo` syntax --- ...harp.Data.GraphQL.Server.Middleware.fsproj | 4 +--- .../FSharp.Data.GraphQL.Server.fsproj | 19 ++++-------------- .../FSharp.Data.GraphQL.Shared.fsproj | 20 +++++-------------- 3 files changed, 10 insertions(+), 33 deletions(-) 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 4838ed43..ca674076 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 @@ -15,9 +15,7 @@ - - <_Parameter1>FSharp.Data.GraphQL.Tests - + diff --git a/src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj b/src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj index d97cc68f..c409a5d0 100644 --- a/src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj +++ b/src/FSharp.Data.GraphQL.Server/FSharp.Data.GraphQL.Server.fsproj @@ -14,21 +14,10 @@ - - <_Parameter1>FSharp.Data.GraphQL.Benchmarks - - - <_Parameter1>FSharp.Data.GraphQL.Tests - - - - - - <_Parameter1>FSharp.Data.GraphQL.Server.AspNetCore - - - <_Parameter1>FSharp.Data.GraphQL.Server.Middleware - + + + + diff --git a/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj b/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj index a116e8ea..f23b49b1 100644 --- a/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj +++ b/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj @@ -14,21 +14,11 @@ - - <_Parameter1>FSharp.Data.GraphQL.Server - - - <_Parameter1>FSharp.Data.GraphQL.Server.Middleware - - - <_Parameter1>FSharp.Data.GraphQL.Client - - - <_Parameter1>FSharp.Data.GraphQL.Client.DesignTime - - - <_Parameter1>FSharp.Data.GraphQL.Tests - + + + + + From 8e3423735786a4e06f70bed62064184e9dbe3ca9 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 17 Jul 2026 21:07:40 +0200 Subject: [PATCH 05/11] Implemented support of `Guid` and value object scalars as `InputValue` * Enhanced `InputValue.OfObject` to handle `Guid` (as `StringValue "D"`), `IReadOnlyDictionary`/`IDictionary` (as `ObjectValue`), and improved F# union handling. * Added `GuidId` DU, wrapped scalar, and new `Guid`/`ValueObject` fields to test types, extended tests for filtering with `Guid` and custom value object scalars. --- src/FSharp.Data.GraphQL.Shared/Ast.fs | 30 ++-- .../MiddlewareTests.fs | 150 ++++++++++++++++-- 2 files changed, 159 insertions(+), 21 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/Ast.fs b/src/FSharp.Data.GraphQL.Shared/Ast.fs index 90b47455..bd343d47 100644 --- a/src/FSharp.Data.GraphQL.Shared/Ast.fs +++ b/src/FSharp.Data.GraphQL.Shared/Ast.fs @@ -4,6 +4,7 @@ namespace FSharp.Data.GraphQL.Ast open System open System.Text.Json +open Microsoft.FSharp.Reflection //NOTE: For references, see https://facebook.github.io/graphql/ /// 2.2 Query Document @@ -124,26 +125,35 @@ and InputValue = | :? single as value -> FloatValue (double value) | :? bool as value -> BooleanValue value | :? string as value -> StringValue value + | :? Guid as value -> StringValue (value.ToString "D") | :? uint64 as value -> IntValue (int64 value) | :? uint32 as value -> IntValue (int64 value) | :? uint16 as value -> IntValue (int64 value) + | :? System.Collections.Generic.IReadOnlyDictionary as dict -> + let map = + dict + |> Seq.map (fun kv -> kv.Key, InputValue.OfObject kv.Value) + |> Map.ofSeq + ObjectValue map + | :? System.Collections.Generic.IDictionary as dict -> + let map = + dict + |> Seq.map (fun kv -> kv.Key, InputValue.OfObject kv.Value) + |> Map.ofSeq + ObjectValue map | value -> let ``type`` = value.GetType() if ``type``.IsArray then let array = value :?> System.Array let list = [ for i in 0 .. array.Length - 1 -> InputValue.OfObject (array.GetValue i) ] ListValue list + elif FSharpType.IsUnion (``type``, true) then + let _, unionFields = FSharpValue.GetUnionFields (value, ``type``, true) + match unionFields with + | [| singleField |] -> InputValue.OfObject singleField + | _ -> failwith "Cannot convert object to 'InputValue'" else - let genericType = ``type``.GetGenericTypeDefinition() - if typeof>.IsAssignableFrom genericType then - let dict = value :?> System.Collections.Generic.IReadOnlyDictionary - let map = - dict - |> Seq.map (fun kv -> kv.Key.ToString(), InputValue.OfObject kv.Value) - |> Map.ofSeq - ObjectValue map - else - failwith "Cannot convert object to 'InputValue'" + failwith "Cannot convert object to 'InputValue'" static member OfJsonElement (element : JsonElement) = match element.ValueKind with diff --git a/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs b/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs index 0e177cae..0584313d 100644 --- a/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs @@ -10,22 +10,65 @@ open FSharp open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Server.Middleware -open FSharp.Data.GraphQL.Shared open FSharp.Data.GraphQL.Parser -open FSharp.Data.GraphQL.Execution open FSharp.Data.GraphQL.Ast #nowarn "40" type Root = { clientId : int } -and Subject = +type GuidId = ValueObjectId of Guid + +let private parseGuidId (value : string) = + match Guid.TryParse value with + | true, guid -> Ok (ValueObjectId guid) + | false, _ -> + Error [ + { new IGQLError with + member _.Message = $"Cannot coerce '{value}' to GuidID" + } + ] + +let private guidIdToString (ValueObjectId guidId) = guidId.ToString "D" + +let ValueObjectType = + Define.WrappedScalar ( + name = "ValueObject", + coerceInput = + (function + | InputParameterValue.Variable value when value.ValueKind = JsonValueKind.String -> parseGuidId (value.GetString ()) + | InputParameterValue.InlineConstant (StringValue value) -> parseGuidId value + | _ -> + Error [ + { new IGQLError with + member _.Message = "ValueObject must be provided as string" + } + ]), + coerceOutput = + (function + | :? GuidId as guid -> guidIdToString guid |> Some + | _ -> None) + ) + +type Subject = | A of A | B of B -and A = { Id : int; Value : string; Subjects : int list } +and A = { + Id : int + Value : string + GuidValue : Guid + ValueObject : GuidId + Subjects : int list +} -and B = { Id : int; Value : string; Subjects : int list } +and B = { + Id : int + Value : string + GuidValue : Guid + ValueObject : GuidId + Subjects : int list +} type Complex = { Id : int @@ -43,12 +86,12 @@ type Property = | Community of Community let getExecutor (expectedFilter : ObjectListFilter voption) = - let a1 : A = { Id = 1; Value = "A1"; Subjects = [ 2; 6 ] } - let a2 : A = { Id = 2; Value = "A2"; Subjects = [ 1; 3; 5 ] } - let a3 : A = { Id = 3; Value = "A3"; Subjects = [ 1; 2; 4 ] } - let b1 = { Id = 4; Value = "1000"; Subjects = [ 1; 5 ] } - let b2 = { Id = 5; Value = "2000"; Subjects = [ 3; 4; 6 ] } - let b3 = { Id = 6; Value = "3000"; Subjects = [ 1; 3; 5 ] } + let a1 : A = { Id = 1; Value = "A1"; GuidValue = Guid.Parse "11111111-1111-1111-1111-111111111111"; ValueObject = ValueObjectId (Guid.Parse "11111111-1111-1111-1111-111111111111"); Subjects = [ 2; 6 ] } + let a2 : A = { Id = 2; Value = "A2"; GuidValue = Guid.Parse "22222222-2222-2222-2222-222222222222"; ValueObject = ValueObjectId (Guid.Parse "22222222-2222-2222-2222-222222222222"); Subjects = [ 1; 3; 5 ] } + let a3 : A = { Id = 3; Value = "A3"; GuidValue = Guid.Parse "33333333-3333-3333-3333-333333333333"; ValueObject = ValueObjectId (Guid.Parse "33333333-3333-3333-3333-333333333333"); Subjects = [ 1; 2; 4 ] } + let b1 = { Id = 4; Value = "1000"; GuidValue = Guid.Parse "44444444-4444-4444-4444-444444444444"; ValueObject = ValueObjectId (Guid.Parse "44444444-4444-4444-4444-444444444444"); Subjects = [ 1; 5 ] } + let b2 = { Id = 5; Value = "2000"; GuidValue = Guid.Parse "55555555-5555-5555-5555-555555555555"; ValueObject = ValueObjectId (Guid.Parse "55555555-5555-5555-5555-555555555555"); Subjects = [ 3; 4; 6 ] } + let b3 = { Id = 6; Value = "3000"; GuidValue = Guid.Parse "66666666-6666-6666-6666-666666666666"; ValueObject = ValueObjectId (Guid.Parse "66666666-6666-6666-6666-666666666666"); Subjects = [ 1; 3; 5 ] } let al = [ a1; a2; a3 ] let bl = [ b1; b2; b3 ] let p1 = Complex{ Id = 1; Name = "Complex 1"; Discriminator = "Complex"; Communities = [ 5 ]; Buildings = [ 3 ] } @@ -90,6 +133,8 @@ let getExecutor (expectedFilter : ObjectListFilter voption) = fun () -> [ Define.Field ("id", IntType, resolve = (fun _ a -> a.Id)) Define.Field ("value", StringType, resolve = (fun _ a -> a.Value)) + Define.Field ("guidValue", GuidType, resolve = (fun _ a -> a.GuidValue)) + Define.Field ("valueObject", ValueObjectType, resolve = (fun _ a -> a.ValueObject)) Define .Field( "subjects", @@ -111,6 +156,8 @@ let getExecutor (expectedFilter : ObjectListFilter voption) = fun () -> [ Define.Field ("id", IntType, resolve = (fun _ b -> b.Id)) Define.Field ("value", StringType, resolve = (fun _ b -> b.Value)) + Define.Field ("guidValue", GuidType, resolve = (fun _ b -> b.GuidValue)) + Define.Field ("valueObject", ValueObjectType, resolve = (fun _ b -> b.ValueObject)) Define .Field( "subjects", @@ -1093,6 +1140,87 @@ let ``Object list filter: Must parse filter that references variables`` () = data |> equals (upcast expected) result.Metadata.TryFind ("filters") |> wantValueSome |> seqEquals [ expectedFilter ] +[] +let ``Object list filter: Must parse inline filter variable backed by Guid scalar`` () = + let query = + parse + """query testQuery($filter: Guid!) { + A (id : 1) { + id + value + subjects (filter : { guidValue : $filter }) { ...Value } + } + } + + fragment Value on Subject { + ...on A { + id + value + } + ...on B { + id + value + } + }""" + let expected = + NameValueLookup.ofList [ + "A", + upcast + NameValueLookup.ofList [ + "id", upcast 1 + "value", upcast "A1" + "subjects", + upcast + [ + NameValueLookup.ofList [ "id", upcast 2; "value", upcast "A2" ] + NameValueLookup.ofList [ "id", upcast 6; "value", upcast "3000" ] + ] + ] + ] + + let guidText = "22222222-2222-2222-2222-222222222222" + let filterValue = $"\"{guidText}\"" |> JsonDocument.Parse |> _.RootElement + let variables = ImmutableDictionary.Empty.Add ("filter", filterValue) + let filter = Equals ({ FieldName = "guidvalue"; Value = guidText }, null) + let expectedFilter : KeyValuePair = kvp ([ "A"; "subjects" ]) filter + let result = executeAndVerifyFilter (query, variables, filter) + + ensureDirect result <| fun data errors -> + empty errors + data |> equals (upcast expected) + result.Metadata.TryFind ("filters") |> wantValueSome |> seqEquals [ expectedFilter ] + +[] +let ``Object list filter: Must parse inline filter variable backed by wrapped value object`` () = + let query = + parse + """query testQuery($valueObject: ValueObject!) { + A (id : 1) { + id + value + subjects (filter : { valueObject : $valueObject }) { ...Value } + } + } + + fragment Value on Subject { + ...on A { + id + value + } + ...on B { + id + value + } + }""" + + let valueObjectText = "22222222-2222-2222-2222-222222222222" + let valueObjectVariable = $"\"{valueObjectText}\"" |> JsonDocument.Parse |> _.RootElement + let variables = ImmutableDictionary.Empty.Add ("valueObject", valueObjectVariable) + let result = executeWithVariables (query, variables) + + ensureDirect result <| fun _ errors -> + empty errors + [] let ``Object list filter: Must return empty filter when all discriminated union types are specified`` () = let query = From 78d21c1c3524a517ffe240849de84222067de15c Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 9 Aug 2026 22:57:44 +0200 Subject: [PATCH 06/11] Implemented tests for `ObjectListFilter` with empty arrays * Added `ObjectListFilterEmptyArrayTests.fs` with cases for Contains, Equals, Not Equals, and logical operators on empty/non-empty lists. Updated `.fsproj` to include the new file. * Extended `ObjectListFilterLinqGenerateTests.fs` to verify correct Cosmos SQL generation for list equality and length checks. --- .../FSharp.Data.GraphQL.Tests.fsproj | 1 + .../ObjectListFilterEmptyArrayTests.fs | 220 ++++++++++++++++++ .../ObjectListFilterLinqGenerateTests.fs | 45 ++++ 3 files changed, 266 insertions(+) create mode 100644 tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterEmptyArrayTests.fs diff --git a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj index 7242322a..557c6742 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -83,6 +83,7 @@ + diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterEmptyArrayTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterEmptyArrayTests.fs new file mode 100644 index 00000000..097d6109 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterEmptyArrayTests.fs @@ -0,0 +1,220 @@ +[] +[] +module FSharp.Data.GraphQL.Tests.ObjectListFilter.EmptyArray.Tests + +open Xunit +open System +open System.Linq +open System.Text.Json +open FSharp.Data.GraphQL.Shared +open FSharp.Data.GraphQL.Server.Middleware + +// ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── +// Test types +// ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +type EmptyArrayEntity = { + Id: int + Name: string + Tags: string list +} + +// ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── +// Test data +// ──────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +let testData = [| + { Id = 1; Name = "Alice"; Tags = [] } + { Id = 2; Name = "Bob"; Tags = [ "admin"; "user" ] } + { Id = 3; Name = "Charlie"; Tags = [ "user" ] } + { Id = 4; Name = "Diana"; Tags = [] } +|] + +let filterOptions = ObjectListFilterLinqOptions (Json.getSerializerOptions Seq.empty) + +let applyFilter (filter : ObjectListFilter) = + filter.ApplyTo (testData.AsQueryable (), filterOptions) |> Seq.toList + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Validation Tests – Check that empty filters do NOT crash and handle correctly +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +[] +let ``Contains on empty array field returns entities with empty tags`` () = + // Contains on an empty list should match entities with empty lists + let filter = Contains ({ FieldName = "tags"; Value = "admin" }, null) + let result = applyFilter filter + // Only Bob and Charlie have "admin" or any tags + result |> List.length |> equals 1 + result |> List.map (fun e -> e.Name) |> equals [ "Bob" ] + +[] +let ``Empty filter list returns all entities`` () = + // No filter applied means all entities pass + // The built-in queryable should return all + let result = testData.AsQueryable () |> Seq.toList + result |> List.length |> equals 4 + +[] +let ``And with empty field matches correctly`` () = + // Filter: (id > 1 AND tags contains "admin") + let filter = + And ( + GreaterThan { FieldName = "id"; Value = 1 }, + Contains ({ FieldName = "tags"; Value = "admin" }, null) + ) + let result = applyFilter filter + // Only Bob (id=2) has "admin" tag and id > 1 + result |> List.length |> equals 1 + (List.head result).Name |> equals "Bob" + +[] +let ``Or with empty field returns union of results`` () = + // Filter: (id = 1 OR tags contains "admin") + let filter = + Or ( + Equals ({ FieldName = "id"; Value = 1 }, null), + Contains ({ FieldName = "tags"; Value = "admin" }, null) + ) + let result = applyFilter filter + // Alice (id=1) and Bob (has "admin") + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Bob" ] + +[] +let ``Not on empty array field returns complementary set`` () = + // Filter: NOT (tags contains "user") + let filter = Not (Contains ({ FieldName = "tags"; Value = "user" }, null)) + let result = applyFilter filter + // Alice, Diana have empty tags; Bob and Charlie have "user" + // so NOT "user" = Alice, Diana + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Diana" ] + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// SQL Generation Tests – Check that LINQ expression tree is correctly built +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +[] +let ``Contains filter on array field generates valid LINQ expression`` () = + // Verify the filter doesn't crash and produces a valid LINQ provider execution + // by actually running it + let filter = Contains ({ FieldName = "tags"; Value = "user" }, null) + let result = applyFilter filter + // Bob and Charlie have "user" tag + result |> List.length |> equals 2 + +[] +let ``Complex nested filter generates valid LINQ expression`` () = + // Nested: (NOT (id < 3)) AND (tags contains "user") + let filter = + And ( + Not (LessThan { FieldName = "id"; Value = 3 }), + Contains ({ FieldName = "tags"; Value = "user" }, null) + ) + let result = applyFilter filter + // id >= 3: Charlie (3), Diana (4) + // tags contains "user": Bob (2), Charlie (3) + // intersection: Charlie (3) + result |> List.length |> equals 1 + (List.head result).Name |> equals "Charlie" + +[] +let ``Equals empty list filter generates valid LINQ expression`` () = + // Verify: tags = [] + // Should correctly compile LINQ expression for list equality check + let emptyListValue = [] : string list + let filter = Equals ({ FieldName = "tags"; Value = emptyListValue }, null) + let result = applyFilter filter + // Alice and Diana have empty tags + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Diana" ] + +[] +let ``Not Equals empty list filter generates valid LINQ expression`` () = + // Verify: NOT (tags = []) => tags != [] + // Should correctly compile LINQ expression for list inequality check + let emptyListValue = [] : string list + let filter = Not (Equals ({ FieldName = "tags"; Value = emptyListValue }, null)) + let result = applyFilter filter + // Bob and Charlie have non-empty tags + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Bob"; "Charlie" ] + +[] +let ``Complex Equals empty list with logical operators generates valid LINQ expression`` () = + // Verify: (id <= 2) AND (tags = []) + let emptyListValue = [] : string list + let filter = + And ( + LessThanOrEqual { FieldName = "id"; Value = 2 }, + Equals ({ FieldName = "tags"; Value = emptyListValue }, null) + ) + let result = applyFilter filter + // id <= 2: Alice (1), Bob (2) + // tags = []: Alice (1), Diana (4) + // intersection: Alice (1) + result |> List.length |> equals 1 + (List.head result).Name |> equals "Alice" + +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ +// Coercion Tests – Check that string values are correctly coerced to list membership checks +// ════════════════════════════════════════════════════════════════════════════════════════════════════════════════ + +[] +let ``Contains coerces string value into list element check`` () = + // "admin" (string) should match list containing "admin" + let filter = Contains ({ FieldName = "tags"; Value = "admin" }, null) + let result = applyFilter filter + result |> List.length |> equals 1 + result |> List.map (fun e -> e.Name) |> equals [ "Bob" ] + +[] +let ``Contains returns empty result when no match`` () = + // Looking for a tag that doesn't exist in any entity + let filter = Contains ({ FieldName = "tags"; Value = "superadmin" }, null) + let result = applyFilter filter + result |> List.length |> equals 0 + +[] +let ``Contains with multiple identical tags matches correctly`` () = + // If an entity had ["user"; "user"], Contains "user" should still match + // (list element membership check, not count) + let filter = Contains ({ FieldName = "tags"; Value = "user" }, null) + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Bob"; "Charlie" ] + +[] +let ``Equals on list field with empty list returns only empty lists`` () = + // Filter: tags = [] + let emptyListValue = [] : string list + let filter = Equals ({ FieldName = "tags"; Value = emptyListValue }, null) + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Alice"; "Diana" ] + +[] +let ``Not Equals on empty list returns only non-empty lists`` () = + // Filter: NOT (tags = []) => tags != [] => only entities with non-empty tags + let emptyListValue = [] : string list + let filter = Not (Equals ({ FieldName = "tags"; Value = emptyListValue }, null)) + let result = applyFilter filter + result |> List.length |> equals 2 + result |> List.map (fun e -> e.Name) |> List.sort |> equals [ "Bob"; "Charlie" ] + +[] +let ``Implicit non-empty check via Contains finds all entities with any tag`` () = + // Any entity where Contains matches ANY tag is considered "has tags" + // This is implicit non-empty: if Contains("user") or Contains("admin") matches, the list is non-empty + let filter1 = Contains ({ FieldName = "tags"; Value = "user" }, null) + let filter2 = Contains ({ FieldName = "tags"; Value = "admin" }, null) + let result1 = applyFilter filter1 // "user": Bob, Charlie + let result2 = applyFilter filter2 // "admin": Bob + let combined = + (result1 |> List.map (fun e -> e.Id)) + @ (result2 |> List.map (fun e -> e.Id)) + |> List.distinct + |> List.sort + // Bob (2) and Charlie (3) have at least one tag + combined |> equals [ 2; 3 ] diff --git a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqGenerateTests.fs b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqGenerateTests.fs index 27d5332d..c1033fce 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqGenerateTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ObjectListFilter/ObjectListFilterLinqGenerateTests.fs @@ -270,6 +270,51 @@ let ``ObjectListFilter works with In operator for empty ValidStringStruct list`` let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE false""" +[] +let ``ObjectListFilter works with Equals operator for empty ValidStringStructList`` () = + let queryable = container.GetItemLinqQueryable () + let filter = Equals ({ FieldName = "validStringStructList"; Value = [] }, null) + let filterQuery = queryable.Apply (filter, filterOptions) + let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery + // Equals empty list should use ARRAY_LENGTH = 0 or NOT ANY + equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (ARRAY_LENGTH(root["validStringStructList"]) = 0)""" + +[] +let ``ObjectListFilter works with Not Equals operator for empty ValidStringStructList`` () = + let queryable = container.GetItemLinqQueryable () + let filter = Not (Equals ({ FieldName = "validStringStructList"; Value = [] }, null)) + let filterQuery = queryable.Apply (filter, filterOptions) + let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery + // Not Equals empty list should use ARRAY_LENGTH > 0 + equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (ARRAY_LENGTH(root["validStringStructList"]) > 0)""" + +[] +let ``ObjectListFilter works with Equals operator for non-empty ValidStringStructList`` () = + let queryable = container.GetItemLinqQueryable () + let filter = Equals ({ FieldName = "validStringStructList"; Value = ["tag1"; "tag2"] }, null) + let filterQuery = queryable.Apply (filter, filterOptions) + let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery + // Equals non-empty list should check exact list match + equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (root["validStringStructList"] = ["tag1", "tag2"])""" + +[] +let ``ObjectListFilter works with Not Equals operator for non-empty ValidStringStructList`` () = + let queryable = container.GetItemLinqQueryable () + let filter = Not (Equals ({ FieldName = "validStringStructList"; Value = ["tag1"; "tag2"] }, null)) + let filterQuery = queryable.Apply (filter, filterOptions) + let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery + // Not Equals non-empty list should check NOT exact list match + equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (root["validStringStructList"] != ["tag1", "tag2"])""" + +[] +let ``ObjectListFilter works with Equals operator for single-element ValidStringStructList`` () = + let queryable = container.GetItemLinqQueryable () + let filter = Equals ({ FieldName = "validStringStructList"; Value = ["tag1"] }, null) + let filterQuery = queryable.Apply (filter, filterOptions) + let queryDefinition = CosmosLinqExtensions.ToQueryDefinition filterQuery + // Equals single-element list + equals queryDefinition.QueryText, """SELECT VALUE root FROM root WHERE (root["validStringStructList"] = ["tag1"])""" + [] let ``ObjectListFilter works with Equals operator for ValidStringObject`` () = let filter = Equals ({ FieldName = "validStringObject"; Value = ValidStringObject "Jonathan" }, null) From 060fd26180a3048293c8f2e8245fb52260a3ca2c Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 9 Aug 2026 22:59:02 +0200 Subject: [PATCH 07/11] Updated SDK to `10.0.302` --- .github/workflows/publish-ci.yml | 2 +- .github/workflows/publish-release.yml | 2 +- .github/workflows/pull-request.yml | 2 +- build/Program.fs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-ci.yml b/.github/workflows/publish-ci.yml index 3b26ec16..73c714da 100644 --- a/.github/workflows/publish-ci.yml +++ b/.github/workflows/publish-ci.yml @@ -8,7 +8,7 @@ on: env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 DOTNET_NOLOGO: true - DOTNET_SDK_VERSION: 10.0.301 + DOTNET_SDK_VERSION: 10.0.302 jobs: publish: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c329f681..ae0941cf 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -9,7 +9,7 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 DOTNET_NOLOGO: true SLEEP_DURATION: 60 - DOTNET_SDK_VERSION: 10.0.301 + DOTNET_SDK_VERSION: 10.0.302 jobs: publish: diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 228084b6..68582826 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -38,7 +38,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-22.04, windows-latest, macOS-latest] - dotnet: [10.0.301] + dotnet: [10.0.302] runs-on: ${{ matrix.os }} steps: diff --git a/build/Program.fs b/build/Program.fs index b5b73573..a269caf2 100644 --- a/build/Program.fs +++ b/build/Program.fs @@ -30,7 +30,7 @@ let ctx = Context.forceFakeContext () let embedAll = ctx.Arguments |> List.exists (fun arg -> arg = BuildArguments.EmbedAll) module DotNetCli = - let setVersion (o : DotNet.Options) = { o with Version = Some "10.0.301" } + let setVersion (o : DotNet.Options) = { o with Version = Some "10.0.302" } let setRestoreOptions (o : DotNet.RestoreOptions) = o.WithCommon setVersion let configurationString = Environment.environVarOrDefault "CONFIGURATION" "Release" From d971701b943109c70a64cda62de321077451af26 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 9 Aug 2026 22:59:55 +0200 Subject: [PATCH 08/11] Updated `Fantomas` to `7.0.5` --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 17de0029..e4b18c90 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "fantomas": { - "version": "7.0.1", + "version": "7.0.5", "commands": [ "fantomas" ], From eada5c2662aeffa8c06cf38c2d0ea16515100dfe Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 9 Aug 2026 23:01:00 +0200 Subject: [PATCH 09/11] Updated `FSDocs-Tools` to `22.1.0` --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e4b18c90..26363e2e 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "fsdocs-tool": { - "version": "20.0.1", + "version": "22.1.0", "commands": [ "fsdocs" ], From b9ce3ad3b85eab7f4f1e94e8d07a05b5004d8d76 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 10 Aug 2026 00:53:43 +0200 Subject: [PATCH 10/11] Implemented robust interface field covariance validation & tests - Implemented comprehensive validation for interface field covariance, covering output type subtyping, argument compatibility, and field implementation checks. - Enhanced error messages with clearer formatting and context. - Updated validation logic for object, input object, union, and enum types to use interpolated strings. - Added `InterfaceCovarianceTests.fs` with extensive valid/invalid covariance scenarios. - Modernized `TypeValidationTests.fs` and expanded `UnionInterfaceTests.fs` for execution coverage. --- src/FSharp.Data.GraphQL.Shared/Validation.fs | 76 +++- .../FSharp.Data.GraphQL.Tests.fsproj | 3 +- .../InterfaceCovarianceTests.fs | 419 ++++++++++++++++++ .../TypeValidationTests.fs | 78 ++-- .../UnionInterfaceTests.fs | 133 ++++++ 5 files changed, 671 insertions(+), 38 deletions(-) create mode 100644 tests/FSharp.Data.GraphQL.Tests/InterfaceCovarianceTests.fs diff --git a/src/FSharp.Data.GraphQL.Shared/Validation.fs b/src/FSharp.Data.GraphQL.Shared/Validation.fs index cf795c67..a0d45ef1 100644 --- a/src/FSharp.Data.GraphQL.Shared/Validation.fs +++ b/src/FSharp.Data.GraphQL.Shared/Validation.fs @@ -15,6 +15,65 @@ open FsToolkit.ErrorHandling module Types = + let private asOutputDef (tdef : TypeDef) = + match tdef with + | :? OutputDef as output -> ValueSome output + | _ -> ValueNone + + let private isOptionalInputField (field : InputFieldDef) = + match field.TypeDef with + | Nullable _ -> true + | _ -> field.DefaultValue.IsSome || field.IsSkippable + + let private areFieldArgumentsCompatible (objArgs : InputFieldDef[]) (ifaceArgs : InputFieldDef[]) = + let objectArguments = objArgs |> Array.map (fun arg -> arg.Name, arg) |> Map.ofArray + let interfaceArguments = ifaceArgs |> Array.map (fun arg -> arg.Name, arg) |> Map.ofArray + + let hasCompatibleInterfaceArguments = + ifaceArgs + |> Array.forall (fun ifaceArg -> + match Map.tryFind ifaceArg.Name objectArguments with + | Some objArg -> objArg.TypeDef = ifaceArg.TypeDef + | None -> false) + + let hasOptionalExtraObjectArguments = + objArgs + |> Array.forall (fun objArg -> + match Map.tryFind objArg.Name interfaceArguments with + | Some ifaceArg -> objArg.TypeDef = ifaceArg.TypeDef + | None -> isOptionalInputField objArg) + + hasCompatibleInterfaceArguments && hasOptionalExtraObjectArguments + + let rec private isOutputSubtype (objType : OutputDef) (ifaceType : OutputDef) = + match objType, ifaceType with + | Nullable objInner, Nullable ifaceInner -> + match asOutputDef objInner, asOutputDef ifaceInner with + | ValueSome objOutput, ValueSome ifaceOutput -> isOutputSubtype objOutput ifaceOutput + | _ -> false + | Nullable _, _ -> false + | _, Nullable ifaceInner -> + match asOutputDef ifaceInner with + | ValueSome ifaceOutput -> isOutputSubtype objType ifaceOutput + | _ -> false + | List objInner, List ifaceInner -> + match asOutputDef objInner, asOutputDef ifaceInner with + | ValueSome objOutput, ValueSome ifaceOutput -> isOutputSubtype objOutput ifaceOutput + | _ -> false + | List _, _ + | _, List _ -> false + | _ when objType = ifaceType -> true + | (:? ObjectDef as objObject), (:? InterfaceDef as ifaceInterface) -> + objObject.Implements |> Array.exists ((=) ifaceInterface) + | (:? ObjectDef as objObject), (:? UnionDef as ifaceUnion) -> + ifaceUnion.Options |> Array.exists ((=) objObject) + | _ -> false + + let private isFieldImplementationCompatible (objField : FieldDef) (ifaceField : FieldDef) = + objField.Name = ifaceField.Name + && areFieldArgumentsCompatible objField.Args ifaceField.Args + && isOutputSubtype objField.TypeDef ifaceField.TypeDef + let validateImplements (objdef : ObjectDef) (idef : InterfaceDef) = let objectFields = objdef.Fields let errors = @@ -23,11 +82,11 @@ module Types = (fun acc f -> match Map.tryFind f.Name objectFields with | None -> - $"'%s{f.Name}' field is defined by interface %s{idef.Name}, but not implemented in object %s{objdef.Name}" + $"'%s{f.Name}' field is defined by interface '%s{idef.Name}', but not implemented in object '%s{objdef.Name}'" :: acc - | Some objf when objf = f -> acc + | Some objf when isFieldImplementationCompatible objf f -> acc | Some _ -> - $"'%s{objdef.Name}.%s{f.Name}' field signature does not match it's definition in interface %s{idef.Name}" + $"'%s{objdef.Name}.%s{f.Name}' field signature does not match it's definition in interface '%s{idef.Name}'" :: acc) [] match errors with @@ -42,7 +101,7 @@ module Types = if objdef.Fields.Count > 0 then Success else - ValidationError [ objdef.Name + " must have at least one field defined" ] + ValidationError [ $"'%s{objdef.Name}' must have at least one field defined" ] let implementsResult = objdef.Implements |> ValidationResult.collect (validateImplements objdef) @@ -52,7 +111,7 @@ module Types = if indef.Fields.Length > 0 then Success else - ValidationError [ indef.Name + " must have at least one field defined" ] + ValidationError [ $"'%s{indef.Name}' must have at least one field defined" ] nonEmptyResult | Union uniondef -> let nonEmptyResult = @@ -60,8 +119,7 @@ module Types = Success else ValidationError [ - uniondef.Name - + " must have at least one type definition option" + $"'%s{uniondef.Name}' must have at least one type definition option" ] nonEmptyResult | Enum enumdef -> @@ -69,14 +127,14 @@ module Types = if enumdef.Options.Length > 0 then Success else - ValidationError [ enumdef.Name + " must have at least one enum value defined" ] + ValidationError [ $"'%s{enumdef.Name}' must have at least one enum value defined" ] nonEmptyResult | Interface idef -> let nonEmptyResult = if idef.Fields.Length > 0 then Success else - ValidationError [ idef.Name + " must have at least one field defined" ] + ValidationError [ $"'%s{idef.Name}' must have at least one field defined" ] nonEmptyResult | InputCustom _ -> Success | _ -> failwithf "Unexpected value of typedef: %O" typedef diff --git a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj index 557c6742..9615ba1a 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -38,9 +38,10 @@ + + - diff --git a/tests/FSharp.Data.GraphQL.Tests/InterfaceCovarianceTests.fs b/tests/FSharp.Data.GraphQL.Tests/InterfaceCovarianceTests.fs new file mode 100644 index 00000000..8c7f9c79 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/InterfaceCovarianceTests.fs @@ -0,0 +1,419 @@ +// The MIT License (MIT) +// Copyright (c) 2016 Bazinga Technologies Inc +module FSharp.Data.GraphQL.Tests.InterfaceCovarianceTests + +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Validation +open FSharp.Data.GraphQL.Validation.Types +open FSharp.Data.GraphQL.Types +open Helpers +open Xunit + +type IChildView = + interface + abstract Id : string + end + +type IParentView = + interface + abstract Child : IChildView + end + +type ChildAView = { + Id : string +} with + + interface IChildView with + member x.Id = x.Id + +type ChildBView = { + Id : string +} with + + interface IChildView with + member x.Id = x.Id + +type OtherView = { Name : string } + +type ParentAInfoView = { + Child : ChildAView +} with + + interface IParentView with + member x.Child = x.Child :> IChildView + +type ParentBInfoView = { + Child : ChildBView +} with + + interface IParentView with + member x.Child = x.Child :> IChildView + +type ParentOtherInfoView = { Child : OtherView } + +type ParentInterfaceChildView = { + Child : IChildView +} with + + interface IParentView with + member x.Child = x.Child + +type ParentChildOptionView = { Child : ChildAView option } + +type ParentChildVOptionView = { Child : ChildAView voption } + +type ParentChildListView = { Child : ChildAView list } + +type ParentChildOptionListView = { Child : ChildAView option list } + +type ParentChildVOptionListView = { Child : ChildAView voption list } + +let IChildInfo : InterfaceDef = + Define.Interface (name = "IChildInfo", fields = [ Define.Field ("id", StringType) ]) + +let ChildAInfo : ObjectDef = + Define.Object ( + name = "ChildAInfo", + fields = [ Define.Field ("id", StringType, (fun _ (x : ChildAView) -> x.Id)) ], + interfaces = [ IChildInfo ] + ) + +let ChildBInfo : ObjectDef = + Define.Object ( + name = "ChildBInfo", + fields = [ Define.Field ("id", StringType, (fun _ (x : ChildBView) -> x.Id)) ], + interfaces = [ IChildInfo ] + ) + +let OtherInfo : ObjectDef = + Define.Object (name = "OtherInfo", fields = [ Define.Field ("name", StringType, (fun _ x -> x.Name)) ]) + +let signatureMismatch objectName fieldName interfaceName = + $"'{objectName}.{fieldName}' field signature does not match it's definition in interface '{interfaceName}'" + +let hasValidationErrorContaining (text : string) result = + match result with + | ValidationError errors -> + Assert.True ( + (errors : string list) + |> List.exists (fun (x : string) -> x.Contains (text)), + $"Expected validation error containing '{text}', but got {errors}" + ) + | Success -> Assert.Fail ($"Expected validation error containing '{text}', but validation succeeded") + +[] +let ``Validation allows interface field covariance with ChildAInfo`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + equals Success result + +[] +let ``Validation allows interface field covariance with ChildBInfo`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentBInfo", + fields = [ Define.Field ("child", ChildBInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + equals Success result + +[] +let ``Validation allows nullable interface field with non-null ChildAInfo`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", Nullable IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + equals Success result + +[] +let ``Validation allows struct nullable interface field with non-null ChildAInfo`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", StructNullable IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + equals Success result + +[] +let ``Validation allows exact non-null interface field type`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", IChildInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + equals Success result + +[] +let ``Validation allows list covariance from IChildInfo to ChildAInfo`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", ListOf IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ListOf ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + equals Success result + +[] +let ``Validation allows deep wrapper covariance from Nullable(List(Nullable(IChildInfo))) to List(ChildAInfo)`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", Nullable (ListOf (Nullable IChildInfo))) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ListOf ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + equals Success result + +[] +let ``Validation rejects unrelated object type for interface field`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", OtherInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation rejects nullable object field when interface field is non-null`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", Nullable ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation rejects struct nullable object field when interface field is non-null`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", StructNullable ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation rejects scalar type when interface expects list`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", ListOf IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation rejects list item nullability widening with Nullable`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", ListOf IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ListOf (Nullable ChildAInfo), (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation rejects list item nullability widening with StructNullable`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", ListOf IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ListOf (StructNullable ChildAInfo), (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation rejects field arguments when required interface argument is missing`` () = + let parentInterface = + Define.Interface ( + name = "IParentInfo", + fields = [ + Define.Field ("child", IChildInfo, "Child field", [ Define.Input ("id", IntType) ], (fun _ _ -> Unchecked.defaultof)) + ] + ) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ChildAInfo, "Child field", [], (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation rejects field arguments when argument type differs`` () = + let parentInterface = + Define.Interface ( + name = "IParentInfo", + fields = [ + Define.Field ("child", IChildInfo, "Child field", [ Define.Input ("id", IntType) ], (fun _ _ -> Unchecked.defaultof)) + ] + ) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ + Define.Field ( + "child", + ChildAInfo, + "Child field", + [ Define.Input ("id", StringType) ], + (fun _ _ -> Unchecked.defaultof) + ) + ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation rejects field arguments when object adds extra required argument`` () = + let parentInterface = + Define.Interface ( + name = "IParentInfo", + fields = [ + Define.Field ("child", IChildInfo, "Child field", [ Define.Input ("id", IntType) ], (fun _ _ -> Unchecked.defaultof)) + ] + ) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ + Define.Field ( + "child", + ChildAInfo, + "Child field", + [ Define.Input ("id", IntType); Define.Input ("extra", IntType) ], + (fun _ _ -> Unchecked.defaultof) + ) + ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + hasValidationErrorContaining (signatureMismatch "ParentAInfo" "child" "IParentInfo") result + +[] +let ``Validation allows field arguments when object adds extra optional argument`` () = + let parentInterface = + Define.Interface ( + name = "IParentInfo", + fields = [ + Define.Field ("child", IChildInfo, "Child field", [ Define.Input ("id", IntType) ], (fun _ _ -> Unchecked.defaultof)) + ] + ) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ + Define.Field ( + "child", + ChildAInfo, + "Child field", + [ Define.Input ("id", IntType); Define.Input ("extra", Nullable IntType) ], + (fun _ _ -> Unchecked.defaultof) + ) + ], + interfaces = [ parentInterface ] + ) + + let result = validateImplements parentObject parentInterface + equals Success result + +[] +let ``Validation type map allows covariance for concrete implementation of interface field`` () = + let parentInterface = + Define.Interface (name = "IParentInfo", fields = [ Define.Field ("child", IChildInfo) ]) + + let parentObject = + Define.Object ( + name = "ParentAInfo", + fields = [ Define.Field ("child", ChildAInfo, (fun _ _ -> Unchecked.defaultof)) ], + interfaces = [ parentInterface ] + ) + + let typeMap = TypeMap () + typeMap.AddType (parentObject) + + let result = validateTypeMap typeMap + equals Success result diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeValidationTests.fs b/tests/FSharp.Data.GraphQL.Tests/TypeValidationTests.fs index 70b5f394..2afddea1 100644 --- a/tests/FSharp.Data.GraphQL.Tests/TypeValidationTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/TypeValidationTests.fs @@ -14,54 +14,76 @@ type ITestInterface = abstract TestMethod : int -> string -> string end -type TestDataType = - { TestProperty : string } +type TestDataType = { + TestProperty : string +} with + interface ITestInterface with member x.TestProperty = x.TestProperty - member x.TestMethod i y = x.TestProperty + y + i.ToString() + member x.TestMethod i y = x.TestProperty + y + i.ToString () let TestInterface = - Define.Interface("TestInterface", - [ Define.Field("property", StringType) - Define.Field("method", StringType, "Test method", - [ Define.Input("x", IntType) - Define.Input("y", StringType) ], (fun _ _ -> "")) ]) + Define.Interface ( + "TestInterface", + [ + Define.Field ("property", StringType) + Define.Field ("method", StringType, "Test method", [ Define.Input ("x", IntType); Define.Input ("y", StringType) ], (fun _ _ -> "")) + ] + ) [] -let ``Validation must inform about not implemented fields``() = +let ``Validation must inform about not implemented fields`` () = let TestData = - Define.Object - (name = "TestData", fields = [ Define.Field("property", StringType, (fun _ d -> d.TestProperty)) ], - interfaces = [ TestInterface ]) + Define.Object ( + name = "TestData", + fields = [ Define.Field ("property", StringType, (fun _ d -> d.TestProperty)) ], + interfaces = [ TestInterface ] + ) let expected = - ValidationError [ "'method' field is defined by interface TestInterface, but not implemented in object TestData" ] + ValidationError [ "'method' field is defined by interface 'TestInterface', but not implemented in object 'TestData'" ] let result = validateImplements TestData TestInterface equals expected result [] -let ``Validation must inform about fields with not matching signatures``() = +let ``Validation must inform about fields with not matching signatures`` () = let TestData = - Define.Object - (name = "TestData", - fields = [ Define.Field("property", IntType, (fun _ d -> 1)) - Define.Field("method", StringType, "Test method", [ Define.Input("x", IntType) ], (fun _ _ -> "res")) ], - interfaces = [ TestInterface ]) + Define.Object ( + name = "TestData", + fields = [ + Define.Field ("property", IntType, (fun _ d -> 1)) + Define.Field ("method", StringType, "Test method", [ Define.Input ("x", IntType) ], (fun _ _ -> "res")) + ], + interfaces = [ TestInterface ] + ) let expected = - ValidationError - [ "'TestData.method' field signature does not match it's definition in interface TestInterface"; - "'TestData.property' field signature does not match it's definition in interface TestInterface" ] + ValidationError [ + "'TestData.method' field signature does not match it's definition in interface 'TestInterface'" + "'TestData.property' field signature does not match it's definition in interface 'TestInterface'" + ] + let result = validateImplements TestData TestInterface equals expected result [] -let ``Validation must succeed if object implements interface correctly``() = +let ``Validation must succeed if object implements interface correctly`` () = let TestData = - Define.Object - (name = "TestData", - fields = [ Define.Field("property", StringType, (fun _ d -> d.TestProperty)) - Define.Field("method", StringType, "Test method", [ Define.Input("x", IntType); Define.Input("y", StringType) ], (fun _ _ -> "res")) ], - interfaces = [ TestInterface ]) + Define.Object ( + name = "TestData", + fields = [ + Define.Field ("property", StringType, (fun _ d -> d.TestProperty)) + Define.Field ( + "method", + StringType, + "Test method", + [ Define.Input ("x", IntType); Define.Input ("y", StringType) ], + (fun _ _ -> "res") + ) + ], + interfaces = [ TestInterface ] + ) let result = validateImplements TestData TestInterface equals Success result + + diff --git a/tests/FSharp.Data.GraphQL.Tests/UnionInterfaceTests.fs b/tests/FSharp.Data.GraphQL.Tests/UnionInterfaceTests.fs index 291a8bf4..755b159f 100644 --- a/tests/FSharp.Data.GraphQL.Tests/UnionInterfaceTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/UnionInterfaceTests.fs @@ -35,6 +35,16 @@ type Person = interface INamed with member x.Name = x.Name +type IHasChild = + interface + abstract Child : INamed + end + +type ParentWithDog = + { Child : Dog } + interface IHasChild with + member x.Child = x.Child :> INamed + let NamedType = Define.Interface( name = "Named", @@ -329,3 +339,126 @@ let ``Execute allows fragment conditions to be abstract types`` () = ensureDirect result <| fun data errors -> empty errors data |> equals (upcast expected) + +[] +let ``Executes covariance for interface field implemented by concrete object field`` () = + let hasChildType = + Define.Interface( + name = "HasChild", + fields = [ Define.Field("child", NamedType, fun _ (x : IHasChild) -> x.Child) ]) + + let parentWithDogType = + Define.Object( + name = "ParentWithDog", + isTypeOf = is, + interfaces = [ hasChildType ], + fields = [ Define.Field("child", DogType, fun _ x -> x.Child) ]) + + let queryType = + Define.Object( + name = "Query", + fields = [ Define.Field("parent", hasChildType, fun _ _ -> ({ Child = odie } :> IHasChild)) ]) + + let covariantSchema = + Schema(query = queryType, config = { SchemaConfig.Default with Types = [ parentWithDogType :> NamedDef ] }) + + let ast = parse """{ parent { __typename child { __typename name ... on Dog { barks } } } }""" + let result = sync <| Executor(covariantSchema).AsyncExecute(ast, getMockInputContext) + + let expected = + NameValueLookup.ofList [ + "parent", upcast NameValueLookup.ofList [ + "__typename", box "ParentWithDog" + "child", upcast NameValueLookup.ofList [ + "__typename", box "Dog" + "name", upcast "Odie" + "barks", upcast true + ] + ] + ] + + ensureDirect result <| fun data errors -> + empty errors + data |> equals (upcast expected) + +[] +let ``Executes covariance for Nullable interface field implemented by non-null object field`` () = + let hasChildType = + Define.Interface( + name = "HasChildNullable", + fields = [ Define.Field("child", Nullable NamedType, fun _ (x : IHasChild) -> Some x.Child) ]) + + let parentWithDogType = + Define.Object( + name = "ParentWithDogNullable", + isTypeOf = is, + interfaces = [ hasChildType ], + fields = [ Define.Field("child", DogType, fun _ x -> x.Child) ]) + + let queryType = + Define.Object( + name = "QueryNullable", + fields = [ Define.Field("parent", hasChildType, fun _ _ -> ({ Child = odie } :> IHasChild)) ]) + + let covariantSchema = + Schema(query = queryType, config = { SchemaConfig.Default with Types = [ parentWithDogType :> NamedDef ] }) + + let ast = parse """{ parent { __typename child { __typename name ... on Dog { barks } } } }""" + let result = sync <| Executor(covariantSchema).AsyncExecute(ast, getMockInputContext) + + let expected = + NameValueLookup.ofList [ + "parent", upcast NameValueLookup.ofList [ + "__typename", box "ParentWithDogNullable" + "child", upcast NameValueLookup.ofList [ + "__typename", box "Dog" + "name", upcast "Odie" + "barks", upcast true + ] + ] + ] + + ensureDirect result <| fun data errors -> + empty errors + data |> equals (upcast expected) + +[] +let ``Executes covariance for StructNullable interface field implemented by non-null object field`` () = + let hasChildType = + Define.Interface( + name = "HasChildStructNullable", + fields = [ Define.Field("child", StructNullable NamedType, fun _ (x : IHasChild) -> ValueSome x.Child) ]) + + let parentWithDogType = + Define.Object( + name = "ParentWithDogStructNullable", + isTypeOf = is, + interfaces = [ hasChildType ], + fields = [ Define.Field("child", DogType, fun _ x -> x.Child) ]) + + let queryType = + Define.Object( + name = "QueryStructNullable", + fields = [ Define.Field("parent", hasChildType, fun _ _ -> ({ Child = odie } :> IHasChild)) ]) + + let covariantSchema = + Schema(query = queryType, config = { SchemaConfig.Default with Types = [ parentWithDogType :> NamedDef ] }) + + let ast = parse """{ parent { __typename child { __typename name ... on Dog { barks } } } }""" + let result = sync <| Executor(covariantSchema).AsyncExecute(ast, getMockInputContext) + + let expected = + NameValueLookup.ofList [ + "parent", upcast NameValueLookup.ofList [ + "__typename", box "ParentWithDogStructNullable" + "child", upcast NameValueLookup.ofList [ + "__typename", box "Dog" + "name", upcast "Odie" + "barks", upcast true + ] + ] + ] + + ensureDirect result <| fun data errors -> + empty errors + data |> equals (upcast expected) From be74f88ae8ae2e14bb6a192cb5ccc30af4a379aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:10:44 +0200 Subject: [PATCH 11/11] Bump @protobufjs/utf8 from 1.1.0 to 1.1.1 in /samples/client-provider/file-upload/server (#558) Signed-off-by: dependabot[bot] --- .../client-provider/file-upload/server/package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/samples/client-provider/file-upload/server/package-lock.json b/samples/client-provider/file-upload/server/package-lock.json index 7f3441f0..752bcabf 100644 --- a/samples/client-provider/file-upload/server/package-lock.json +++ b/samples/client-provider/file-upload/server/package-lock.json @@ -433,9 +433,10 @@ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" }, "node_modules/@types/accepts": { "version": "1.3.5",