tOgg1 / graphene-django-cud

Easy and painless CUD-mutations for graphene-django.
MIT License
81 stars 37 forks source link

Any way to generate InputTypes without writing a mutation class? #131

Open alex-a-pereira opened 6 months ago

alex-a-pereira commented 6 months ago

Hi, I'm wondering if this library exposes any way to generate an InputType for a model in an ad-hoc way.

I was able to get this working by subclassing DjangoCreateMutation to create the InputType and add it to the registry, then accessing it below. However don't want to actually expose these mutations - I'm concerned that the mutations created might be included in the schema by another developer on the team accidentally. We really only want the InputType generated by these mutations.

In the docs it mentions

Here, the owner field will now be of type CreateUserInput!, which has to have been created before, typically via a CreateUserMutation

Is there another way to take advantage of the auto-generated InputType without creating unused subclasses of DjangoCreateMutation ?


Here's an example - I'm building a custom mutation that

Example payload from client

mutation CustomFormSubmitMut {
  customFormSubmit(input: {
    additionalItem: "extra string that the backend does something with"
    dog {
      name: "fido"
      owner {
        firstName: "alex"
      }
    }
  }) {
    wasCreated
  }
}

I was able to get that to work using the following python code. The types are what I expect - they're auto generated from the models and use the names that I assign.

# NOTE: needed because it creates an additional InputType for User model for
# use only in the CustomFormSubmitMutation.
class CustomFormCreateUserMutation(DjangoCreateMutation):
    class Meta:
        model = User
        type_name = "CustomSubmitUserInput"

# NOTE: needed because it creates an additional InputType for Dog model for
# use only in the CustomFormSubmitMutation.
class CustomFormCreateDogMutation(DjangoCreateMutation):
    class Meta:
        model = Dog 
        type_name = "CustomFormDogInput"
        foreign_key_extras = {"owner": {"type": "CustomSubmitUserInput"}}

class CustomFormInputType(graphene.InputObjectType):
    # NOTE:
    dog = CustomFormDogMutation._meta.InputType(required=True)

    # NOTE: this illustrates why we don't want Dog as the root of the mutation
    # you can imagine that maybe this custom form also has other, non-related models at the root
    additional_item = graphene.String(required=True)

class CustomFormSubmitMutation(graphene.Mutation):
    class Arguments:
        input = CustomFormInputType(required=True)

    was_created = graphene.Boolean()

    @classmethod
    def mutate(cls, root, info, input):  # noqa VNE003
        # custom mutate method handler - e.g. get_or_create() on both models, do something
        # with the `additional_item` in the input, etc.
        return CustomFormSubmitMutation(was_created=True)

I tried to manually call the utils that DjangoCreateMutation calls but got a few errors related to the registry. I probably needed to add the lines below, but I'm concerned with how fragile my approach here is

# django_graphene_cud/mutations/create.py
model_fields = get_input_fields_for_model(
            model,
            fields,
            exclude,
            tuple(auto_context_fields.keys()) + optional_fields,
            required_fields,
            many_to_many_extras,
            foreign_key_extras,
            many_to_one_extras,
            one_to_one_extras=one_to_one_extras,
            parent_type_name=input_type_name,
            field_types=field_types,
            ignore_primary_key=ignore_primary_key,
        )

        for name, field in custom_fields.items():
            model_fields[name] = field

        InputType = type(input_type_name, (InputObjectType,), model_fields)
tOgg1 commented 5 months ago

Hi @alex-a-pereira.

This is an interesting use case.

In short: No, the library does not expose any way to generate the input types in a standalone manner. However, this is something that can easily be added.

If you got some errors from the registry (either graphene-django's or graphene-django-cud's), could you share them here? It might help me in the direction of implementing this as a feature.

alex-a-pereira commented 5 months ago

@tOgg1 thanks for the quick response. Here's a minimal example. Our member model has nullable foreign key to the default User model (django.contrib.auth.models.User)

CustomFormMemberInputType = type(
    "CustomFormMemberInput",
    (graphene.InputObjectType,),
    get_input_fields_for_model(Member, ("first_name",), exclude=tuple()),
)

CustomFormUserInputType = type(
    "CustomFormUserInput",
    (graphene.InputObjectType,),
    get_input_fields_for_model(
        User,
        fields=("is_superuser",),
        exclude=tuple(),
        many_to_one_extras={"member_set": {"add": {"type": "CustomFormMemberInput"}}},
    ),
)

