Skip to content

Instantly share code, notes, and snippets.

@Stock44
Last active September 20, 2024 14:00
Show Gist options
  • Select an option

  • Save Stock44/0f465a56fba5095fbf078b1d0ee4526a to your computer and use it in GitHub Desktop.

Select an option

Save Stock44/0f465a56fba5095fbf078b1d0ee4526a to your computer and use it in GitHub Desktop.
FSharp Nullability Type Interceptor
open System
open System.Collections.Concurrent
open System.Collections.Generic
open HotChocolate.Configuration
open HotChocolate.Types.Descriptors
open HotChocolate.Types.Descriptors.Definitions
open Microsoft.FSharp.Reflection
/// Memoizes the results of a unary function to optimize for repeated calls
/// with the same argument by caching the results, with physical (by reference) comparison.
let private memoize (f: 'a -> 'b) =
let equalityComparer =
{ new IEqualityComparer<'a> with
member _.Equals(a, b) = LanguagePrimitives.PhysicalEquality a b
member _.GetHashCode(a) = LanguagePrimitives.PhysicalHash a }
let cache = new ConcurrentDictionary<'a, 'b>(equalityComparer)
fun a -> cache.GetOrAdd(a, f)
let buildOptionType ty =
typedefof<_ option>.MakeGenericType([| ty |])
let isOptionType (ty: Type) =
ty.IsGenericType && ty.GetGenericTypeDefinition() = typedefof<_ option>
let buildNullableType (ty: Type) =
typedefof<Nullable<_>>.MakeGenericType([| ty |])
let getInnerOptionType =
memoize (fun ty ->
if isOptionType ty then
Some(ty.GetGenericArguments()[0])
else
None)
/// Determines if a given .NET type is an F# type.
///
/// This function evaluates the provided .NET type to check if it is one of the
/// following F# types:
/// - Record
/// - Union
/// - Tuple
/// - Exception representation (Discriminated Union)
/// - Function
/// - Module
///
/// Additionally, it recursively checks if any generic type arguments are
/// themselves F# types.
///
/// - Parameters:
/// - ty: The .NET type to evaluate.
/// - Returns: A boolean value indicating whether the type is an F# type.
let rec isFSharpType (ty: Type) =
FSharpType.IsRecord ty
|| FSharpType.IsUnion ty
|| FSharpType.IsTuple ty
|| FSharpType.IsExceptionRepresentation ty
|| FSharpType.IsFunction ty
|| FSharpType.IsModule ty
|| (ty.IsGenericType && ty.GenericTypeArguments |> Seq.exists isFSharpType)
/// Converts the type information to accommodate F# nullability.
///
/// This function processes a given type and its nullability characteristics to fit F# conventions.
/// It performs a recursive exploration of the type to ensure that nullability is aligned with F# expectations.
/// The function handles different kinds of types, such as generic arguments and arrays, and constructs the
/// appropriate nullability configuration.
///
/// Parameters:
/// - typeInspector: An instance of ITypeInspector used to obtain type information.
/// - tyRef: An ExtendedTypeReference containing the type information to convert.
///
/// Returns:
/// An ExtendedTypeReference with the converted type and adjusted nullability for F#.
let convertToFSharpNullability (typeInspector: ITypeInspector) (tyRef: ExtendedTypeReference) =
// HotChocolate basically stores a 1D nullability array for a given type. If the type has any generic args,
// They get appended to the array. It works as a depth first search of a tree, types are added in the order they are
// found. As such, here I use a loop to explore the types recursively.
let rec loop (parentType: Type, prevNullabilities: bool list) : (Type * bool list) =
match getInnerOptionType parentType with
| Some innerType ->
if innerType.IsValueType then
let t, n = loop (innerType, [ true ])
buildNullableType t, n @ (List.tail prevNullabilities)
else
let t, n = loop (innerType, [ true ])
buildOptionType t, n @ prevNullabilities
| None when parentType.GenericTypeArguments.Length > 0 ->
parentType.GenericTypeArguments
|> Array.map (fun x -> loop (x, [ false ]))
|> Array.fold (fun (ta, na) (t, n) -> (t :: ta, n @ na)) ([], prevNullabilities)
|> (fun (types, na) -> (parentType.GetGenericTypeDefinition().MakeGenericType(types |> Array.ofList), na))
| None when parentType.IsArray ->
if parentType.GetArrayRank() > 1 then
failwith "Can't convert arrays with a rank higher than 1."
let t, n = loop (parentType.GetElementType(), [ false ])
t.MakeArrayType(), n @ prevNullabilities
| None -> parentType, prevNullabilities
// The loop returns the nullabilities in reverse order, and as such must be first reversed.
let (finalTy, nullability) = loop (tyRef.Type.Type, [ false ]) //|> List.rev |> map Nullable |> Array.ofList
let finalType =
typeInspector.GetType(finalTy, nullability |> List.map Nullable |> Array.ofList |> Array.rev)
tyRef.WithType(finalType)
/// Determines if the provided ObjectTypeDefinition represents an F# type.
///
/// This function evaluates an object type definition and checks if it is an F# type
/// by considering the FieldBindingType, ExtendsType, and RuntimeType properties.
///
/// - Parameters:
/// - objectDef: The ObjectTypeDefinition to be evaluated.
let objectDefIsFSharpType (objectDef: ObjectTypeDefinition) =
if objectDef.FieldBindingType |> isNull |> not then
isFSharpType objectDef.FieldBindingType
else if objectDef.ExtendsType |> isNull |> not then
isFSharpType objectDef.ExtendsType
else
isFSharpType objectDef.RuntimeType
/// Determines if the input object definition corresponds to an F# type.
///
/// This function checks if the `ExtendsType` property of the `inputObjectDef` is not null and evaluates
/// it using the `isFSharpType` function. If `ExtendsType` is null, then `RuntimeType` of the `inputObjectDef`
/// is checked using the same function.
///
/// - Parameters:
/// - inputObjectDef: The input object type definition to be evaluated.
/// - Returns: A boolean value indicating whether the input object definition corresponds to an F# type.
let inputObjectDefIsFSharpType (inputObjectDef: InputObjectTypeDefinition) =
if inputObjectDef.ExtendsType |> isNull |> not then
isFSharpType inputObjectDef.ExtendsType
else
isFSharpType inputObjectDef.RuntimeType
/// Adapts an ArgumentDefinition to account for F# type conventions.
///
/// This function adjusts the type of the provided argument definition to handle F# nullability
/// and type conventions. It checks whether the argument type or the field type is an F# type,
/// and converts the type information accordingly.
///
/// Parameters:
/// - typeInspector: An instance that inspects types and provides necessary type information.
/// - fieldIsFSharpType: A boolean indicating whether the field type is an F# type.
/// - argumentDef: The ArgumentDefinition that will be adapted and modified.
let adaptArgumentDef typeInspector fieldIsFSharpType (argumentDef: ArgumentDefinition) =
match argumentDef.Type with
| :? ExtendedTypeReference as argTypeRef when fieldIsFSharpType || isFSharpType argTypeRef.Type.Type ->
argumentDef.Type <- convertToFSharpNullability typeInspector argTypeRef
| _ -> ()
/// Adapts the field definitions of an ObjectFieldDefinition to account for F# nullability conventions.
///
/// This function checks if the field extends an F# type or if its parent object is an F# type.
/// If applicable, it adapts the type references of the field and its arguments to handle F# nullability.
/// It also handles special cases for connections generated by the paging middleware.
///
/// Parameters:
/// - typeInspector: An instance used to inspect types and determine nullability.
/// - parentIsFSharpType: A boolean indicating whether the parent object is an F# type.
/// - fieldDef: The ObjectFieldDefinition to be inspected and adapted if necessary.
let adaptFieldDef typeInspector parentIsFSharpType (fieldDef: ObjectFieldDefinition) =
match fieldDef.Type with
// When the field is extending an FSharp type or the parent object is an FSharpType
| :? ExtendedTypeReference as extendedTypeRef ->
let fieldIsFSharpType = parentIsFSharpType || isFSharpType extendedTypeRef.Type.Type
fieldDef.Arguments
|> Seq.iter (adaptArgumentDef typeInspector fieldIsFSharpType)
if fieldIsFSharpType then
fieldDef.Type <- convertToFSharpNullability typeInspector extendedTypeRef
// When the field is generated by the Paging factory, generating a connection using a factory
| :? SyntaxTypeReference as fieldTypeRef when
fieldTypeRef.Name.EndsWith("Connection") && not (isNull fieldTypeRef.Factory)
->
// The UsePaging middleware generates the Connection type using a factory, performing its own type processing.
// Here we directly modify the Factory delegate, modifying the nodeType itself
let target = fieldTypeRef.Factory.Target // The delegate's target (i.e. where the captured variables are stored)
let nodeTypeProperty = target.GetType().GetField("nodeType") // We get the nodeType property from the type of the delegate's target, by name.
let previousTypeRef = nodeTypeProperty.GetValue(target) :?> ExtendedTypeReference // Using the property, we get the value from the delegate's target.
// We only perform the adaptation if the connection is paginating an FSharp type.
if parentIsFSharpType || isFSharpType previousTypeRef.Type.Type then
nodeTypeProperty.SetValue(
fieldTypeRef.Factory.Target,
convertToFSharpNullability typeInspector previousTypeRef
)
| _ -> ()
/// Adapts the type of an input field definition to handle F#-style nullability.
///
/// This function checks the type of the provided input field definition. If the type is an `ExtendedTypeReference` and
/// the parent or the extended type is an F# type, it converts the type for F# nullability using the provided type inspector.
///
/// - Parameters:
/// - typeInspector: An instance of `ITypeInspector` used to inspect types.
/// - parentIsFSharpType: A boolean indicating if the parent type is an F# type.
/// - inputFieldDef: The input field definition whose type is to be adapted.
let adaptInputFieldDef typeInspector parentIsFSharpType (inputFieldDef: InputFieldDefinition) =
match inputFieldDef.Type with
| :? ExtendedTypeReference as extendedTypeRef ->
if parentIsFSharpType || isFSharpType extendedTypeRef.Type.Type then
inputFieldDef.Type <- convertToFSharpNullability typeInspector extendedTypeRef
| _ -> ()
/// Adapts the fields of an ObjectTypeDefinition to account for F# nullability conventions.
///
/// This function checks if the ObjectTypeDefinition represents a record type.
/// If it does, it iterates over the fields of the object definition and adapts
/// the type references of the fields to handle F# nullability.
///
/// Parameters:
/// - typeInspector: An instance that allows inspection of types, used for determining nullability.
/// - objectDef: The ObjectTypeDefinition to be inspected and adapted if it is a record type.
let adaptObjectDef typeInspector (objectDef: ObjectTypeDefinition) =
objectDef.Fields
|> Seq.iter (adaptFieldDef typeInspector (objectDefIsFSharpType objectDef))
/// Adapts the fields of an input object definition to handle F#-style nullability.
///
/// This function checks if the `RuntimeType` of the provided input object definition is an F# record type.
/// If it is, it iterates over the fields of the input object definition to adapt their types for F# nullability.
///
/// - Parameters:
/// - typeInspector: An instance of `ITypeInspector` used to inspect types.
/// - inputObjectDef: The input object type definition whose fields are to be adapted.
let adaptInputObjectDef typeInspector (inputObjectDef: InputObjectTypeDefinition) =
inputObjectDef.Fields
|> Seq.iter (adaptInputFieldDef typeInspector (inputObjectDefIsFSharpType inputObjectDef))
/// Intercepts the nullability settings for F# types within the GraphQL schema.
/// This interceptor ensures that F# record fields are correctly
/// represented in the GraphQL schema.
type FsharpNullabilityInterceptor() =
inherit TypeInterceptor()
override this.OnAfterInitialize(discoveryContext, definition) =
let typeInspector = discoveryContext.TypeInspector
match definition with
| :? ObjectTypeDefinition as objectDef -> adaptObjectDef typeInspector objectDef
| :? InputObjectTypeDefinition as inputObjectDef -> adaptInputObjectDef typeInspector inputObjectDef
| _ -> ()
@cmeeren
Copy link

