diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index dac839b..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: pre-commit - -on: - pull_request: - push: - branches: [ develop, master ] - -jobs: - pre-commit: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: '3.8' - - uses: pre-commit/action@v2.0.3 diff --git a/.github/workflows/primary.yml b/.github/workflows/primary.yml index 0dcf9d0..2252f6e 100644 --- a/.github/workflows/primary.yml +++ b/.github/workflows/primary.yml @@ -10,21 +10,18 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] - poetry-version: ["1.3.1"] + python-version: [ "3.9", "3.10", "3.11", "3.12" ] + poetry-version: [ "1.8.3" ] steps: - - uses: actions/checkout@v2 - - name: Run image - uses: abatilo/actions-poetry@v2.2.0 - with: - poetry-version: ${{ matrix.poetry-version }} - + - uses: actions/checkout@v4 + - name: Install Poetry + uses: snok/install-poetry@v1 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'poetry' @@ -36,23 +33,23 @@ jobs: release: needs: test if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: 3.12 + - name: Install Poetry + uses: snok/install-poetry@v1 - name: Install dependencies run: | pip install twine - pip install poetry==1.1.4 pip install wheel - poetry config virtualenvs.create false - poetry install --no-interaction + poetry install - name: Build package run: make build - name: Publish package diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55b10e2..eb1febf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: exclude: (migrations/|locale/|docs/) args: - --line-length=120 -- repo: https://gitlab.com/pycqa/flake8 +- repo: https://github.com/pycqa/flake8 rev: 3.9.2 hooks: - id: flake8 diff --git a/docs/guide/nested-fields.rst b/docs/guide/nested-fields.rst index 4b0b7df..364a3ed 100644 --- a/docs/guide/nested-fields.rst +++ b/docs/guide/nested-fields.rst @@ -283,6 +283,62 @@ populator of the relationship. If you don't have an existing type for creating a user, e.g. the "CreateCatInput" we used above, you can set the type to "auto", which will create a new type. +Many to many with `through` models +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The way Django handles a `through` model internally is functionally similar to using many to one relationships. This +implies that the more convienient option is to use `many_to_one_extras` instead of `many_to_many_extras` when dealing +with `through` models. + +If you have a many to many relationship with a `through` model, you can use +a `many_to_one_extras` field to specify how to handle the `through` model. +Due to how the m2m fields are automatically generated using the input schema, it is recommended to use the +`many_to_one_extras` field instead of the `many_to_many_extras` field. + +Suppose we have a `Dog` model with a many to many relationship +with a `Cat` model, but we want to keep track of the number of times a dog +has fought a cat. We can do this with a `through` model: + +.. code:: python + + class Dog(models.Model): + owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) + name = models.TextField() + fights = models.ManyToManyField(Cat, through='Fight') + + class Fight(models.Model): + dog = models.ForeignKey(Dog, on_delete=models.CASCADE, related_name='fights') + cat = models.ForeignKey(Cat, on_delete=models.CASCADE, related_name='fights') + times = models.IntegerField(default=0) + +We can then create a mutation to create a dog, and add a fight to it: + +.. code:: python + + class CreateDogMutation(DjangoCreateMutation): + class Meta: + model = Dog + many_to_one_extras = { + 'fights': { + 'exact': {"type": "auto"} + } + } + +This will infer the dog's ID, and allows us to create a fight in the same +mutation: + +.. code:: + + mutation { + createDog(input: { + name: "Buster", + fights: [{cat: "Q2F0Tm9kZTox", times: 1}] + }}){ + dog{ + ...DogInfo + } + } + } + One to one extras ~~~~~~~~~~~~~~~~~ diff --git a/graphene_django_cud/converter.py b/graphene_django_cud/converter.py index ed5df0b..7fa1fbc 100644 --- a/graphene_django_cud/converter.py +++ b/graphene_django_cud/converter.py @@ -8,7 +8,6 @@ # # From the last point, users of this module are expected to discard any field returning None from functools import singledispatch -from typing import Union import graphql from django.db import models @@ -31,7 +30,6 @@ Dynamic, Decimal, ) -from graphene.types import enum from graphene.types.json import JSONString from graphene_django.compat import ArrayField, HStoreField, RangeField from graphene_file_upload.scalars import Upload @@ -119,12 +117,12 @@ def description(self): def convert_django_field_with_choices( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): choices = getattr(field, "choices", None) @@ -160,9 +158,11 @@ def convert_django_field_with_choices( else: # Fetch the actual converted Choices class. We have to do this with a slightly shady usage of # the protected "_of_type" property of the NonNull type. - UnderlyingEnumCls = existing_conversion_in_registry.type._of_type if isinstance( # noqa - existing_conversion_in_registry.type, - NonNull) else existing_conversion_in_registry.type + UnderlyingEnumCls = ( + existing_conversion_in_registry.type._of_type + if isinstance(existing_conversion_in_registry.type, NonNull) # noqa + else existing_conversion_in_registry.type + ) # Return the converted field with the correct description and required value. return UnderlyingEnumCls(description=field.help_text, required=is_required(field, required)) @@ -179,12 +179,12 @@ def convert_django_field_with_choices( @singledispatch def convert_django_field_to_input( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): raise Exception("Don't know how to convert the Django field %s (%s)" % (field, field.__class__)) @@ -197,12 +197,12 @@ def convert_django_field_to_input( @convert_django_field_to_input.register(models.GenericIPAddressField) @convert_django_field_to_input.register(models.FilePathField) def convert_field_to_string_extended( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return String(description=field.help_text, required=is_required(field, required)) @@ -210,12 +210,12 @@ def convert_field_to_string_extended( @convert_django_field_to_input.register(models.OneToOneField) @convert_django_field_to_input.register(models.OneToOneRel) def convert_one_to_one_field( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): type_name = field_one_to_one_extras.get("type", "ID") if field_one_to_one_extras else "ID" if type_name == "ID": @@ -242,12 +242,12 @@ def dynamic_type(): @convert_django_field_to_input.register(models.AutoField) @convert_django_field_to_input.register(models.ForeignKey) def convert_field_to_id( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): # Call getattr here, as OneToOneRel does not carry the attribute whatsoeever. id_type = ID( @@ -274,12 +274,12 @@ def dynamic_type(): @convert_django_field_to_input.register(models.UUIDField) def convert_field_to_uuid( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return UUID(description=field.help_text, required=is_required(field, required)) @@ -290,24 +290,24 @@ def convert_field_to_uuid( @convert_django_field_to_input.register(models.BigIntegerField) @convert_django_field_to_input.register(models.IntegerField) def convert_field_to_int( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return Int(description=field.help_text, required=is_required(field, required)) @convert_django_field_to_input.register(models.BooleanField) def convert_field_to_boolean( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): if is_required(field, required): return NonNull(Boolean, description=field.help_text) @@ -318,60 +318,60 @@ def convert_field_to_boolean( @convert_django_field_to_input.register(models.NullBooleanField) def convert_field_to_nullboolean( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return Boolean(description=field.help_text, required=is_required(field, required)) @convert_django_field_to_input.register(models.FloatField) def convert_field_to_float( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return Float(description=field.help_text, required=is_required(field, required)) @convert_django_field_to_input.register(models.DecimalField) def convert_field_to_decimal( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return Decimal(description=field.help_text, required=is_required(field, required)) @convert_django_field_to_input.register(models.DurationField) def convert_field_to_time_delta( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return TimeDelta(description=field.help_text, required=is_required(field, required)) @convert_django_field_to_input.register(models.DateTimeField) def convert_datetime_to_string( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): # We only render DateTimeFields with auto_now[_add] if they are explicitly required or not if required is None and (getattr(field, "auto_now", None) or getattr(field, "auto_now_add", None)): @@ -382,12 +382,12 @@ def convert_datetime_to_string( @convert_django_field_to_input.register(models.DateField) def convert_date_to_string( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): # We only render DateFields with auto_now[_add] if they are explicitly required or not if required is None and (getattr(field, "auto_now", None) or getattr(field, "auto_now_add", None)): @@ -398,12 +398,12 @@ def convert_date_to_string( @convert_django_field_to_input.register(models.TimeField) def convert_time_to_string( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return Time(description=field.help_text, required=is_required(field, required)) @@ -412,12 +412,12 @@ def convert_time_to_string( @convert_django_field_to_input.register(models.ManyToManyRel) @convert_django_field_to_input.register(models.ManyToOneRel) def convert_many_to_many_field( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): # Use getattr on help_text here as ManyToOnRel does not possess this. list_id_type = List( @@ -448,12 +448,12 @@ def dynamic_type(): @convert_django_field_to_input.register(ArrayField) def convert_postgres_array_to_list( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): base_type = convert_django_field_to_input(field.base_field) if not isinstance(base_type, (List, NonNull)): @@ -464,24 +464,24 @@ def convert_postgres_array_to_list( @convert_django_field_to_input.register(HStoreField) @convert_django_field_to_input.register(models.JSONField) def convert_posgres_field_to_string( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return JSONString(description=field.help_text, required=is_required(field, required)) @convert_django_field_to_input.register(RangeField) def convert_postgres_range_to_string( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): inner_type = convert_django_field_to_input(field.base_field) if not isinstance(inner_type, (List, NonNull)): @@ -492,11 +492,11 @@ def convert_postgres_range_to_string( @convert_django_field_to_input.register(FileField) @convert_django_field_to_input.register(ImageField) def convert_file_field_to_upload( - field, - registry=None, - required=None, - field_many_to_many_extras=None, - field_foreign_key_extras=None, - field_one_to_one_extras=None, + field, + registry=None, + required=None, + field_many_to_many_extras=None, + field_foreign_key_extras=None, + field_one_to_one_extras=None, ): return Upload(required=is_required(field, required)) diff --git a/graphene_django_cud/mutations/batch_create.py b/graphene_django_cud/mutations/batch_create.py index 4aa7929..8424081 100644 --- a/graphene_django_cud/mutations/batch_create.py +++ b/graphene_django_cud/mutations/batch_create.py @@ -36,7 +36,7 @@ def __init_subclass_with_meta__( exclude_fields=(), # Deprecated in favor of `exclude` optional_fields=(), required_fields=(), - auto_context_fields=None, + auto_context_fields=None, return_field_name=None, many_to_many_extras=None, foreign_key_extras=None, diff --git a/graphene_django_cud/mutations/batch_delete.py b/graphene_django_cud/mutations/batch_delete.py index 8eed8c6..0294fba 100644 --- a/graphene_django_cud/mutations/batch_delete.py +++ b/graphene_django_cud/mutations/batch_delete.py @@ -109,7 +109,7 @@ def mutate(cls, root, info, ids): cls.check_permissions(root, info, ids) - Model = cls._meta.model + Model = cls._meta.model # noqa ids = cls.resolve_ids(ids) cls.validate(root, info, ids) @@ -126,7 +126,6 @@ def mutate(cls, root, info, ids): all_global_ids = [cls.get_return_id(id) for id in ids] - missed_ids = list(set(all_global_ids).difference(deleted_ids)) deletion_count, _ = qs_to_delete.delete() diff --git a/graphene_django_cud/mutations/batch_patch.py b/graphene_django_cud/mutations/batch_patch.py index f620f32..03894de 100644 --- a/graphene_django_cud/mutations/batch_patch.py +++ b/graphene_django_cud/mutations/batch_patch.py @@ -17,8 +17,8 @@ def __init_subclass_with_meta__( cls, _meta=None, model=None, - optional_fields=None, - required_fields=None, + optional_fields=None, + required_fields=None, type_name=None, **kwargs, ): diff --git a/graphene_django_cud/mutations/batch_update.py b/graphene_django_cud/mutations/batch_update.py index 3a01bef..b3b388c 100644 --- a/graphene_django_cud/mutations/batch_update.py +++ b/graphene_django_cud/mutations/batch_update.py @@ -36,7 +36,7 @@ def __init_subclass_with_meta__( exclude_fields=(), # Deprecated in favor of `exclude` optional_fields=(), required_fields=(), - auto_context_fields=None, + auto_context_fields=None, return_field_name=None, many_to_many_extras=None, foreign_key_extras=None, diff --git a/graphene_django_cud/mutations/core.py b/graphene_django_cud/mutations/core.py index c27444f..acc8dcb 100644 --- a/graphene_django_cud/mutations/core.py +++ b/graphene_django_cud/mutations/core.py @@ -378,7 +378,11 @@ def create_obj( model_field_values[name + "_id"] = obj_id # Foreign keys are added, we are ready to create our object - obj = Model.objects.create(**model_field_values) + obj = Model(**model_field_values) + + cls.before_create_obj(info, input, obj) + + obj.save() # Handle one to one rels if len(one_to_one_rels) > 0: @@ -408,7 +412,10 @@ def create_obj( setattr(obj, name, new_value) - obj.save() + # This is only needed for the case where we are getting an id, and not calling + # the `create_or_update_one_to_one_relation` method. + # Pending a proper code cleanup, this is a temporary fix. + obj.save() # Handle extras fields for name, extras in many_to_many_extras.items(): @@ -757,6 +764,10 @@ def before_save(cls, root, info, *args, **kwargs): def after_mutate(cls, root, info, *args, **kwargs): return None + @classmethod + def before_create_obj(cls, info, input, obj): + return None + @classmethod def resolve_id(cls, id): return disambiguate_id(id) diff --git a/graphene_django_cud/mutations/create.py b/graphene_django_cud/mutations/create.py index 9e2f6c6..f4898bb 100644 --- a/graphene_django_cud/mutations/create.py +++ b/graphene_django_cud/mutations/create.py @@ -36,7 +36,7 @@ def __init_subclass_with_meta__( exclude_fields=(), # Deprecated in favor of `exclude` optional_fields=(), required_fields=(), - auto_context_fields=None, + auto_context_fields=None, return_field_name=None, many_to_many_extras=None, foreign_key_extras=None, diff --git a/graphene_django_cud/mutations/filter_update.py b/graphene_django_cud/mutations/filter_update.py index f5326ac..9fc7897 100644 --- a/graphene_django_cud/mutations/filter_update.py +++ b/graphene_django_cud/mutations/filter_update.py @@ -45,10 +45,10 @@ def __init_subclass_with_meta__( only_fields=(), # Deprecated in favor of `fields` exclude=(), exclude_fields=(), # Deprecated in favor of `exclude` - optional_fields=None, # Explicitly defaulted to None here and handled below. + optional_fields=None, # Explicitly defaulted to None here and handled below. required_fields=(), field_types=None, - auto_context_fields=None, + auto_context_fields=None, **kwargs, ): diff --git a/graphene_django_cud/mutations/patch.py b/graphene_django_cud/mutations/patch.py index a51db20..ccce273 100644 --- a/graphene_django_cud/mutations/patch.py +++ b/graphene_django_cud/mutations/patch.py @@ -17,8 +17,8 @@ def __init_subclass_with_meta__( cls, _meta=None, model=None, - optional_fields=None, - required_fields=None, + optional_fields=None, + required_fields=None, type_name=None, **kwargs, ): diff --git a/graphene_django_cud/tests/factories.py b/graphene_django_cud/tests/factories.py index 2d0d177..9662775 100644 --- a/graphene_django_cud/tests/factories.py +++ b/graphene_django_cud/tests/factories.py @@ -5,7 +5,14 @@ from django.db import models from factory.django import DjangoModelFactory -from graphene_django_cud.tests.models import User, Cat, Dog, Mouse, DogRegistration +from graphene_django_cud.tests.models import ( + User, + Cat, + Dog, + Mouse, + DogRegistration, + Fish, +) class UserFactory(DjangoModelFactory): @@ -104,3 +111,10 @@ class Meta: model = Mouse name = "mouse" + + +class FishFactory(DjangoModelFactory): + class Meta: + model = Fish + + name = "Koi" diff --git a/graphene_django_cud/tests/migrations/0009_auto_20231228_1921.py b/graphene_django_cud/tests/migrations/0009_auto_20231228_1921.py new file mode 100644 index 0000000..ac4ff3b --- /dev/null +++ b/graphene_django_cud/tests/migrations/0009_auto_20231228_1921.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.20 on 2023-12-28 19:21 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0008_auto_20220313_1242'), + ] + + operations = [ + migrations.CreateModel( + name='Fish', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=40)), + ], + ), + migrations.AlterField( + model_name='user', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + ] diff --git a/graphene_django_cud/tests/models.py b/graphene_django_cud/tests/models.py index 5d5ca47..e0476f1 100644 --- a/graphene_django_cud/tests/models.py +++ b/graphene_django_cud/tests/models.py @@ -1,3 +1,4 @@ +import uuid from django.contrib.auth.models import AbstractUser from django.db import models @@ -59,3 +60,8 @@ class CatUserRelation(models.Model): class Meta: unique_together = (("cat", "user"),) + + +class Fish(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + name = models.CharField(max_length=40, blank=False, null=False) diff --git a/graphene_django_cud/tests/schema.py b/graphene_django_cud/tests/schema.py index b3e0430..9ea013c 100644 --- a/graphene_django_cud/tests/schema.py +++ b/graphene_django_cud/tests/schema.py @@ -18,6 +18,7 @@ Mouse, DogRegistration, CatUserRelation, + Fish, ) @@ -57,18 +58,26 @@ class Meta: interfaces = (Node,) +class FishNode(DjangoObjectType): + class Meta: + model = Fish + interfaces = (Node,) + + class Query(graphene.ObjectType): user = Node.Field(UserNode) cat = Node.Field(CatNode) dog = Node.Field(DogNode) mice = Node.Field(MouseNode) cat_user_relation = Node.Field(CatUserRelationNode) + fish = Node.Field(FishNode) all_users = DjangoConnectionField(UserNode) all_cats = DjangoConnectionField(CatNode) all_dogs = DjangoConnectionField(DogNode) all_mice = DjangoConnectionField(MouseNode) all_cat_user_relations = DjangoConnectionField(CatUserRelationNode) + all_fish = DjangoConnectionField(FishNode) class CreateUserMutation(DjangoCreateMutation): @@ -251,6 +260,21 @@ class Meta: filter_fields = ("name", "name__startswith") +class CreateFishMutation(DjangoCreateMutation): + class Meta: + model = Fish + + +class UpdateFishMutation(DjangoUpdateMutation): + class Meta: + model = Fish + + +class DeleteFishMutation(DjangoDeleteMutation): + class Meta: + model = Fish + + class Mutations(graphene.ObjectType): create_user = CreateUserMutation.Field() @@ -282,5 +306,9 @@ class Mutations(graphene.ObjectType): delete_mouse = DeleteMouseMutation.Field() batch_delete_mouse = FilterDeleteMouseMutation.Field() + create_fish = CreateFishMutation.Field() + update_fish = UpdateFishMutation.Field() + delete_fish = DeleteFishMutation.Field(0) + schema = Schema(query=Query, mutation=Mutations) diff --git a/graphene_django_cud/tests/test_create_mutation.py b/graphene_django_cud/tests/test_create_mutation.py index 6b42941..55fbdb1 100644 --- a/graphene_django_cud/tests/test_create_mutation.py +++ b/graphene_django_cud/tests/test_create_mutation.py @@ -1,18 +1,19 @@ import graphene from addict import Dict from django.test import TestCase -from graphene import Schema from graphene import ResolveInfo +from graphene import Schema from graphql_relay import to_global_id -from graphene_django_cud.mutations import DjangoUpdateMutation, DjangoCreateMutation +from graphene_django_cud.mutations import DjangoCreateMutation +from graphene_django_cud.tests.dummy_query import DummyQuery from graphene_django_cud.tests.factories import ( UserFactory, CatFactory, DogFactory, + FishFactory, ) -from graphene_django_cud.tests.dummy_query import DummyQuery -from graphene_django_cud.tests.models import User, Cat, Dog, DogRegistration +from graphene_django_cud.tests.models import User, Cat, Dog, DogRegistration, Fish from graphene_django_cud.util import disambiguate_id @@ -33,7 +34,7 @@ def mock_info(context=None): class TestCreateMutationManyToOneExtras(TestCase): def test_many_to_one_extras__auto_calling_mutation_with_setting_field__does_nothing( - self, + self, ): # This registers the UserNode type from .schema import UserNode # noqa: F401 @@ -229,95 +230,31 @@ class Mutations(graphene.ObjectType): self.assertEqual(user.cats.all().count(), 5) -class TestUpdateWithOneToOneField(TestCase): - def test__one_to_one_relation_exists__updates_specified_fields(self): - +class TestCreateWithOneToOneField(TestCase): + def test__one_to_one__without_extra__assigns_field(self): # This registers the UserNode type - from .schema import UserNode # noqa: F401 + from .schema import UserNode - class UpdateDogMutation(DjangoUpdateMutation): + class CreateDogRegistrationMutation(DjangoCreateMutation): class Meta: - model = Dog - one_to_one_extras = {"registration": {"type": "auto"}} + model = DogRegistration class Mutations(graphene.ObjectType): - update_dog = UpdateDogMutation.Field() + create_dog_registration = CreateDogRegistrationMutation.Field() user = UserFactory.create() dog = DogFactory.create() - DogRegistration.objects.create(dog=dog, registration_number="1234") schema = Schema(query=DummyQuery, mutation=Mutations) - mutation = """ - mutation UpdateDog( - $id: ID!, - $input: UpdateDogInput! - ){ - updateDog(id: $id, input: $input){ - dog{ - id - registration{ - id - registrationNumber - } - } - } - } - """ - - result = schema.execute( - mutation, - variables={ - "id": to_global_id("DogNode", dog.id), - "input": { - "name": dog.name, - "breed": dog.breed, - "tag": dog.tag, - "owner": to_global_id("UserNode", dog.owner.id), - "registration": {"registrationNumber": "12345"}, - }, - }, - context=Dict(user=user), - ) - self.assertIsNone(result.errors) - data = Dict(result.data) - self.assertIsNone(result.errors) - self.assertEqual("12345", data.updateDog.dog.registration.registrationNumber) - - # Load from database - dog.refresh_from_db() - self.assertEqual(dog.registration.registration_number, "12345") - - def test__reverse_one_to_one_exists__updates_specified_fields(self): - # This registers the UserNode type - from .schema import UserNode # noqa: F401 - - class UpdateDogRegistrationMutation(DjangoUpdateMutation): - class Meta: - model = DogRegistration - one_to_one_extras = {"dog": {"type": "auto"}} - class Mutations(graphene.ObjectType): - update_dog_registration = UpdateDogRegistrationMutation.Field() - - user = UserFactory.create() - dog = DogFactory.create(breed="HUSKY") - dog_registration = DogRegistration.objects.create(dog=dog, registration_number="1234") - - schema = Schema(query=DummyQuery, mutation=Mutations) mutation = """ - mutation UpdateDogRegistration( - $id: ID!, - $input: UpdateDogRegistrationInput! + mutation CreateDogRegistration( + $input: CreateDogRegistrationInput! ){ - updateDogRegistration(id: $id, input: $input){ + createDogRegistration(input: $input){ dogRegistration{ id registrationNumber - dog{ - id - breed - } } } } @@ -326,31 +263,27 @@ class Mutations(graphene.ObjectType): result = schema.execute( mutation, variables={ - "id": to_global_id("DogRegistrationNode", dog_registration.id), "input": { - "registrationNumber": dog_registration.registration_number, - "dog": { - "name": dog.name, - "breed": "LABRADOR", - "tag": dog.tag, - "owner": to_global_id("UserNode", dog.owner.id), - }, + "registrationNumber": "12345", + "dog": to_global_id("DogNode", dog.id), }, }, context=Dict(user=user), ) + self.assertIsNone(result.errors) data = Dict(result.data) - self.assertEqual("LABRADOR", data.updateDogRegistration.dogRegistration.dog.breed) - # Load from database - dog_registration.refresh_from_db() - dog.refresh_from_db() + self.assertEqual("12345", data.createDogRegistration.dogRegistration.registrationNumber) - self.assertEqual(dog.breed, "LABRADOR") + dog_registration = DogRegistration.objects.get( + pk=disambiguate_id(data.createDogRegistration.dogRegistration.id)) + self.assertEqual(dog_registration.registration_number, "12345") + dog = getattr(dog_registration, "dog", None) + self.assertIsNotNone(dog) + self.assertEqual(dog.id, dog.id) -class TestCreateWithOneToOneField(TestCase): def test__one_to_one_relation_exists__creates_specified_fields(self): # This registers the UserNode type from .schema import UserNode # noqa: F401 @@ -402,6 +335,7 @@ class Mutations(graphene.ObjectType): # Load from database dog = Dog.objects.get(pk=disambiguate_id(data.createDog.dog.id)) + dog.refresh_from_db() registration = getattr(dog, "registration", None) self.assertIsNotNone(registration) self.assertEqual(registration.registration_number, "12345") @@ -598,7 +532,7 @@ class Mutations(graphene.ObjectType): class TestCreateMutationCustomFields(TestCase): def test_custom_field__separate_from_model_fields__adds_new_field_which_can_be_handled( - self, + self, ): # This registers the UserNode type from .schema import UserNode # noqa: F401 @@ -736,3 +670,43 @@ class Mutations(graphene.ObjectType): cat["catUserRelations"]["edges"][0]["node"]["user"]["id"], to_global_id("UserNode", other_user.id), ) + + +class TestCreateUuidPk(TestCase): + def test__creating_a_record_with_uuid_pk(self): + # This register the FishNode type + from .schema import FishNode # noqa: F401 + + class CreateFishMutation(DjangoCreateMutation): + class Meta: + model = Fish + + class Mutations(graphene.ObjectType): + create_fish = CreateFishMutation.Field() + + user = UserFactory.create() + fish = FishFactory.build() + + schema = Schema(query=DummyQuery, mutation=Mutations) + mutation = """ + mutation CreateFish( + $input: CreateFishInput! + ){ + createFish(input: $input) { + fish { + id + name + } + } + } + """ + + result = schema.execute( + mutation, + variables={"input": {"name": fish.name}}, + context=Dict(user=user), + ) + self.assertIsNone(result.errors) + + data = Dict(result.data) + self.assertEqual(data.createFish.fish.name, fish.name) diff --git a/graphene_django_cud/tests/test_delete_mutation.py b/graphene_django_cud/tests/test_delete_mutation.py index ad5d04f..213d4ac 100644 --- a/graphene_django_cud/tests/test_delete_mutation.py +++ b/graphene_django_cud/tests/test_delete_mutation.py @@ -9,9 +9,10 @@ UserWithPermissionsFactory, CatFactory, UserFactory, + FishFactory, ) from graphene_django_cud.tests.dummy_query import DummyQuery -from graphene_django_cud.tests.models import Cat +from graphene_django_cud.tests.models import Cat, Fish from graphene_django_cud.util import disambiguate_id @@ -128,3 +129,39 @@ class Mutations(graphene.ObjectType): ) self.assertIsNotNone(result.errors) self.assertIn("Not permitted", str(result.errors)) + + def test__deleting_a_record_with_uuid_pk__with_pk_as_str(self): + # This register the FishNode type + from .schema import FishNode # noqa: F401 + + class DeleteFishMutation(DjangoDeleteMutation): + class Meta: + model = Fish + + class Mutations(graphene.ObjectType): + delete_fish = DeleteFishMutation.Field() + + user = UserFactory.create() + fish = FishFactory.create() + + schema = Schema(query=DummyQuery, mutation=Mutations) + mutation = """ + mutation DeleteFish( + $id: ID! + ){ + deleteFish(id: $id) { + found + deletedId + } + } + """ + + # Excluded use of `to_global_id` and cast UUID to str to match some + # real-world mutation scenarios. + result = schema.execute( + mutation, + variables={"id": str(fish.id)}, + context=Dict(user=user), + ) + self.assertIsNone(result.errors) + self.assertEqual(Fish.objects.count(), 0) diff --git a/graphene_django_cud/tests/test_update_mutation.py b/graphene_django_cud/tests/test_update_mutation.py index 9c599d7..39bd770 100644 --- a/graphene_django_cud/tests/test_update_mutation.py +++ b/graphene_django_cud/tests/test_update_mutation.py @@ -11,8 +11,9 @@ UserWithPermissionsFactory, DogFactory, MouseFactory, + FishFactory, ) -from graphene_django_cud.tests.models import User, Cat, Dog +from graphene_django_cud.tests.models import User, Cat, Dog, Fish, DogRegistration from graphene_django_cud.util import disambiguate_id from graphene_django_cud.tests.dummy_query import DummyQuery @@ -484,6 +485,48 @@ class Mutations(graphene.ObjectType): ) self.assertIsNone(result.errors) + def test__updating_a_record_with_uuid_pk__with_pk_as_str(self): + # This register the FishNode type + from .schema import FishNode # noqa: F401 + + class UpdateFishMutation(DjangoUpdateMutation): + class Meta: + model = Fish + + class Mutations(graphene.ObjectType): + update_fish = UpdateFishMutation.Field() + + user = UserFactory.create() + fish = FishFactory.create() + + schema = Schema(query=DummyQuery, mutation=Mutations) + mutation = """ + mutation UpdateFish( + $id: ID! + $input: UpdateFishInput! + ){ + updateFish(id: $id, input: $input) { + fish { + id + name + } + } + } + """ + + # Excluded use of `to_global_id` and cast UUID to str to match some + # real-world mutation scenarios. + result = schema.execute( + mutation, + variables={"id": str(fish.id), "input": {"name": "Fugu"}}, + context=Dict(user=user), + ) + self.assertIsNone(result.errors) + + data = Dict(result.data) + self.assertNotEqual(data.updateFish.fish.name, fish.name) + self.assertEqual(data.updateFish.fish.name, "Fugu") + class TestUpdateMutationManyToManyOnReverseField(TestCase): def test_default_setup__adding_resource_by_id__adds_resource(self): @@ -1573,3 +1616,123 @@ class Mutations(graphene.ObjectType): dog.refresh_from_db() self.assertEqual(1, dog.bark_count) + + +class TestUpdateWithOneToOneField(TestCase): + def test__one_to_one_relation_exists__updates_specified_fields(self): + # This registers the UserNode type + from .schema import UserNode # noqa: F401 + + class UpdateDogMutation(DjangoUpdateMutation): + class Meta: + model = Dog + one_to_one_extras = {"registration": {"type": "auto"}} + + class Mutations(graphene.ObjectType): + update_dog = UpdateDogMutation.Field() + + user = UserFactory.create() + dog = DogFactory.create() + DogRegistration.objects.create(dog=dog, registration_number="1234") + + schema = Schema(query=DummyQuery, mutation=Mutations) + mutation = """ + mutation UpdateDog( + $id: ID!, + $input: UpdateDogInput! + ){ + updateDog(id: $id, input: $input){ + dog{ + id + registration{ + id + registrationNumber + } + } + } + } + """ + + result = schema.execute( + mutation, + variables={ + "id": to_global_id("DogNode", dog.id), + "input": { + "name": dog.name, + "breed": dog.breed, + "tag": dog.tag, + "owner": to_global_id("UserNode", dog.owner.id), + "registration": {"registrationNumber": "12345"}, + }, + }, + context=Dict(user=user), + ) + self.assertIsNone(result.errors) + data = Dict(result.data) + self.assertIsNone(result.errors) + self.assertEqual("12345", data.updateDog.dog.registration.registrationNumber) + + # Load from database + dog.refresh_from_db() + self.assertEqual(dog.registration.registration_number, "12345") + + def test__reverse_one_to_one_exists__updates_specified_fields(self): + # This registers the UserNode type + from .schema import UserNode # noqa: F401 + + class UpdateDogRegistrationMutation(DjangoUpdateMutation): + class Meta: + model = DogRegistration + one_to_one_extras = {"dog": {"type": "auto"}} + + class Mutations(graphene.ObjectType): + update_dog_registration = UpdateDogRegistrationMutation.Field() + + user = UserFactory.create() + dog = DogFactory.create(breed="HUSKY") + dog_registration = DogRegistration.objects.create(dog=dog, registration_number="1234") + + schema = Schema(query=DummyQuery, mutation=Mutations) + mutation = """ + mutation UpdateDogRegistration( + $id: ID!, + $input: UpdateDogRegistrationInput! + ){ + updateDogRegistration(id: $id, input: $input){ + dogRegistration{ + id + registrationNumber + dog{ + id + breed + } + } + } + } + """ + + result = schema.execute( + mutation, + variables={ + "id": to_global_id("DogRegistrationNode", dog_registration.id), + "input": { + "registrationNumber": dog_registration.registration_number, + "dog": { + "name": dog.name, + "breed": "LABRADOR", + "tag": dog.tag, + "owner": to_global_id("UserNode", dog.owner.id), + }, + }, + }, + context=Dict(user=user), + ) + self.assertIsNone(result.errors) + data = Dict(result.data) + self.assertEqual("LABRADOR", data.updateDogRegistration.dogRegistration.dog.breed) + + # Load from database + dog_registration.refresh_from_db() + dog.refresh_from_db() + + self.assertEqual(dog.breed, "LABRADOR") diff --git a/graphene_django_cud/util/model.py b/graphene_django_cud/util/model.py index d149c1a..7ce614f 100644 --- a/graphene_django_cud/util/model.py +++ b/graphene_django_cud/util/model.py @@ -42,7 +42,10 @@ def disambiguate_id(ambiguous_id: Union[int, float, str, uuid.UUID]): if isinstance(ambiguous_id, str): try: - return from_global_id(ambiguous_id)[1] + _id = from_global_id(ambiguous_id)[1] + + if _id: + return _id except (ValueError, TypeError, binascii.Error): pass diff --git a/poetry.lock b/poetry.lock index dec2703..0bbb30d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "addict" @@ -130,13 +130,13 @@ toml = ["tomli"] [[package]] name = "django" -version = "3.2.20" +version = "3.2.25" description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.6" files = [ - {file = "Django-3.2.20-py3-none-any.whl", hash = "sha256:a477ab326ae7d8807dc25c186b951ab8c7648a3a23f9497763c37307a2b5ef87"}, - {file = "Django-3.2.20.tar.gz", hash = "sha256:dec2a116787b8e14962014bf78e120bba454135108e1af9e9b91ade7b2964c40"}, + {file = "Django-3.2.25-py3-none-any.whl", hash = "sha256:a52ea7fcf280b16f7b739cec38fa6d3f8953a5456986944c3ca97e79882b4e38"}, + {file = "Django-3.2.25.tar.gz", hash = "sha256:7ca38a78654aee72378594d63e51636c04b8e28574f5505dff630895b5472777"}, ] [package.dependencies]