Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reverse relations input in mutations update #14

Merged
merged 8 commits into from
Jul 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition is used 3 times, so a utility function could be used.

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:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justinask7 Adding 'unless the field is in only_fields' to the comment certainly makes the reading easier.

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