cmeeren / FSharp.HotChocolate

Support for F# types and nullability in HotChocolate
MIT License
7 stars 1 forks source link

Added special handling for single-case discriminated unions #18

Closed Stock44 closed 3 weeks ago

Stock44 commented 1 month ago

Single case discriminated unions should not be treated as regular DUs, instead they are usually used to model constrained types within a domain. This PR implements automatic unwrapping of these values into their field types in the GraphQL schema. This allows the direct mapping of wrapped DUs coming from a source such as Marten (that use IQueryable) to be directly exposed as part of the GraphQL schema.

The unwrapping support is only enabled for output object types, as generally input types should be validated before being wrapped.

cmeeren commented 1 month ago

Thanks!

I have a couple of issues:

But most importantly, you touch on an important point when you say this:

The unwrapping support is only enabled for output object types, as generally input types should be validated before being wrapped.

I absolutely agree, and I would take it even further: It's not always you want the inner value returned directly as-is. I have single-case DUs consisting of multiple fields, e.g. for composite IDs. I also have single-case DUs that outwardly in the API is represented by a string, even though internally they wrap e.g. byte array (for some access tokens), or they internally wrap an int, but externally are represented by (possibly opaque) strings.

Opt-in support for all of the above, strongly typed and without using reflection, can be implemented more simply than in this PR. In my code, I solve discriminated unions for both input and output using this extension method on IRequestExecutorBuilder:

