thecodingmachine / graphqlite

Use PHP Attributes/Annotations to declare your GraphQL API

Home Page:https://graphqlite.thecodingmachine.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Nested `Input` annotated types within `Factory` defined inputs causes type conflicts

oojacoboo opened this issue · comments

See #532.

Currently, factories require a Type annotation on the target class. This is problematic if a field of that class is typed as an Input type. One possible solution here would be to update the factory logic to require an Input attribute. To be compatible with the existing input type mapping, we'd need to add a custom parameter on the Input attribute, to denote that the input type is created with a factory.

I also think we could probably just get rid of the Factory attribute, altogether and have the Input property point to the factory method instead.

I think for most people, a factory isn't needed at all. A constructor on a DTO style Input defined class is more than sufficient.

Consider the following, where factory takes a callable.

#[GraphQLite\Input(factory: [FooInputFactory::class, 'build'])
class FooInput
{
    public function __construct(
        public readonly ID $id,
    ) {}
}

class FooInputFactory
{
    public function build(ID $id): FooInput
    {
        return new FooInput($id);
    }
}

Can you give a code example to better understand the problem? I have many factories in use that map input types to models (without any annotation) just fine. You mentioned in #532 however that the issue could be linked to using input types within input types, so that might be the problematic combination.

@mdoelker Building off that last example. Here is how it is currently. You cannot mix factories and Input annotated classes together. A factory requires that you annotate the input type class with a Type annotation. Which is really wrong. That should be for an output type, not an input type. And, I haven't checked the schema to check, but it's possible it's also generating an output type for that class as well.

#[GraphQLite\Type)
class FooInput
{
    public function __construct(
        public readonly ID $id,
    ) {}
}

class FooInputFactory
{
    #[GraphQLite\Factory]
    public function build(ID $id): FooInput
    {
        return new FooInput($id);
    }
}

#[GraphQLite\Input)
class BarInput
{
    public function __construct(
        #[GraphQLite\Field]
        public readonly ID $id,

        #[GraphQLite\Field]
        public readonly FooInput $foo,
    ) {}
}

This might be a misunderstanding of the role of a factory. Returning an Input-annotated class from a factory does not make much sense to me, as the factory itself represents the GraphQL input type. You use one or the other.

Let's imagine your build method would return a Foo class which is a model in your application. Then the parameters of that build method become the fields of the generated input type and its name will be Foo + Input (see https://github.com/thecodingmachine/graphqlite/blob/master/src/NamingStrategy.php#L83). Your mutation just type-hints the model and the factory takes care of creating that instance for you.

// This is our plain old model.
class Foo
{
  public function __construct(
    public readonly string $id,
    public readonly string $value,
  )
  {}
}

// The class holding the factory that represents the GraphQL input type.
// It convert the values from the input type to a Foo instance.
class FooFactory
{
  #[Factory]
  public function createFoo(ID $id, string $value): Foo
  {
    return new Foo((string) $id, $value);
  }
}

// The class mapping a Foo instance to a GraphQL output type.
#[Type(class: Foo::class)]
class FooType
{
  public function getId(Foo $foo): ID
  {
    return new ID($foo->id);
  }

  public function getValue(Foo $foo): string
  {
    return $foo->value;
  } 
}

// The class holding the mutation. The incoming Foo instance is mapped with FooFactory,
// the returned Foo instance is mapped via FooType.
class Controller
{
  #[Mutation]
  public function someMutation(Foo $foo): Foo
  {
    // do something with $foo

    return $foo;
  }
}

Your factory returns an Input-annotated class which is redundant. You should use the FooInput class directly in the method representing your mutation:

class Controller
{
  #[Mutation]
  public function someMutation(FooInput $foo): string
  {
    return $foo->value;
  }
}

That said, I'm not sure about the BarInput use case where a field is another input type. I never had the need to do that, but this seems like it should be supported. I will try this later.

@mdoelker That's correct. I removed the #[Field] attribute that was on the FooInput class. That isn't needed.

As for the Type or the Input annotation needing to be on the target class - yea, they're pointless really, as the Factory is what is defining the actual input type. At any rate, requiring a Type annotation, as it currently is, is certainly not ideal.

All of this is a result of factories being the only way you could create input types, initially. The Input annotation didn't exist, so the Type annotation was used. In that context, it makes sense. Technically, both input and output are types. However, as it is now, a Type annotated class is really an output and Input is an input.

But yea, the issue is when you're mixing them with a nested input, which is what happened in our case as we began slowly migrating from factories to using Input annotated classes.

Maybe we should just remove the requirement for a target class to have any annotations at all, and solely rely on the Factory annotated method. That would be a BC friendly change that should resolve the nested input type issue as well.

In my example above, which represents how I use factories in my codebase, the target class has no annotations and knows nothing about graphqlite. So that works perfectly well already.

@mdoelker hmm, maybe it was only requiring the annotation because of the nested input then. We've now migrated all our factories over to Input annotated classes and the issue no longer exists. I guess nested inputs really aren't going to be all that common and mixing Input annotated classes with Factory built inputs is also less likely.

We need a testcase to replicate this issue.