Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
</ItemGroup>

<ItemGroup>
<Compile Include="ObjectListFilter.fs" />
<Compile Include="FilterSuffixConstants.fs" />
<Compile Include="ObjectListFilter.fs" />
<Compile Include="TypeCoercion.fs" />
<Compile Include="ObjectListFilterModule.fs" />
<Compile Include="TypeSystemExtensions.fs" />
<Compile Include="SchemaDefinitions.fs" />
<Compile Include="MiddlewareDefinitions.fs" />
Expand Down
385 changes: 20 additions & 365 deletions src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilter.fs

Large diffs are not rendered by default.

407 changes: 407 additions & 0 deletions src/FSharp.Data.GraphQL.Server.Middleware/ObjectListFilterModule.fs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ type private ComparisonOperator =
| LessThanOrEqual of string
| In of string


let rec private coerceObjectListFilterInput (variables : Variables) inputValue : Result<ObjectListFilter voption, IGQLError list> =

let parseFieldCondition (s : string) =
Expand Down Expand Up @@ -184,7 +183,7 @@ let ObjectListFilterType : InputCustomDefinition<ObjectListFilter> = {
(String.concat
" "
[
"The ObjectListFilter value represents field filters for object lists."
"The `ObjectListFilter` value represents field filters for object lists."
"Lowercase string suffixes such as `_starts_with`/`_sw`, `_ends_with`/`_ew`, `_contains` (no shorthand), and `_equals`/`_eq` are case-insensitive when applied to string fields."
"Capitalized string suffixes such as `_Starts_With`/`_SW`, `_Ends_With`/`_EW`, `_Contains` (no shorthand), and `_Equals`/`_EQ` are case-sensitive when applied to string fields."
"Comparison suffixes such as `_gt`, `_gte`, `_lt`, `_lte`, and `_in` are also supported."
Expand Down
259 changes: 259 additions & 0 deletions src/FSharp.Data.GraphQL.Server.Middleware/TypeCoercion.fs
Original file line number Diff line number Diff line change
@@ -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

[<RequireQualifiedAccess>]
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<string>

/// <summary>
/// If <paramref name="t"/> is <c>voption</c>, <c>option</c>, or <c>Skippable</c>, returns the inner type; otherwise <see langword="ValueNone"/>.
/// </summary>
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

/// <summary>
/// If <paramref name="t"/> is a generic collection, returns the element type. Handles both concrete collections (where <c>IEnumerable</c> is an
/// implemented interface) and properties typed directly as <c>IEnumerable&lt;T&gt;</c>.
/// </summary>
let tryUnwrapEnumerableElement (t : Type) : Type voption =
let isEnumerableInterface (i : Type) =
i.IsGenericType
&& Type.(=) (i.GetGenericTypeDefinition (), typedefof<IEnumerable<_>>)
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])

/// <summary>
/// Suffixes the middleware's parser preserves on <c>FieldFilter.FieldName</c> for scalar operators (e.g. <c>meetingId_eq</c>, <c>
/// validFrom_gte</c>). They must be stripped before resolving the actual CLR property.
/// These correspond to the lowercase variants produced after Phase 2 parsing in <c>SchemaDefinitions.parseFieldCondition</c>. Longer suffixes are
/// listed first to prevent shorter ones (e.g. <c>_gt</c>) from incorrectly matching longer ones (e.g. <c>_gte</c>).
/// </summary>
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

/// <summary>
/// Writes a boxed GraphQL scalar primitive as a JSON token directly into <paramref name="writer"/>. Strings become JSON strings; numbers and
/// booleans become raw JSON tokens. Returns <c>true</c> if the value was written; <c>false</c> if the type is unsupported.
/// </summary>
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"
/// <summary>
/// Tries to coerce a value into <paramref name="targetType"/> using STJ deserialization. Primitives are written directly as JSON bytes via
/// <see cref="writeJsonValue"/> into an <see cref="ArrayBufferWriter{T}"/>, then deserialized from <c>ReadOnlySpan&lt;byte&gt;</c>.
/// Already-correct values pass through unchanged. No intermediate string or <see cref="JsonDocument"/> is allocated.
/// </summary>
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<byte> 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

/// <summary>
/// Coerces an entire <see cref="ObjectListFilter"/> tree recursively by resolving the entities's properties and converting filter values into the
/// property's CLR type.
/// </summary>
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 }
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions src/FSharp.Data.GraphQL.Shared/Helpers/Extensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,35 @@ module Array =
i <- i + 1
Array.sub temp 0 i

/// <summary>
/// Attempts to find the first element in an array that satisfies the given predicate.
/// </summary>
/// <param name="predicate">Function to test each element.</param>
/// <param name="source">The input array.</param>
/// <returns>ValueSome of the first matching element, or ValueNone if no match is found.</returns>
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

/// <summary>
/// Applies a function to each element of an array and returns the first result where the function returns ValueSome.
/// </summary>
/// <param name="mapping">Function to apply to each element.</param>
/// <param name="source">The input array.</param>
/// <returns>ValueSome of the first successful mapping result, or ValueNone if no match is found.</returns>
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 =

/// <summary>
Expand All @@ -99,6 +128,35 @@ module List =
|> List.filter (fun x -> not <| List.exists(fun y -> f(x) = f(y)) listy)
uniqx @ listy

/// <summary>
/// Attempts to find the first element in a list that satisfies the given predicate.
/// </summary>
/// <param name="predicate">Function to test each element.</param>
/// <param name="source">The input list.</param>
/// <returns>ValueSome of the first matching element, or ValueNone if no match is found.</returns>
let rec vtryFind predicate (source : 'T list) =
match source with
| [] -> ValueNone
| head :: tail ->
if predicate head then
ValueSome head
else
vtryFind predicate tail

/// <summary>
/// Applies a function to each element of a list and returns the first result where the function returns ValueSome.
/// </summary>
/// <param name="mapping">Function to apply to each element.</param>
/// <param name="source">The input list.</param>
/// <returns>ValueSome of the first successful mapping result, or ValueNone if no match is found.</returns>
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 =

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,11 @@
<Compile Include="Variables and Inputs\InputEnumTests.fs" />
<Compile Include="Variables and Inputs\InputListTests.fs" />
<Compile Include="SelectLinqTests.fs" />
<Compile Include="ObjectListFilterLinqTests.fs" />
<Compile Include="ObjectListFilterLinqGenerateTests.fs" />
<Compile Include="ObjectListFilter\ObjectListFilterLinqTests.fs" />
<Compile Include="ObjectListFilter\ObjectListFilterLinqGenerateTests.fs" />
<Compile Include="ObjectListFilter\TypeCoercionTests.Common.fs" />
<Compile Include="ObjectListFilter\TypeCoercionValueTests.fs" />
<Compile Include="ObjectListFilter\TypeCoercionFilterTests.fs" />
<Compile Include="DeferredTests.fs" />
<Compile Include="SubscriptionTests.fs" />
<Compile Include="MiddlewareTests.fs" />
Expand Down
Loading
Loading