cmeeren commented Sep 18, 2024

In ChilliCream/graphql-platform#6884 (comment), I said:

From a cursory look, it seems like it applies the non-null-by-default policy only for F# types. Is that correct? I'd argue that it's idiomatic in F# that all types are non-null by default; even BCL reference types such as string should be Option/ValueOption-wrapped to indicate nullability.

The response in ChilliCream/graphql-platform#6884 (comment) was:

I initially wanted to make it work like that, but the problem with that approach is dealing with types not defined in FSharp, mainly middlewares and types generated by HotChocolate itself. Types such as string and int get wrapped depending on their context, whether they're used within FSharp types and so. The code I shared doesn't account for ValueOptions though, it only checks for Option.

I'd like to continue the discussion here.

Could one use reflection to do this for any type defined in an F# assembly? Or (perhaps simpler to get started) for all types in specific assemblies the user specifies?

@Stock44
Copy link
Author

Stock44 commented Sep 18, 2024

It should be possible. From a quick search, I think something like asm.GetTypes().Where(fun x -> x.FullName.StartsWith("<StartupCode$")) (found here) could be used to determine whether a given assembly was compiled from F# code. With that, you could just replace the isFSharpType function to something that checks if the type's assembly was compiled from F#.

However, I'm not sure that is really that different than what is being done right now. The existing code already uses F#'s built-in reflection functions to check for FSharp types, and the current logic makes it so that their presence "contaminates" surrounding types (fields and generic arguments), making them use FSharp nullability rules, even if they themselves are not FSharp types.

