22[<Xunit.Trait ( Tests.TraitType.ObjectListFilterOperator, " in" ) >]
33module FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.InOperatorTests
44
5+ open System
6+ open System.Linq
7+ open System.Linq .Expressions
58open Xunit
9+ open FSharp.Data .GraphQL .Shared
610open FSharp.Data .GraphQL .Server .Middleware
711open FSharp.Data .GraphQL .Tests .ObjectListFilter .TypeCoercion .Common
812
@@ -11,6 +15,96 @@ open FSharp.Data.GraphQL.Tests.ObjectListFilter.TypeCoercion.Common
1115// Covers all coercion types: CLR enum, DU-as-enum, Guid, single-case DU, and primitives
1216// ──────────────────────────────────────────────────────────────────────────────
1317
18+ /// Minimal entity model used to validate ` In ` translation for nullable fields.
19+ type NullableEntity = {
20+ /// Primary identifier used in test assertions.
21+ Id: int
22+ /// Nullable scalar field used to assert typed ` Contains<Nullable<int>> ` expression generation.
23+ MaybeId: Nullable < int >
24+ }
25+
26+ /// Query options for nullable-expression tests with JSON coercion enabled.
27+ let private nullableOptions =
28+ ObjectListFilterLinqOptions< NullableEntity, obj> ( Json.getSerializerOptions Seq.empty)
29+
30+ /// In-memory source for nullable-field ` In ` tests.
31+ let private nullableData =
32+ [|
33+ { Id = 1 ; MaybeId = Nullable 1 }
34+ { Id = 2 ; MaybeId = Nullable() }
35+ { Id = 3 ; MaybeId = Nullable 3 }
36+ |]
37+
38+ /// Applies an ` ObjectListFilter ` to the nullable-field test dataset.
39+ let private applyNullableFilter ( filter : ObjectListFilter ) =
40+ filter.ApplyTo ( nullableData.AsQueryable (), nullableOptions) |> Seq.toList
41+
42+ /// Traverses an expression tree and returns the first ` Enumerable.Contains ` call node.
43+ let private tryFindEnumerableContainsCall ( expr : Expression ) : MethodCallExpression option =
44+ let rec find ( node : Expression ) =
45+ match node with
46+ | :? MethodCallExpression as call
47+ when call.Method.Name = " Contains"
48+ && call.Method.DeclaringType = typeof< Enumerable>
49+ && call.Arguments.Count = 2 ->
50+ Some call
51+ | :? MethodCallExpression as call ->
52+ let fromObject =
53+ if isNull call.Object then None else find call.Object
54+ match fromObject with
55+ | Some found -> Some found
56+ | None -> call.Arguments |> Seq.tryPick find
57+ | :? UnaryExpression as unary -> find unary.Operand
58+ | :? LambdaExpression as lambda -> find lambda.Body
59+ | :? BinaryExpression as binary ->
60+ match find binary.Left with
61+ | Some found -> Some found
62+ | None -> find binary.Right
63+ | :? MemberExpression as memberExpr ->
64+ if isNull memberExpr.Expression then None else find memberExpr.Expression
65+ | _ -> None
66+ find expr
67+
68+ /// Builds a filtered query and extracts the generated ` Enumerable.Contains ` call from its expression tree.
69+ let private findEnumerableContainsCall < 'T , 'D > ( options : ObjectListFilterLinqOptions < 'T , 'D >) ( filter : ObjectListFilter ) =
70+ let query = Enumerable.Empty< 'T>() .AsQueryable()
71+ let result = query.Apply ( filter, options)
72+ match tryFindEnumerableContainsCall result.Expression with
73+ | Some call -> call
74+ | None ->
75+ fail " Expected to find Enumerable.Contains call in generated expression tree"
76+ Unchecked.defaultof< MethodCallExpression>
77+
78+ /// Asserts that ` In ` is translated to a strongly typed ` Enumerable.Contains<TField> ` expression.
79+ /// Validates method generic argument, typed values container, and non-boxed member argument.
80+ let private assertTypedInExpression < 'T , 'D >
81+ ( options : ObjectListFilterLinqOptions < 'T , 'D >)
82+ ( filter : ObjectListFilter )
83+ ( expectedElementType : Type )
84+ =
85+ let containsCall = findEnumerableContainsCall options filter
86+ containsCall.Method.GetGenericArguments().[ 0 ] |> equals expectedElementType
87+
88+ let valuesArgType = containsCall.Arguments.[ 0 ]. Type
89+ if valuesArgType = typeof< obj> || valuesArgType = typeof< obj list> then
90+ fail $" Expected a strongly typed values argument, but got {valuesArgType.FullName}"
91+
92+ let actualElementType =
93+ if valuesArgType.IsArray then
94+ valuesArgType.GetElementType ()
95+ elif valuesArgType.IsGenericType then
96+ valuesArgType.GetGenericArguments().[ 0 ]
97+ else
98+ fail $" Expected array or generic collection values argument, got {valuesArgType.FullName}"
99+ Unchecked.defaultof< Type>
100+
101+ actualElementType |> equals expectedElementType
102+
103+ match containsCall.Arguments.[ 1 ] with
104+ | :? UnaryExpression as unary when unary.NodeType = ExpressionType.Convert && unary.Type = typeof< obj> ->
105+ fail " Expected In member argument to remain strongly typed without boxing to object"
106+ | _ -> ()
107+
14108[<Fact>]
15109let ``In operator coerces string primitives`` () =
16110 let filter = In { FieldName = " name" ; Value = [ box " Alice" ; box " Bob" ] }
@@ -77,3 +171,65 @@ let ``In operator coerces single-case DU wrapping Guid`` () =
77171 let result = applyFilter filter
78172 result |> List.length |> equals 2
79173 result |> List.map ( fun e -> e.Name) |> List.sort |> equals [ " Alice" ; " Charlie" ]
174+
175+ [<Fact>]
176+ let ``In operator coerces Nullable int primitives`` () =
177+ let filter = In { FieldName = " maybeId" ; Value = [ box 1 ; box 3 ] }
178+ let result = applyNullableFilter filter
179+ result |> List.map ( fun e -> e.Id) |> List.sort |> equals [ 1 ; 3 ]
180+
181+ [<Fact>]
182+ let ``In operator expression uses typed contains for string primitive field`` () =
183+ let filter = In { FieldName = " name" ; Value = [ box " Alice" ; box " Bob" ] }
184+ assertTypedInExpression filterOptions filter typeof< string>
185+
186+ [<Fact>]
187+ let ``In operator expression uses typed contains for int primitive field`` () =
188+ let filter = In { FieldName = " id" ; Value = [ box 1 ; box 3 ] }
189+ assertTypedInExpression filterOptions filter typeof< int>
190+
191+ [<Fact>]
192+ let ``In operator expression uses typed contains for CLR enum field`` () =
193+ let filter = In { FieldName = " color" ; Value = [ box " Red" ; box " Blue" ] }
194+ assertTypedInExpression filterOptions filter typeof< Color>
195+
196+ [<Fact>]
197+ let ``In operator expression uses typed contains for fieldless DU field`` () =
198+ let filter = In { FieldName = " status" ; Value = [ box " Active" ; box " Pending" ] }
199+ assertTypedInExpression filterOptions filter typeof< Status>
200+
201+ [<Fact>]
202+ let ``In operator expression uses typed contains for Guid field`` () =
203+ let filter = In { FieldName = " guidField" ; Value = [ box " aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" ] }
204+ assertTypedInExpression filterOptions filter typeof< Guid>
205+
206+ [<Fact>]
207+ let ``In operator expression uses typed contains for single - case DU string field`` () =
208+ let filter = In { FieldName = " wrappedName" ; Value = [ box " Alice" ; box " Charlie" ] }
209+ assertTypedInExpression filterOptions filter typeof< WrappedString>
210+
211+ [<Fact>]
212+ let ``In operator expression uses typed contains for single - case DU int field`` () =
213+ let filter = In { FieldName = " wrappedScore" ; Value = [ box 10 ; box 30 ] }
214+ assertTypedInExpression filterOptions filter typeof< WrappedInt>
215+
216+ [<Fact>]
217+ let ``In operator expression uses typed contains for single - case DU Guid field`` () =
218+ let filter = In { FieldName = " wrappedGuid" ; Value = [ box " aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" ] }
219+ assertTypedInExpression filterOptions filter typeof< WrappedGuid>
220+
221+ [<Fact>]
222+ let ``In operator expression uses typed contains for option field`` () =
223+ let filter = In { FieldName = " optionName" ; Value = [ box " Alice" ; box " Charlie" ] }
224+ assertTypedInExpression filterOptions filter typeof< string option>
225+
226+ [<Fact>]
227+ let ``In operator expression uses typed contains for voption field`` () =
228+ let filter = In { FieldName = " vOptionId" ; Value = [ box 1 ; box 3 ] }
229+ assertTypedInExpression filterOptions filter typeof< int voption>
230+
231+ [<Fact>]
232+ let ``In operator expression uses typed contains for Nullable field`` () =
233+ let filter = In { FieldName = " maybeId" ; Value = [ box 1 ; box 3 ] }
234+ assertTypedInExpression nullableOptions filter typeof< Nullable< int>>
235+
0 commit comments