class CustomFormInputType(graphene.InputObjectType):
    user = CustomFormUserInputType(required=True)

class CustomFormSubmitMutation(graphene.Mutation):
    class Arguments:
        input = CustomFormInputType(required=True)

    was_created = graphene.Boolean()

    @classmethod
    def mutate(cls, root, info, input):  # noqa VNE003
        # custom mutate method handler - e.g. get_or_create() on both models, do something
        # with the `additional_item` in the input, etc.
        return CustomFormSubmitMutation(was_created=True)

This prevents django from starting up and spits out the error:

File "/Users/ME/.pyenv/versions/3.12.0/lib/python3.12/functools.py", line 995, in __get__
    val = self.func(instance)
          ^^^^^^^^^^^^^^^^^^^
  File "/Users/ME/work/repos/REPO_NAME/.venv/lib/python3.12/site-packages/graphql/type/definition.py", line 1459, in fields
    raise cls(f"{self.name} fields cannot be resolved. {error}") from error
  File "/Users/ME/work/repos/REPO_NAME/.venv/lib/python3.12/site-packages/graphql/type/definition.py", line 1456, in fields
    fields = resolve_thunk(self._fields)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/ME/work/repos/REPO_NAME/.venv/lib/python3.12/site-packages/graphql/type/definition.py", line 300, in resolve_thunk
    return thunk() if callable(thunk) else thunk
           ^^^^^^^
  File "/Users/ME/work/repos/REPO_NAME/.venv/lib/python3.12/site-packages/graphene/types/schema.py", line 309, in create_fields_for_type
    field = get_field_as(field.get_type(self), _as=Field)
                         ^^^^^^^^^^^^^^^^^^^^
  File "/Users/ME/work/repos/REPO_NAME/.venv/lib/python3.12/site-packages/graphene/types/dynamic.py", line 22, in get_type
    return self.type()
           ^^^^^^^^^^^
  File "/Users/ME/work/repos/REPO_NAME/.venv/lib/python3.12/site-packages/graphene_django_cud/util/model.py", line 563, in dynamic_type
    raise GraphQLError(f"The type {type_name} does not exist.")
graphql.error.graphql_error.GraphQLError: CustomFormUserInput fields cannot be resolved. The type CustomFormMemberInput does not exist.

My theory about it being related to the registry was correct it looks like - when I copy the code from DjangoCreateMutation.__init_subclass_with_meta__ that the CustomFormMemberInput type to the registry, it works as expected. I have reason to believe this will work to any level of nesting as well.

Here's a minimal working example:

registry = get_global_registry()
meta_registry = get_type_meta_registry()

def build_and_register_custom_form_input_type(model, fields, many_to_one_extras=None):
    input_type_name = f"CustomForm{model.__name__}Input"

    model_fields = get_input_fields_for_model(model, fields, exclude=tuple(), many_to_one_extras=many_to_one_extras)

    InputType = type(input_type_name, (graphene.InputObjectType,), model_fields)

    # Register meta-data
    meta_registry.register(
        input_type_name,
        {
            "auto_context_fields": {},
            "optional_fields": {},
            "required_fields": {},
            "many_to_many_extras": {},
            "many_to_one_extras": many_to_one_extras,
            "foreign_key_extras": {},
            "one_to_one_extras": {},
            "field_types": {},
        },
    )

    registry.register_converted_field(input_type_name, InputType)

    return InputType

CustomFormMemberInputType = build_and_register_custom_form_input_type(Member, ("first_name",))
CustomFormUserInputType = build_and_register_custom_form_input_type(
    User,
    ("is_superuser",),
    many_to_one_extras={
        "member_set": {"add": {"type": "CustomFormMemberInput"}},
    },
)

class CustomFormInputType(graphene.InputObjectType):
    user = CustomFormUserInputType(required=True)

class CustomFormSubmitMutation(graphene.Mutation):
    class Arguments:
        input = CustomFormInputType(required=True)

    was_created = graphene.Boolean()

    @classmethod
    def mutate(cls, root, info, input):  # noqa VNE003
        # custom mutate method handler - e.g. get_or_create() on both models, do something
        # with the `additional_item` in the input, etc.
        return CustomFormSubmitMutation(was_created=True)

Doing this takes advantage of graphene-django-cud's auto-generation of input types - you can see how it pulls the help_text from the is_superuser field on the default User model (awesome feature btw).

image