graphql-platform icon indicating copy to clipboard operation
graphql-platform copied to clipboard

Add an improved annotation for polymorphic use of `OneOf`

Open atrauzzi opened this issue 3 years ago • 12 comments

Is your feature request related to a problem?

Yes! :slightly_smiling_face:

Currently the implementation of oneOf in Hot Chocolate doesn't offer any measurable convenience or even any degree of "polymorphism" as implied by the spec and much of the documentation.

From a GraphQL spec point of view, it's just a mutally exclusive assertion: That one out of a handful of fields is non-null.

When consuming oneOf functionality in Hot Chocolate, nothing is done to leverage the common interface and it's still up to the developer to figure out which of the numerous fields is non-null, thus requiring a small-but-gross amount of boilerplate.

(It's a little funny, the documentation has // Omitted code for brevity in the sections where the most of the actual benefit people expect with oneOf to happen.)

[OneOf]
public class PetInput
{
    public Cat? Cat { get; set; }
    public Dog? Dog { get; set; }
    public Fish? Fish { get; set; }
}

public class Cat : IPet {}
public class Dog : IPet {}
public class Fish : IPet {}

public class Mutation
{
    public Task<IPet> CreatePetAsync(PetInput input)
    {
        // This is what's being omitted for "brevity". Hot Chocolate can totally help avoid this kind of boilerplate code.
        var pet = input.Cat ?? input.Dog ?? input.Fish ?? throw new("Unreachable thanks to `oneOf`.");

        return pet;
    }
}

Overall, I think Hot Chocolate can be of more assistance to the developer when the desired use of oneOf is for polymorphism.

The solution you'd like

In polymorphic scenarios, declaring InputType should be considered unnecessary. Hot Chocolate shouldn't require me to author something like a PetInput, but instead should be generating that type for the client automatically.

Accepting that the current OneOf exists and could still be useful in other scenarios, Hot Chocolate should introduce something that behaves largely like a union type, but when used for input, performs the work necessary to marshal a value of the correct type to the mutation:

[PolymorphicOneOf]
public interface IPet
{
}

public class Cat : IPet {}
public class Dog : IPet {}
public class Fish : IPet {}

public class Mutation
{
    public Task<IPet> CreatePetAsync(IPet pet)
    {
        // No code needs to be omitted for brevity anymore, `pet` is an `IPet`, yay!
        return pet;
    }
}

Product

Hot Chocolate

atrauzzi avatar May 18 '22 13:05 atrauzzi

well,

[PolymorphicOneOf]
public interface IPet
{
}

public class Cat : IPet {}
public class Dog : IPet {}
public class Fish : IPet {}

this does make naming of the fields difficult. but we know that the api has to improve to avoid all the ??.

If we introduce a pattern we also have to take this into account:

[OneOf]
public class ScalarOneOf
{
    public string? Foo { get; set; }
    public int? Bar { get; set; }
    public double? Baz { get; set; }
}

Maybe something like:

public class Mutation
{
    public Task<IPet> CreatePetAsync(OneOf<PetInput> input)
    {
        return input.Get<IPet>(); // throws when not of type IPet

        // or
        
        if(input.TryGet(out IPet pet)) 
        {
               throw new("Unreachable thanks to `oneOf`.");
        }
        return pet;

        // or

        return input.Value switch {
           IPet p => p,
           _ =>    throw new("Unreachable thanks to `oneOf`.");
        
        };
    }
}

PascalSenn avatar May 18 '22 14:05 PascalSenn

I like it (particularly option 3)! Being able to pull out a value based on interface would be pretty neat!

atrauzzi avatar May 18 '22 14:05 atrauzzi

We were chatting about this in the morning 😅 so we will make it easier, it probably will make its way into 12.10

michaelstaib avatar May 18 '22 18:05 michaelstaib

That include 13.x? :slightly_smiling_face:

atrauzzi avatar May 19 '22 01:05 atrauzzi

@michaelstaib - Am I right in understanding that the OneOf type is the only value that can be accepted by the mutation when used? Or can I have other types alongside a OneOf decorated type?

public async Task<bool> MyDemoMutation(
    MyOneOfSomethings oneOfSomething,
    Guid someIdentifier
)
{
    // ...
}

atrauzzi avatar May 19 '22 14:05 atrauzzi

@atrauzzi oneof is only scoped for this input object

PascalSenn avatar May 19 '22 15:05 PascalSenn

Good stuff. Anyway, still love the ideas from @PascalSenn. Would really offer a benefit!

atrauzzi avatar May 20 '22 16:05 atrauzzi

A followup question...

Can I do a list/array of OneOf values with things as they are currently?

atrauzzi avatar Jun 07 '22 19:06 atrauzzi

Not stale.

atrauzzi avatar Oct 08 '22 19:10 atrauzzi

Need this feature because I have a domain model with nested OneOf. Such polymorphic way of doing things will save me from creating new classes.

class MyModel
{
  public ICommonOperation { get; set; }
  public string SomeProperty { get; set; }  
}

With the way things are now I have to introduce new classes instead of just using MyModel for input

public class CommonOperationInput  {
  Operation1? Operation1 {get;set; }
  Operation2? Operation2 {get; set; }
  }

public class MyModelInput
{
  public CommonOperationInput { get; set; }
  public string SomeProperty { get; set; }  
}

Spaier avatar Jan 27 '23 06:01 Spaier

I have the exact same problem and concerns about this, like @Spaier we have a domain object with polymorphic objects or lists of those (f.e. different implementations of a base class). To get strongly typed schema we use union types, so we had to use one-of for the corresponding input objects. As one can not use middlewares on arguments or input objects (or can one?), i assume this has to be done somehow like Michael demonstrated in the youtube video on rewriting resolver arguments - but the example on simple strings does not get that far. Although announced, there isn't yet an episode on syntax rewriters for complex input objects.

It would be so so great to get some helper methods for this or at least an example on how to do this "the complicated way"..

And in addition to the proposed helpers by @PascalSenn: in my case the fields with polymorphic objects or object lists are somehow nested in a complex object and therefore not directly the root input argument. so i would prefer more like a value converter, directly converting between a union type and its corresponding one-of input, f.e. during configuration of the one-of input object; something neat like

public class MyOneOfInputType : InputObjectType<MyOneOfInput>
    {
        protected override void Configure(IInputObjectTypeDescriptor<MyOneOfInput> descriptor)
        {
            base.Configure(descriptor);
            descriptor.OneOf()
                   // here the runtime type should be rewritten
                    .ConvertToUnionType<MyUnionType>(
                           // if not automatically possible expression or function parameter to do the conversion..
                    );
        }
    }

jhpetersen avatar Jul 12 '23 14:07 jhpetersen

Also just ran into a situation where I want to be able to use a union type (or oneof) in a filter.

atrauzzi avatar Jan 15 '24 01:01 atrauzzi