Skip to content

Commit

Permalink
Merge pull request #14 from CornerCaseTechnologies/master
Browse files Browse the repository at this point in the history
Reverse relations input in mutations update
  • Loading branch information
bellini666 authored Jul 29, 2020
2 parents 7c05908 + 124fa02 commit 7135351
Show file tree
Hide file tree
Showing 8 changed files with 313 additions and 4 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ wheels/
*.egg
MANIFEST

# ide files
.idea/*

# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,15 @@ mutation {

Any validation errors will be presented in the `errors` return value.

To turn off auto related relations addition to the mutation input - set global `MUTATIONS_INCLUDE_REVERSE_RELATIONS` parameter to `False` in your `settings.py`:
```
GRAPHENE_DJANGO_PLUS = {
'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False
}
```

Note: in case reverse relation does not have `related_name` attribute set - mutation input will be generated as Django itself is generating by appending `_set` to the lower cased model name - `modelname_set`

## License

This project is licensed under MIT licence (see `LICENSE` for more info)
Expand Down
14 changes: 10 additions & 4 deletions graphene_django_plus/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
check_perms,
check_authenticated,
)
from .settings import graphene_django_plus_settings
from .utils import (
get_node,
get_nodes,
Expand Down Expand Up @@ -94,7 +95,7 @@ def _get_fields(model, only_fields, exclude_fields, required_fields):
# can be set to null, otherwise updates won't work.
fields.extend(
[
(field.related_name, field)
(field.related_name or field.name + "_set", field)
for field in sorted(
list(model._meta.related_objects),
key=lambda field: field.name,
Expand Down Expand Up @@ -133,6 +134,11 @@ def _get_fields(model, only_fields, exclude_fields, required_fields):
description=field.help_text,
)
elif isinstance(field, (ManyToOneRel, ManyToManyRel)):
reverse_rel_include = graphene_django_plus_settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS
# Checking whether it was globally configured to not include reverse relations
if isinstance(field, ManyToOneRel) and not reverse_rel_include and not only_fields:
continue

ret[name] = graphene.List(
graphene.ID,
description='Set list of {0}'.format(
Expand Down Expand Up @@ -573,17 +579,17 @@ def perform_mutation(cls, root, info, **data):
cls.clean_instance(instance, cleaned_input)
cls.save(info, instance, cleaned_input)

# save m2m data
# save m2m and related object's data
for f in itertools.chain(
instance._meta.many_to_many,
instance._meta.related_objects,
instance._meta.private_fields,
):
if isinstance(f, (ManyToOneRel, ManyToManyRel)):
# Handle reverse side relationships.
d = cleaned_input.get(f.related_name, None)
d = cleaned_input.get(f.related_name or f.name + "_set", None)
if d is not None:
target_field = getattr(instance, f.related_name)
target_field = getattr(instance, f.related_name or f.name + "_set")
target_field.set(d)
elif hasattr(f, 'save_form_data'):
d = cleaned_input.get(f.name, None)
Expand Down
121 changes: 121 additions & 0 deletions graphene_django_plus/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
Settings for graphene-django-plus are all namespaced in the GRAPHENE_DJANGO_PLUS setting.
For example your project's `settings.py` file might look like this:
GRAPHENE_DJANGO_PLUS = {
'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False
}
This module provides the `graphene_django_plus_settings` object, that is used to access
graphene-django-plus settings, checking for user settings first, then falling
back to the defaults.
"""
from django.conf import settings
from django.test.signals import setting_changed

import importlib


# Copied shamelessly from Django REST Framework and graphene-django

DEFAULTS = {
"MUTATIONS_INCLUDE_REVERSE_RELATIONS": True,
}

# List of settings that may be in string import notation.
IMPORT_STRINGS = []


def perform_import(val, setting_name):
"""
If the given setting is a string import notation,
then perform the necessary import or imports.
"""
if val is None:
return None
elif isinstance(val, str):
return import_from_string(val, setting_name)
elif isinstance(val, (list, tuple)):
return [import_from_string(item, setting_name) for item in val]
return val


def import_from_string(val, setting_name):
"""
Attempt to import a class from a string representation.
"""
try:
# Nod to tastypie's use of importlib.
parts = val.split(".")
module_path, class_name = ".".join(parts[:-1]), parts[-1]
module = importlib.import_module(module_path)
return getattr(module, class_name)
except (ImportError, AttributeError) as e:
msg = "Could not import '%s' for graphene-django-plus setting '%s'. %s: %s." % (
val,
setting_name,
e.__class__.__name__,
e,
)
raise ImportError(msg)


class GrapheneDjangoPlusSettings(object):
"""
A settings object, that allows API settings to be accessed as properties.
For example:
from graphene_django_plus.settings import settings
print(settings.MUTATIONS_INCLUDE_REVERSE_RELATIONS)
Any setting with string import paths will be automatically resolved
and return the class, rather than the string literal.
"""

def __init__(self, user_settings=None, defaults=None, import_strings=None):
if user_settings:
self._user_settings = user_settings
self.defaults = defaults or DEFAULTS
self.import_strings = import_strings or IMPORT_STRINGS
self._cached_attrs = set()

@property
def user_settings(self):
if not hasattr(self, "_user_settings"):
self._user_settings = getattr(settings, "GRAPHENE_DJANGO_PLUS", {})
return self._user_settings

def reload(self):
for attr in self._cached_attrs:
delattr(self, attr)
self._cached_attrs.clear()
if hasattr(self, '_user_settings'):
delattr(self, '_user_settings')

def __getattr__(self, attr):
if attr not in self.defaults:
raise AttributeError("Invalid graphene-django-plus setting: '%s'" % attr)

try:
# Check if present in user settings
val = self.user_settings[attr]
except KeyError:
# Fall back to defaults
val = self.defaults[attr]

# Coerce import strings into classes
if attr in self.import_strings:
val = perform_import(val, attr)

# Cache the result
self._cached_attrs.add(attr)
setattr(self, attr, val)
return val


graphene_django_plus_settings = GrapheneDjangoPlusSettings(None, DEFAULTS, IMPORT_STRINGS)


def reload_graphene_django_plus_settings(*args, **kwargs):
setting = kwargs["setting"]
if setting == "GRAPHENE_DJANGO_PLUS":
graphene_django_plus_settings.reload()


setting_changed.connect(reload_graphene_django_plus_settings)
13 changes: 13 additions & 0 deletions tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,16 @@ class Meta:
default=None,
on_delete=models.SET_NULL,
)


class MilestoneComment(models.Model):

text = models.CharField(
max_length=255,
)
milestone = models.ForeignKey(
Milestone,
null=True,
blank=True,
on_delete=models.SET_NULL,
)
9 changes: 9 additions & 0 deletions tests/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Project,
Milestone,
Issue,
MilestoneComment,
)

# Types
Expand Down Expand Up @@ -48,6 +49,14 @@ class Meta:
filter_fields = {}


class MilestoneCommentType(ModelType):
class Meta:
model = MilestoneComment
connection_class = CountableConnection
interfaces = [relay.Node]
filter_fields = {}


# Queries


Expand Down
128 changes: 128 additions & 0 deletions tests/test_mutations.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import base64
import json

from django.test.utils import override_settings
import graphene
from graphql_relay import to_global_id
from graphene_django_plus.mutations import ModelCreateMutation

from .base import BaseTestCase
from .models import (
Project,
Milestone,
Issue,
MilestoneComment,
)
from .schema import MilestoneCommentType, IssueType, ProjectType


class TestTypes(BaseTestCase):
Expand Down Expand Up @@ -397,3 +404,124 @@ def test_remove_all_milestone_issues(self):
}
}
)


class TestMutationRelatedObjectsWithOverrideSettings(BaseTestCase):
"""Tests for creating and updating reverse side of FK and M2M relationships."""

@override_settings(GRAPHENE_DJANGO_PLUS={'MUTATIONS_INCLUDE_REVERSE_RELATIONS': False})
def test_create_milestone_issues_turned_off_related_setting(self):
"""Test that a milestone can be created with a list of issues."""

milestone = 'release_1A'
self.assertFalse(Milestone.objects.filter(name=milestone).exists())

project_id = to_global_id(ProjectType.__name__, self.project.id)
issue_id = to_global_id(IssueType.__name__, self.issues[0].id)

# Creating schema inside test run so that settings would be already modified
# since schema is generated on server start, not on execution
class MilestoneCreateMutation(ModelCreateMutation):
class Meta:
model = Milestone

class Mutation(graphene.ObjectType):
milestone_create = MilestoneCreateMutation.Field()

schema = graphene.Schema(
mutation=Mutation,
)
query = """
mutation milestoneCreate {
milestoneCreate (input: {
name: "%s",
project: "%s",
issues: ["%s"]
}) {
milestone {
name
issues {
edges {
node {
name
}
}
}
}
}
}
""" % (milestone, project_id, issue_id)
result = schema.execute(query)
self.assertFalse(Milestone.objects.filter(name=milestone).exists())
self.assertTrue('Unknown field.' in result.errors[0].message)

def test_create_milestone_issues_with_comments_without_related_name(self):
comment = MilestoneComment.objects.create(
text="Milestone Comment",
milestone=self.milestone_1,
)

milestone = 'release_1A'
self.assertFalse(Milestone.objects.filter(name=milestone).exists())

project_id = to_global_id(ProjectType.__name__, self.project.id)
issue_id = to_global_id(IssueType.__name__, self.issues[0].id)
comment_id = to_global_id(MilestoneCommentType.__name__, comment.id)

r = self.query(
"""
mutation milestoneCreate {
milestoneCreate (input: {
name: "%s",
project: "%s",
issues: ["%s"],
milestonecommentSet: ["%s"],
}) {
milestone {
name
issues {
edges {
node {
name
}
}
}
milestonecommentSet {
edges {
node {
text
}
}
}
}
}
}
""" % (milestone, project_id, issue_id, comment_id),
op_name='milestoneCreate',
)
self.assertTrue(Milestone.objects.filter(name=milestone).exists())
self.assertEqual(
json.loads(r.content),
{'data': {
'milestoneCreate': {
'milestone': {
'name': 'release_1A',
'issues': {
'edges': [{
'node': {
'name': 'Issue 1'
},
}]
},
'milestonecommentSet': {
'edges': [{
'node': {
'text': 'Milestone Comment'
},
}]
},
}
}
}
}
)
Loading

0 comments on commit 7135351

Please sign in to comment.