@cmeeren
Copy link

cmeeren commented Sep 18, 2024

First, a couple of possible bugs:

  • I have an [<ID>]-annotated field that is of type Guid option, but with your interceptor it's ID! and not ID. (This is, I think, the default behavior, cf. ChilliCream/graphql-platform#6884)
  • Something similar happens with an input field I have of type Guid[] option, which in the schema becomes [ID!]! instead of the expected [ID!].

As to your reply, thanks for the insight! My problem is that I am working with a legacy VB code base served by my new F# GraphQL API. For simplicity, I return e.g. a legacy User type, but I use explicit binding and type extensions to explicitly specify what I want in the GraphQL API. Still, since it's F# returning the type, I want it non-nullable unless option-wrapped. (In my code, I am making sure to always convert null to option when working with the legacy code base.)

@Stock44
Copy link
Author

Stock44 commented Sep 18, 2024

I think those two will require some looking around the inner workings of the global identification middleware. From what I've seen, middleware rewrite types using factory types and their own internal logic, hiding the typings behind delegates. This requires some reflection magic to work around, like what I did for the paging middleware. I think I'll work on that later this week.

As for your case adding that functionality should be simple, you could probably just pass a list of types into the constructor of the Type interceptor, and inside the isFSharpType function just check if the type is included.

