-
-
Save Stock44/0f465a56fba5095fbf078b1d0ee4526a to your computer and use it in GitHub Desktop.
| 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 | |
| | _ -> () |
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.
First, a couple of possible bugs:
- I have an
[<ID>]-annotated field that is of typeGuid option, but with your interceptor it'sID!and notID. (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.)
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.
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
}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.
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
}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
}
}
}Seems that problem can be solved with field middleware. Investigating further.
I believe I am on to something! Taking the rest to a separate repo. Details in this comment.
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.
In ChilliCream/graphql-platform#6884 (comment), I said:
The response in ChilliCream/graphql-platform#6884 (comment) was:
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?