type IRequestExecutorBuilder with

    member this.AddPrimitiveConverter<'domain, 'serialized, 'schemaType
        when 'schemaType :> INamedType and 'schemaType: (new: unit -> 'schemaType)>
        (parse, getValue)
        =
        let parse x =
            match parse x with
            | Ok x -> x
            | Error(msg: string) -> raise (SerializationException(msg, new 'schemaType ()))

        this
            .AddTypeConverter<'serialized, 'domain>(parse)
            .AddTypeConverter<'domain, 'serialized>(getValue)
            .BindRuntimeType<'domain, 'schemaType>()

It's called like this:

.AddPrimitiveConverter<_, _, StringType>(StatusMessageText.create, _.value)

(To be accurate, all my DUs have create and value members like this, so I use some SRTP in the extension member and just call .AddPrimitiveConverter<StatusMessageText, string, StringType>(), but the principle is the same.)

This provides opt-in and trivial support for custom domain primitives for both input and output (and works whether the primitives are DU-based or not). If we do this opt-in, I think that is the way to go about this.

Similarly, if I want to actually have a separate type for this in the schema (and not just "erase" to String or similar), I have the following base type descriptors:

type PrimitiveType<'domain, 'schema when 'schema :> IValueNode>(name, description, parse, getNode) =
    inherit ScalarType<'domain, 'schema>(name)
    do base.Description <- description

    override this.ParseLiteral(x: 'schema) : 'domain =
        parse x
        |> Result.defaultWith (fun (msg: string) -> raise (SerializationException(msg, this)))

    override this.ParseValue(x: 'domain) : 'schema = getNode x

    override this.ParseResult(resultValue) = this.ParseValue(resultValue)

type StringPrimitiveType<'domain>(name, description, parse, getValue) =
    inherit PrimitiveType<'domain, StringValueNode>(name, description, _.Value >> parse, getValue >> StringValueNode)

This allows me to define schema scalars like this:

type CountryCode2Descriptor() =
    inherit
        StringPrimitiveType<CountryCode2>(
            "CountryCode",
            "A String-based ISO 3166-1 alpha-2 two-letter country code.",
            CountryCode2.create,
            _.value
        )

    override this.Configure(descriptor) =
        base.Configure(descriptor)

        descriptor.Directive(SpecifiedByDirective("https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2"))
        |> ignore

Again, I think this is a better way to go about opt-in support for primitives (whether represented by single-case DUs or something else).

Let me know what you think. I'm not sure what of this I'm comfortable adding, because it's adapted to my usage (e.g. PrimitiveType accepting an explicit name and description), but at the very least, some of this could be documented as a good way of doing it (and maybe something could be added also, possibly with adjustments).

Stock44 commented 1 month ago

Regarding the usage of RemoveUnreachableTypes <- true, I added that because without it, the GraphQL schema is left with an object representing the DU, even though all of its usages were replaced by the respective inner type. When the DU is marked private, this errors out Schema generation, as the DU schema object has no members. I could not find a way to actually delete this type myself, as it appears that all schema discovery methods are internal in the TypeInterceptor interface.

I do prefer how your approach works internally, although the one thing I don't like is that it requires manual registration of the type descriptors. This is something I wanted to avoid, and instead make HotChocolate automatically handle the DUs. This is mostly useful when working in an implementation-first style.

I think we could maybe have both approaches, where types that require conversion can be registered manually, while simpler types can just be automatically unwrapped by the GraphQL schema. Perhaps we could even have a convention for DU methods, such as your create and value (my DUs tend to have them in the form of TryCreate and Value), so that they are automatically used for parsing and unwrapping the DU values. This would have to be done via reflection, though.

To actually enable any of this, I need to find a way to remove the leftover types after conversion. I'll also remove the unused capabilities of the SingeCaseUnionConverter

As an aside, I believe the best way to do this would be to replicate how Option support was originally added to HC. Option types are actually removed when inspecting the types, which is similar to how it should work with single case DUs. The option type converter is then responsible for wrapping and unwrapping these types. When dealing with DUs, we could use reflection to find methods that match the create and value conventions, and use them in the conversion.

All of this is done inside the TypeInfo class, which is implemented in the main HC project. Sadly, I don't see a way of replicating this behavior without an extension point there. Perhaps the new APIs intended to replace TypeInterceptor could be used here, once they are ready.

cmeeren commented 1 month ago

Please let me know if I've failed to address all of your points below.

Handling DUs by default, without registration

Despite my earlier remarks, I agree that if you can find a way to make HotChocolate support single-case DUs by unwrapping them by default, then that is acceptable (and even preferrable, given that I can see no other sensible default representation of single-case DUs). This aligns with #16, which is about making HotChocolate automatically use FSharp.HotChocolate's type descriptors for all relevant DU types returned in GraphQL.

Heck, I'd even accept by default constructing single-case DUs on input, as long as the constructor is public.

The requirements for adding this to FSharp.HotChocolate is:

Manual registration of type descriptors

I do prefer how your approach works internally, although the one thing I don't like is that it requires manual registration of the type descriptors.

If you specifically talk about type descriptors (not including type converters, the other approach I mentioned for "erased" primitives), then I actually don't register type descriptors manually; I use the following IRequestExecutorBuilder extension:

type IRequestExecutorBuilder with

    [<RequiresExplicitTypeArguments>]
    member this.AddTypesInAssembly<'a>() =
        Assembly.GetAssembly(typeof<'a>).GetTypes()
        |> Seq.filter (fun schemaType ->
            not schemaType.IsGenericType
            && tryGetBaseType
                (fun t ->
                    t.IsGenericType
                    && (let td = t.GetGenericTypeDefinition()

                        td = typedefof<ScalarType<_, _>>
                        || td = typedefof<EnumType<_>>
                        || td = typedefof<ObjectType<_>>
                        || td = typedefof<InputObjectType<_>>
                        || td = typedefof<UnionType<_>>)
                )
                schemaType
               |> Option.isSome
        )
        |> Seq.iter (fun schemaType -> this.AddType(schemaType) |> ignore<IRequestExecutorBuilder>)

        this

(I may have missed some base types in the || chain, but those are all I use at the moment.)

I call this as:

.AddTypesInAssembly<Query>()

That registers all type descriptors in the assembly containing the specified type. My thinking is that if I define a (non-generic) type descriptor, I would always want it added, and this helper does that once for the whole assembly.

This extension method is not related to F# types in any way, so it has no place in FSharp.HotChocolate. I think something like this would make a nice addition to HotChocolate itself, but it's not something I feel strongly enough that I'm willing to do something about.

If you meant to include my explicit registration of type converters in your remark, then I don't see how you can automatically implement the parse/value-based type converter generally. As I mentioned, many of my DUs have multiple representations, and I want control over which representation is used in the API. This also flows into the point below.

Convention for DU create/value methods

Perhaps we could even have a convention for DU methods, such as your create and value (my DUs tend to have them in the form of TryCreate and Value), so that they are automatically used for parsing and unwrapping the DU values. This would have to be done via reflection, though.

Since there is no standard for this in the F# community, I don't think this is a good idea to build into the library. Issues:

Also, the AddType call is as simple as this:

.AddPrimitiveConverter<_, _, StringType>(StatusMessageText.create, _.value)

With SRTP constraints suited to my methods, it's even simpler:

.AddPrimitiveConverter<StatusMessageText, string, StringType>()

Both are strongly typed and neither uses reflection, and both give me full control over how the type is represented and parsed in the API. Given all of that, this is not something I see a big need for, even with quite a few of these types.

By the way, the AddPrimitiveConverter methods are also examples of something that really isn't F#-related, but would be a nice addition to HotChocolate itself. Again, I don't feel strongly enough about this to do something about it, since the extension methods are fairly small and simple.

cmeeren commented 3 weeks ago

Closing due to lack of activity. Feel free to continue the discussion, if desired.