@cmeeren
Copy link

cmeeren commented Sep 19, 2024

I think I'll work on that later this week.

Great! 🎉

I came across another bug - it doesn't work well with GraphQLType.

Example 1

Consider this record:

type MyRecord = {
    [<GraphQLType(typeof<FloatType>)>]
    DecimalAsFloatNullable: decimal option
}

(Note that the result is the same whether you use FloatType or float.)

It gives this schema:

type MyRecord {
  decimalAsFloatNullable: Float!
}

Since it's option-wrapped, the expected schema is this:

type MyRecord {
  decimalAsFloatNullable: Float
}

A workaround for this example is to use float option in the attribute. You'd have to remember to keep that in sync with the actual return type, though, so it's not ideal.

Example 2

I assume the underlying issue is the same, but AFAIK the workaround can't be used in this example.

Consider this code:

type MyUnionDescriptor() =
    inherit UnionType()

    override _.Configure(descriptor: IUnionTypeDescriptor) : unit =
        descriptor.Name("MyUnion") |> ignore
        descriptor.Type<ObjectType<A>>() |> ignore
        descriptor.Type<ObjectType<B>>() |> ignore


type MyRecord = {
    [<GraphQLType(typeof<MyUnionDescriptor>)>]
    MyUnionNullable: obj option
}

Imagine that A and B are types you don't control, so you can't make them implement an interface and return that. Hence returning obj (casting either A or B to obj). Yes, it looks a bit weird with obj option in a record; in reality, I'd use a HotChocolate type extension here.

It gives this schema:

type MyRecord {
  myUnionNullable: MyUnion!
}

Since it's option-wrapped, the expected schema is this:

type MyRecord {
  myUnionNullable: MyUnion
}

@cmeeren
Copy link

cmeeren commented Sep 19, 2024

I think the issue above stems from an implicit assumption in this interceptor that the type HotChocolate returns in, say, ExtendedTypeReference.Type.Type corresponds to the actual type returned by the field in code. I think this is the underlying cause of the issue with ID, too.

Perhaps the actual nullability information can be taken from ObjectFieldDefinition.ResultType. However, I'm not sure what to do with it. I've been banging my head on this for a while without getting anywhere.

@cmeeren
Copy link

cmeeren commented Sep 19, 2024

