diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 17de0029..26363e2e 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,14 +3,14 @@
"isRoot": true,
"tools": {
"fantomas": {
- "version": "7.0.1",
+ "version": "7.0.5",
"commands": [
"fantomas"
],
"rollForward": false
},
"fsdocs-tool": {
- "version": "20.0.1",
+ "version": "22.1.0",
"commands": [
"fsdocs"
],
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/Packages.props b/Packages.props
index 323f603f..4f5545f2 100644
--- a/Packages.props
+++ b/Packages.props
@@ -80,6 +80,7 @@
+
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/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"
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",
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 @@
+
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..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
@@ -14,13 +14,20 @@
true-->
+
+
+
+
+
+
+
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..847165ac 100644
--- a/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs
+++ b/src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs
@@ -1,40 +1,60 @@
namespace FSharp.Data.GraphQL.Server.Middleware
open System
-open FSharp.Data.GraphQL
+open System.Collections
+open System.Text.Json
/// 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
-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 () =
@@ -61,336 +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 }
-
- /// 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 }
-
- /// Creates a new ObjectListFilter representing an ENDS WITH operation of a string value.
- let ( @@= ) fname value = EndsWith { FieldName = fname; Value = value }
-
- /// Creates a new ObjectListFilter representing a CONTAINS operation.
- let ( @=@ ) fname value = Contains { FieldName = fname; Value = value }
-
- /// Creates a new ObjectListFilter representing a IN operation.
- let ( =~= ) fname value = In { FieldName = fname; Value = value }
-
- /// Creates a new ObjectListFilter representing a field sub comparison.
- let ( --> ) fname filter = FilterField { FieldName = fname; Value = filter }
-
- /// Creates a new ObjectListFilter representing a NOT opreation for the existing one.
- let ( !!! ) filter = Not filter
-
- 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) |])
+ ObjectListFilterLinqOptions<'T, 'D> (ObjectListFilterLinqOptions.GetCompareDiscriminator getDiscriminator, null, null)
- let private objectType = typeof
- let private stringType = typeof
- let private genericIEnumerableType = typedefof>
+ /// Initializes options using a custom discriminator comparison expression.
+ new (compareDiscriminator : CompareDiscriminatorExpression<'T, 'D>) =
+ ObjectListFilterLinqOptions<'T, 'D> (compareDiscriminator, null, null)
- let private StringStartsWithMethod = stringType.GetMethod ("StartsWith", [| stringType |])
- let private StringEndsWithMethod = stringType.GetMethod ("EndsWith", [| stringType |])
- let private StringContainsMethod = stringType.GetMethod ("Contains", [| stringType |])
- 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
-
- 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) ->
- 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``))
- | 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 ->
- 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``)
- | 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 ->
- let ``member`` = Expression.PropertyOrField (param, f.FieldName)
- Expression.Call (normalizeStringMemberExpr ``member``, StringStartsWithMethod, Expression.Constant f.Value)
- | EndsWith f ->
- let ``member`` = Expression.PropertyOrField (param, f.FieldName)
- Expression.Call (normalizeStringMemberExpr ``member``, StringEndsWithMethod, Expression.Constant f.Value)
-
- | Contains f ->
- 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
- Expression.Call (normalizeStringMemberExpr ``member``, StringContainsMethod, Expression.Constant unwrappedValue)
- | In f when not (f.Value.IsEmpty) ->
- let ``member`` = Expression.PropertyOrField (param, f.FieldName)
- let enumerableContains = getEnumerableContainsMethod objectType
- 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 e8239cad..2fd6951d 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
@@ -22,23 +23,36 @@ type private ComparisonOperator =
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 +110,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 +180,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/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.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/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/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
-
+
+
+
+
+
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/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 1bc22455..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 @@
+
+
-
@@ -75,8 +76,15 @@
-
-
+
+
+
+
+
+
+
+
+
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/MiddlewareTests.fs b/tests/FSharp.Data.GraphQL.Tests/MiddlewareTests.fs
index 8859057e..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",
@@ -599,7 +646,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 +694,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 +740,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 +832,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 +878,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 +926,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 +938,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 +950,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 +962,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 +1070,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 +1082,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 +1131,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)
@@ -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 =
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/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" ]
+
+[