Skip to content

Commit 4ca6c90

Browse files
committed
INTPYTHON-355 Add transaction support
1 parent 5bf7f0f commit 4ca6c90

File tree

14 files changed

+230
-33
lines changed

14 files changed

+230
-33
lines changed

.github/workflows/test-python-atlas.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,4 @@ jobs:
5353
working-directory: .
5454
run: bash .github/workflows/start_local_atlas.sh mongodb/mongodb-atlas-local:7
5555
- name: Run tests
56-
run: python3 django_repo/tests/runtests_.py
56+
run: python3 django_repo/tests/runtests.py --settings mongodb_settings -v 2

django_mongodb_backend/base.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import os
33

44
from django.core.exceptions import ImproperlyConfigured
5+
from django.db import DEFAULT_DB_ALIAS
56
from django.db.backends.base.base import BaseDatabaseWrapper
7+
from django.db.backends.utils import debug_transaction
68
from django.utils.asyncio import async_unsafe
79
from django.utils.functional import cached_property
810
from pymongo.collection import Collection
@@ -32,6 +34,17 @@ def __exit__(self, exception_type, exception_value, exception_traceback):
3234
pass
3335

3436

37+
def requires_transaction_support(func):
38+
"""Make a method a no-op if transactions aren't supported."""
39+
40+
def wrapper(self, *args, **kwargs):
41+
if not self.features.supports_transactions:
42+
return
43+
func(self, *args, **kwargs)
44+
45+
return wrapper
46+
47+
3548
class DatabaseWrapper(BaseDatabaseWrapper):
3649
data_types = {
3750
"AutoField": "int",
@@ -140,6 +153,10 @@ def _isnull_operator(a, b):
140153
ops_class = DatabaseOperations
141154
validation_class = DatabaseValidation
142155

156+
def __init__(self, settings_dict, alias=DEFAULT_DB_ALIAS):
157+
super().__init__(settings_dict, alias=alias)
158+
self.session = None
159+
143160
def get_collection(self, name, **kwargs):
144161
collection = Collection(self.database, name, **kwargs)
145162
if self.queries_logged:
@@ -189,14 +206,48 @@ def _driver_info(self):
189206
return DriverInfo("django-mongodb-backend", django_mongodb_backend_version)
190207
return None
191208

209+
@requires_transaction_support
192210
def _commit(self):
193-
pass
211+
if self.session:
212+
with debug_transaction(self, "session.commit_transaction()"):
213+
self.session.commit_transaction()
214+
self._end_session()
194215

216+
@requires_transaction_support
195217
def _rollback(self):
196-
pass
218+
if self.session:
219+
with debug_transaction(self, "session.abort_transaction()"):
220+
self.session.abort_transaction()
221+
self._end_session()
222+
223+
def _start_transaction(self):
224+
# Private API, specific to this backend.
225+
if self.session is None:
226+
self.session = self.connection.start_session()
227+
with debug_transaction(self, "session.start_transaction()"):
228+
self.session.start_transaction()
197229

198-
def set_autocommit(self, autocommit, force_begin_transaction_with_broken_autocommit=False):
199-
self.autocommit = autocommit
230+
def _end_session(self):
231+
# Private API, specific to this backend.
232+
self.session.end_session()
233+
self.session = None
234+
235+
@requires_transaction_support
236+
def _start_transaction_under_autocommit(self):
237+
# Implementing this hook (intended only for SQLite), allows
238+
# BaseDatabaseWrapper.set_autocommit() to use it to start a transaction
239+
# rather than set_autocommit(), bypassing set_autocommit()'s call to
240+
# debug_transaction(self, "BEGIN") which isn't semantic for a no-SQL
241+
# backend.
242+
self._start_transaction()
243+
244+
@requires_transaction_support
245+
def _set_autocommit(self, autocommit, force_begin_transaction_with_broken_autocommit=False):
246+
# Besides @transaction.atomic() (which uses
247+
# _start_transaction_under_autocommit(), disabling autocommit is
248+
# another way to start a transaction.
249+
if not autocommit:
250+
self._start_transaction()
200251

201252
def _close(self):
202253
# Normally called by close(), this method is also called by some tests.
@@ -210,6 +261,10 @@ def close(self):
210261

211262
def close_pool(self):
212263
"""Close the MongoClient."""
264+
# Clear commit hooks and session.
265+
self.run_on_commit = []
266+
if self.session:
267+
self._end_session()
213268
connection = self.connection
214269
if connection is None:
215270
return
@@ -225,6 +280,10 @@ def close_pool(self):
225280
def cursor(self):
226281
return Cursor()
227282

283+
@requires_transaction_support
284+
def validate_no_broken_transaction(self):
285+
super().validate_no_broken_transaction()
286+
228287
def get_database_version(self):
229288
"""Return a tuple of the database's version."""
230289
return tuple(self.connection.server_info()["versionArray"])

django_mongodb_backend/compiler.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,10 @@ def execute_sql(self, returning_fields=None):
685685
@wrap_database_errors
686686
def insert(self, docs, returning_fields=None):
687687
"""Store a list of documents using field columns as element names."""
688-
inserted_ids = self.collection.insert_many(docs).inserted_ids
688+
self.connection.validate_no_broken_transaction()
689+
inserted_ids = self.collection.insert_many(
690+
docs, session=self.connection.session
691+
).inserted_ids
689692
return [(x,) for x in inserted_ids] if returning_fields else []
690693

691694
@cached_property
@@ -768,7 +771,10 @@ def execute_sql(self, result_type):
768771

769772
@wrap_database_errors
770773
def update(self, criteria, pipeline):
771-
return self.collection.update_many(criteria, pipeline).matched_count
774+
self.connection.validate_no_broken_transaction()
775+
return self.collection.update_many(
776+
criteria, pipeline, session=self.connection.session
777+
).matched_count
772778

773779
def check_query(self):
774780
super().check_query()

django_mongodb_backend/features.py

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
3636
supports_temporal_subtraction = True
3737
# MongoDB stores datetimes in UTC.
3838
supports_timezones = False
39-
# Not implemented: https://github.com/mongodb/django-mongodb-backend/issues/7
40-
supports_transactions = False
4139
supports_unspecified_pk = True
4240
uses_savepoints = False
4341

@@ -50,8 +48,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
5048
"aggregation.tests.AggregateTestCase.test_order_by_aggregate_transform",
5149
# 'NulledTransform' object has no attribute 'as_mql'.
5250
"lookup.tests.LookupTests.test_exact_none_transform",
53-
# "Save with update_fields did not affect any rows."
54-
"basic.tests.SelectOnSaveTests.test_select_on_save_lying_update",
5551
# BaseExpression.convert_value() crashes with Decimal128.
5652
"aggregation.tests.AggregateTestCase.test_combine_different_types",
5753
"annotations.tests.NonAggregateAnnotationTestCase.test_combined_f_expression_annotation_with_aggregation",
@@ -96,13 +92,47 @@ class DatabaseFeatures(BaseDatabaseFeatures):
9692
"expressions.tests.ExpressionOperatorTests.test_lefthand_bitwise_xor_right_null",
9793
"expressions.tests.ExpressionOperatorTests.test_lefthand_transformed_field_bitwise_or",
9894
}
95+
_django_test_expected_failures_no_transactions = {
96+
# "Save with update_fields did not affect any rows." instead of
97+
# "An error occurred in the current transaction. You can't execute
98+
# queries until the end of the 'atomic' block."
99+
"basic.tests.SelectOnSaveTests.test_select_on_save_lying_update",
100+
}
101+
_django_test_expected_failures_transactions = {
102+
# When update_or_create() fails with IntegrityError, the transaction
103+
# is no longer usable.
104+
"get_or_create.tests.UpdateOrCreateTests.test_manual_primary_key_test",
105+
"get_or_create.tests.UpdateOrCreateTestsWithManualPKs.test_create_with_duplicate_primary_key",
106+
# Tests that require savepoints
107+
"admin_views.tests.AdminViewBasicTest.test_disallowed_to_field",
108+
"admin_views.tests.AdminViewPermissionsTest.test_add_view",
109+
"admin_views.tests.AdminViewPermissionsTest.test_change_view",
110+
"admin_views.tests.AdminViewPermissionsTest.test_change_view_save_as_new",
111+
"admin_views.tests.AdminViewPermissionsTest.test_delete_view",
112+
"auth_tests.test_views.ChangelistTests.test_view_user_password_is_readonly",
113+
"fixtures.tests.FixtureLoadingTests.test_loaddata_app_option",
114+
"fixtures.tests.FixtureLoadingTests.test_unmatched_identifier_loading",
115+
"fixtures_model_package.tests.FixtureTestCase.test_loaddata",
116+
"get_or_create.tests.GetOrCreateTests.test_get_or_create_invalid_params",
117+
"get_or_create.tests.UpdateOrCreateTests.test_integrity",
118+
"many_to_many.tests.ManyToManyTests.test_add",
119+
"many_to_one.tests.ManyToOneTests.test_fk_assignment_and_related_object_cache",
120+
"model_fields.test_booleanfield.BooleanFieldTests.test_null_default",
121+
"model_fields.test_floatfield.TestFloatField.test_float_validates_object",
122+
"multiple_database.tests.QueryTestCase.test_generic_key_cross_database_protection",
123+
"multiple_database.tests.QueryTestCase.test_m2m_cross_database_protection",
124+
}
99125