Progress report: I'm having some luck with this simplified convertToFSharpNullability. I get serialization errors though, so there's still stuff I haven't understood regarding HC's type internals. (Turns out the serialization errors were due to forgetting AddFSharpTypeConverters as well as HC not supporting option-wrapped sequences as list types with or without the interceptor, so I think I'm on to something!)

let convertToFSharpNullability (typeInspector: ITypeInspector) (tyRef: ExtendedTypeReference) (resultType: Type) =

    let getDepthFirstNullabilityList skipOptionLevel ty : bool list =
        let rec recurse parentIsOption (ty: Type) =
            match getInnerOptionType ty with
            | Some innerType ->
                let current = if skipOptionLevel then [] else [parentIsOption]
                current @ recurse true innerType
            | None when ty.IsArray ->
                [parentIsOption] @ recurse false (ty.GetElementType())
            | None when ty.IsGenericType ->
                [parentIsOption] @ (ty.GenericTypeArguments |> Seq.collect (recurse false) |> Seq.toList)
            | None -> [parentIsOption]
            
        recurse false ty
    
    // Assumptions:
    //   1. If the types don't match, then the user has used GraphQLTypeAttribute.
    //   2. In that case, GraphQLTypeAttribute specifies the type ignoring option wrappers.
    let skipOptionLevel = tyRef.Type.Type <> resultType

    let finalType =
        typeInspector.GetType(tyRef.Type.Type, getDepthFirstNullabilityList skipOptionLevel resultType |> Seq.map Nullable |> Seq.toArray)

    tyRef.WithType(finalType)

Since it has an additional parameter, call sites must be modified. I have only tested the ExtendedTypeReference in adaptFieldDef so far. The call is convertToFSharpNullability typeInspector extendedTypeRef fieldDef.ResultType.

It passes the following record:

type MyRecord = {
    Float: float
    OptionOfFloat: float option
    ArrayOfFloat: float []
    ArrayOfOptionOfFloat: float option []
    ListOfFloat: float list
    ListOfOptionOfFloat: float option list
    OptionOfArrayOfFloat: float [] option
    OptionOfArrayOfOptionOfFloat: float option [] option
    OptionOfListOfFloat: float list option
    OptionOfListOfOptionOfFloat: float option list option
    [<GraphQLType(typeof<FloatType>)>]
    DecimalAsFloat: decimal
    [<GraphQLType(typeof<FloatType>)>]
    OptionOfDecimalAsFloat: decimal option
}

This produces the expected schema:

type MyRecord {
  float: Float!
  optionOfFloat: Float
  arrayOfFloat: [Float!]!
  arrayOfOptionOfFloat: [Float]!
  listOfFloat: [Float!]!
  listOfOptionOfFloat: [Float]!
  optionOfArrayOfFloat: [Float!]
  optionOfArrayOfOptionOfFloat: [Float]
  optionOfListOfFloat: [Float!]
  optionOfListOfOptionOfFloat: [Float]
  decimalAsFloat: Float!
  optionOfDecimalAsFloat: Float
}

@cmeeren
Copy link

cmeeren commented Sep 19, 2024

My main problem at the moment (regardless of whether I use your interceptor, my interceptor, or no interceptor) is that option-wrapped list types do not work at runtime; HC returns EXEC_LIST_TYPE_NOT_SUPPORTED. Not sure what to do about that. I raised an issue in ChilliCream/graphql-platform#7472.

{
  "errors": [
    {
      "message": "The type `Microsoft.FSharp.Core.FSharpOption`1[[System.Double[], System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]` is not supported as list value.",
      "locations": [
        {
          "line": 3,
          "column": 5
        }
      ],
      "path": [
        "record",
        "optionOfArrayOfFloat"
      ],
      "extensions": {
        "code": "EXEC_LIST_TYPE_NOT_SUPPORTED"
      }
    }
  ],
  "data": {
    "record": {
      "optionOfArrayOfFloat": null
    }
  }
}

@cmeeren
Copy link

cmeeren commented Sep 20, 2024

Seems that problem can be solved with field middleware. Investigating further.

@cmeeren
Copy link

cmeeren commented Sep 20, 2024

I believe I am on to something! Taking the rest to a separate repo. Details in this comment.

@Stock44
Copy link
Author

Stock44 commented Sep 20, 2024

Nice work so far! Hopefully full support for F# collections can be implemented cleanly. I'll check out the repo and help if I can.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment