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

Handle database transactions #1039

Merged
merged 6 commits into from
Dec 23, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
118 changes: 118 additions & 0 deletions docs/mutations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,121 @@ This argument is also sent back to the client with the mutation result
(you do not have to do anything). For services that manage
a pool of many GraphQL requests in bulk, the ``clientIDMutation``
allows you to match up a specific mutation with the response.



Django Database Transactions
----------------------------

Django gives you a few ways to control how database transactions are managed.

Tying transactions to HTTP requests
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A common way to handle transactions in Django is to wrap each request in a transaction.
Set ``ATOMIC_REQUESTS`` settings to ``True`` in the configuration of each database for
which you want to enable this behavior.

It works like this. Before calling ``GraphQLView`` Django starts a transaction. If the
response is produced without problems, Django commits the transaction. If the view, a
``DjangoFormMutation`` or a ``DjangoModelFormMutation`` produces an exception, Django
rolls back the transaction.

.. warning::

While the simplicity of this transaction model is appealing, it also makes it
inefficient when traffic increases. Opening a transaction for every request has some
overhead. The impact on performance depends on the query patterns of your application
and on how well your database handles locking.

Check the next section for a better solution.

Tying transactions to mutations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A mutation can contain multiple fields, just like a query. There's one important
distinction between queries and mutations, other than the name:

..

`While query fields are executed in parallel, mutation fields run in series, one
after the other.`

This means that if we send two ``incrementCredits`` mutations in one request, the first
is guaranteed to finish before the second begins, ensuring that we don't end up with a
race condition with ourselves.

On the other hand, if the first ``incrementCredits`` runs successfully but the second
one does not, the operation cannot be retried as it is. That's why is a good idea to
run all mutation fields in a transaction, to guarantee all occur or nothing occurs.

To enable this behavior for all databases set the graphene ``ATOMIC_MUTATIONS`` settings
to ``True`` in your settings file:

.. code:: python

GRAPHENE = {
# ...
"ATOMIC_MUTATIONS": True,
}

On the contrary, if you want to enable this behavior for a specific database, set
``ATOMIC_MUTATIONS`` to ``True`` in your database settings:

.. code:: python

DATABASES = {
"default": {
# ...
"ATOMIC_MUTATIONS": True,
},
# ...
}

Now, given the following example mutation:

.. code::

mutation IncreaseCreditsTwice {

increaseCredits1: increaseCredits(input: { amount: 10 }) {
balance
errors {
field
messages
}
}

increaseCredits2: increaseCredits(input: { amount: -1 }) {
balance
errors {
field
messages
}
}

}

The server is going to return something like:

.. code:: json

{
"data": {
"increaseCredits1": {
"balance": 10.0,
"errors": []
},
"increaseCredits2": {
"balance": null,
"errors": [
{
"field": "amount",
"message": "Amount should be a positive number"
}
]
},
}
}

But the balance will remain the same.
Copy link

Choose a reason for hiding this comment

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

I had a hard time to figure out But the balance will remain the same.

It'd better to be

Assuming prev balance is 0, after the example mutation the balance is 10.

for a much clearer description

1 change: 1 addition & 0 deletions graphene_django/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MUTATION_ERRORS_FLAG = "graphene_mutation_has_errors"
13 changes: 13 additions & 0 deletions graphene_django/forms/mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@
# InputObjectType,
# )
from graphene.types.utils import yank_fields_from_attrs
from graphene_django.constants import MUTATION_ERRORS_FLAG
from graphene_django.registry import get_global_registry


from django.core.exceptions import ValidationError
from django.db import connection

from ..types import ErrorType
from .converter import convert_form_field

Expand Down Expand Up @@ -46,6 +51,7 @@ def mutate_and_get_payload(cls, root, info, **input):
return cls.perform_mutate(form, info)
else:
errors = ErrorType.from_errors(form.errors)
_set_errors_flag_to_context(info)

return cls(errors=errors, **form.data)

Expand Down Expand Up @@ -170,6 +176,7 @@ def mutate_and_get_payload(cls, root, info, **input):
return cls.perform_mutate(form, info)
else:
errors = ErrorType.from_errors(form.errors)
_set_errors_flag_to_context(info)

return cls(errors=errors)

Expand All @@ -178,3 +185,9 @@ def perform_mutate(cls, form, info):
obj = form.save()
kwargs = {cls._meta.return_field_name: obj}
return cls(errors=[], **kwargs)


def _set_errors_flag_to_context(info):
# This is not ideal but necessary to keep the response errors empty
if info and info.context:
setattr(info.context, MUTATION_ERRORS_FLAG, True)