100126
@cached_property
101127
def django_test_expected_failures(self):
102128
expected_failures = super().django_test_expected_failures
103129
expected_failures.update(self._django_test_expected_failures)
104130
if not self.is_mongodb_6_3:
105131
expected_failures.update(self._django_test_expected_failures_bitwise)
132+
if self.supports_transactions:
133+
expected_failures.update(self._django_test_expected_failures_transactions)
134+
else:
135+
expected_failures.update(self._django_test_expected_failures_no_transactions)
106136
return expected_failures
107137

108138
django_test_skips = {
@@ -485,16 +515,6 @@ def django_test_expected_failures(self):
485515
"Connection health checks not implemented.": {
486516
"backends.base.test_base.ConnectionHealthChecksTests",
487517
},
488-
"transaction.atomic() is not supported.": {
489-
"backends.base.test_base.DatabaseWrapperLoggingTests",
490-
"migrations.test_executor.ExecutorTests.test_atomic_operation_in_non_atomic_migration",
491-
"migrations.test_operations.OperationTests.test_run_python_atomic",
492-
},
493-
"transaction.rollback() is not supported.": {
494-
"transactions.tests.AtomicMiscTests.test_mark_for_rollback_on_error_in_autocommit",
495-
"transactions.tests.AtomicMiscTests.test_mark_for_rollback_on_error_in_transaction",
496-
"transactions.tests.NonAutocommitTests.test_orm_query_after_error_and_rollback",
497-
},
498518
"migrate --fake-initial is not supported.": {
499519
"migrations.test_commands.MigrateTests.test_migrate_fake_initial",
500520
"migrations.test_commands.MigrateTests.test_migrate_fake_split_initial",
@@ -587,3 +607,20 @@ def supports_atlas_search(self):
587607
return False
588608
else:
589609
return True
610+
611+
@cached_property
612+
def supports_select_union(self):
613+
# Stage not supported inside of a multi-document transaction: $unionWith
614+
return not self.supports_transactions
615+
616+
@cached_property
617+
def supports_transactions(self):
618+
"""
619+
Transactions are enabled if MongoDB is configured as a replica set or a
620+
sharded cluster.
621+
"""
622+
self.connection.ensure_connection()
623+
client = self.connection.connection.admin
624+
hello = client.command("hello")
625+
# a replica set or a sharded cluster
626+
return "setName" in hello or hello.get("msg") == "isdbgrid"

django_mongodb_backend/query.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,23 @@ def __repr__(self):
6161
@wrap_database_errors
6262
def delete(self):
6363
"""Execute a delete query."""
64+
self.compiler.connection.validate_no_broken_transaction()
6465
if self.compiler.subqueries:
6566
raise NotSupportedError("Cannot use QuerySet.delete() when a subquery is required.")
66-
return self.compiler.collection.delete_many(self.match_mql).deleted_count
67+
return self.compiler.collection.delete_many(
68+
self.match_mql, session=self.compiler.connection.session
69+
).deleted_count
6770

6871
@wrap_database_errors
6972
def get_cursor(self):
7073
"""
7174
Return a pymongo CommandCursor that can be iterated on to give the
7275
results of the query.
7376
"""
74-
return self.compiler.collection.aggregate(self.get_pipeline())
77+
self.compiler.connection.validate_no_broken_transaction()
78+
return self.compiler.collection.aggregate(
79+
self.get_pipeline(), session=self.compiler.connection.session
80+
)
7581

7682
def get_pipeline(self):
7783
pipeline = []

django_mongodb_backend/queryset.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def __init__(self, pipeline, using, model):
3535
def _execute_query(self):
3636
connection = connections[self.using]
3737
collection = connection.get_collection(self.model._meta.db_table)
38-
self.cursor = collection.aggregate(self.pipeline)
38+
self.cursor = collection.aggregate(self.pipeline, session=connection.session)
3939

4040
def __str__(self):
4141
return str(self.pipeline)

docs/source/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"pymongo": ("https://pymongo.readthedocs.io/en/stable/", None),
4646
"python": ("https://docs.python.org/3/", None),
4747
"atlas": ("https://www.mongodb.com/docs/atlas/", None),
48+
"manual": ("https://www.mongodb.com/docs/manual/", None),
4849
}
4950

5051
root_doc = "contents"

docs/source/ref/database.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,43 @@ effect. Rather, if you need to close the connection pool, use
4141
.. versionadded:: 5.2.0b0
4242

4343
Support for connection pooling and ``connection.close_pool()`` were added.
44+
45+
.. _transactions:
46+
47+
Transactions
48+
============
49+
50+
.. versionadded:: 5.2.0b2
51+
52+
Support for :doc:`Django's transactions APIs <django:topics/db/transactions>`
53+
is enabled if MongoDB is configured as a :doc:`replica set<manual:replication>`
54+
or a :doc:`sharded cluster <manual:sharding>`.
55+
56+
If transactions aren't supported, query execution uses Django and MongoDB's
57+
default behavior of autocommit mode. Each query is immediately committed to the
58+
database. Django's transaction management APIs, such as
59+
:func:`~django.db.transaction.atomic`, function as no-ops.
60+
61+
.. _transactions-limitations:
62+
63+
Limitations
64+
-----------
65+
66+
MongoDB's transaction limitations that are applicable to Django are:
67+
68+
- :meth:`QuerySet.union() <django.db.models.query.QuerySet.union>` is not
69+
supported inside a transaction.
70+
- If a transaction raises an exception, the transaction is no longer usable.
71+
For example, if the update stage of :meth:`QuerySet.update_or_create()
72+
<django.db.models.query.QuerySet.update_or_create>` fails with
73+
:class:`~django.db.IntegrityError` due to a unique constraint violation, the
74+
create stage won't be able to proceed.
75+
:class:`pymongo.errors.OperationFailure` is raised, wrapped by
76+
:class:`django.db.DatabaseError`.
77+
- Savepoints (i.e. nested :func:`~django.db.transaction.atomic` blocks) aren't
78+
supported. The outermost :func:`~django.db.transaction.atomic` will start
79+
a transaction while any subsequent :func:`~django.db.transaction.atomic`
80+
blocks will have no effect.
81+
- Migration operations aren't :ref:`wrapped in a transaction
82+
<topics/migrations:transactions>` because of MongoDB restrictions such as
83+
adding indexes to existing collections while in a transaction.

docs/source/releases/5.2.x.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ New features
1313
- Added subquery support for :class:`~.fields.EmbeddedModelArrayField`.
1414
- Added the ``options`` parameter to
1515
:func:`~django_mongodb_backend.utils.parse_uri`.
16+
- Added support for :ref:`database transactions <transactions>`.
1617

1718
5.2.0 beta 1
1819
============

docs/source/topics/known-issues.rst

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,7 @@ Database functions
8080
Transaction management
8181
======================
8282

83-
Query execution uses Django and MongoDB's default behavior of autocommit mode.
84-
Each query is immediately committed to the database.
85-
86-
Django's :doc:`transaction management APIs <django:topics/db/transactions>`
87-
are not supported.
83+
See :ref:`transactions` for details.
8884

8985
Database introspection
9086
======================

tests/backend_/test_base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.core.exceptions import ImproperlyConfigured
22
from django.db import connection
33
from django.db.backends.signals import connection_created
4-
from django.test import SimpleTestCase, TestCase
4+
from django.test import SimpleTestCase, TransactionTestCase
55

66
from django_mongodb_backend.base import DatabaseWrapper
77

@@ -15,7 +15,9 @@ def test_database_name_empty(self):
1515
DatabaseWrapper(settings).get_connection_params()
1616

1717

18-
class DatabaseWrapperConnectionTests(TestCase):
18+
class DatabaseWrapperConnectionTests(TransactionTestCase):
19+
available_apps = ["backend_"]
20+
1921
def test_set_autocommit(self):
2022
self.assertIs(connection.get_autocommit(), True)
2123
connection.set_autocommit(False)

0 commit comments

Comments
 (0)