Skip to content

Commit b9f7dce

Browse files
bluetechtimgraham
authored andcommitted
Fixed #28010 -- Added FOR UPDATE OF support to QuerySet.select_for_update().
1 parent 2d18c60 commit b9f7dce

File tree

12 files changed

+206
-23
lines changed

12 files changed

+206
-23
lines changed

django/db/backends/base/features.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ class BaseDatabaseFeatures:
3636
has_select_for_update = False
3737
has_select_for_update_nowait = False
3838
has_select_for_update_skip_locked = False
39+
has_select_for_update_of = False
40+
# Does the database's SELECT FOR UPDATE OF syntax require a column rather
41+
# than a table?
42+
select_for_update_of_column = False
3943

4044
supports_select_related = True
4145

django/db/backends/base/operations.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,16 +177,15 @@ def force_no_ordering(self):
177177
"""
178178
return []
179179

180-
def for_update_sql(self, nowait=False, skip_locked=False):
180+
def for_update_sql(self, nowait=False, skip_locked=False, of=()):
181181
"""
182182
Return the FOR UPDATE SQL clause to lock rows for an update operation.
183183
"""
184-
if nowait:
185-
return 'FOR UPDATE NOWAIT'
186-
elif skip_locked:
187-
return 'FOR UPDATE SKIP LOCKED'
188-
else:
189-
return 'FOR UPDATE'
184+
return 'FOR UPDATE%s%s%s' % (
185+
' OF %s' % ', '.join(of) if of else '',
186+
' NOWAIT' if nowait else '',
187+
' SKIP LOCKED' if skip_locked else '',
188+
)
190189

191190
def last_executed_query(self, cursor, sql, params):
192191
"""

django/db/backends/oracle/features.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
99
has_select_for_update = True
1010
has_select_for_update_nowait = True
1111
has_select_for_update_skip_locked = True
12+
has_select_for_update_of = True
13+
select_for_update_of_column = True
1214
can_return_id_from_insert = True
1315
allow_sliced_subqueries = False
1416
can_introspect_autofield = True

django/db/backends/postgresql/features.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
1313
can_defer_constraint_checks = True
1414
has_select_for_update = True
1515
has_select_for_update_nowait = True
16+
has_select_for_update_of = True
1617
has_bulk_insert = True
1718
uses_savepoints = True
1819
can_release_savepoints = True

django/db/models/query.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ def difference(self, *other_qs):
839839
return self
840840
return self._combinator_query('difference', *other_qs)
841841

842-
def select_for_update(self, nowait=False, skip_locked=False):
842+
def select_for_update(self, nowait=False, skip_locked=False, of=()):
843843
"""
844844
Return a new QuerySet instance that will select objects with a
845845
FOR UPDATE lock.
@@ -851,6 +851,7 @@ def select_for_update(self, nowait=False, skip_locked=False):
851851
obj.query.select_for_update = True
852852
obj.query.select_for_update_nowait = nowait
853853
obj.query.select_for_update_skip_locked = skip_locked
854+
obj.query.select_for_update_of = of
854855
return obj
855856

856857
def select_related(self, *fields):

django/db/models/sql/compiler.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections
12
import re
23
from itertools import chain
34

@@ -472,14 +473,21 @@ def as_sql(self, with_limits=True, with_col_aliases=False):
472473
)
473474
nowait = self.query.select_for_update_nowait
474475
skip_locked = self.query.select_for_update_skip_locked
475-
# If it's a NOWAIT/SKIP LOCKED query but the backend
476-
# doesn't support it, raise a DatabaseError to prevent a
476+
of = self.query.select_for_update_of
477+
# If it's a NOWAIT/SKIP LOCKED/OF query but the backend
478+
# doesn't support it, raise NotSupportedError to prevent a
477479
# possible deadlock.
478480
if nowait and not self.connection.features.has_select_for_update_nowait:
479481
raise NotSupportedError('NOWAIT is not supported on this database backend.')
480482
elif skip_locked and not self.connection.features.has_select_for_update_skip_locked:
481483
raise NotSupportedError('SKIP LOCKED is not supported on this database backend.')
482-
for_update_part = self.connection.ops.for_update_sql(nowait=nowait, skip_locked=skip_locked)
484+
elif of and not self.connection.features.has_select_for_update_of:
485+
raise NotSupportedError('FOR UPDATE OF is not supported on this database backend.')
486+
for_update_part = self.connection.ops.for_update_sql(
487+
nowait=nowait,
488+
skip_locked=skip_locked,
489+
of=self.get_select_for_update_of_arguments(),
490+
)
483491

484492
if for_update_part and self.connection.features.for_update_after_from:
485493
result.append(for_update_part)
@@ -832,6 +840,59 @@ def get_related_klass_infos(klass_info, related_klass_infos):
832840
)
833841
return related_klass_infos
834842

843+
def get_select_for_update_of_arguments(self):
844+
"""
845+
Return a quoted list of arguments for the SELECT FOR UPDATE OF part of
846+
the query.
847+
"""
848+
def _get_field_choices():
849+
"""Yield all allowed field paths in breadth-first search order."""
850+
queue = collections.deque([(None, self.klass_info)])
851+
while queue:
852+
parent_path, klass_info = queue.popleft()
853+
if parent_path is None:
854+
path = []
855+
yield 'self'
856+
else:
857+
path = parent_path + [klass_info['field'].name]
858+
yield LOOKUP_SEP.join(path)
859+
queue.extend(
860+
(path, klass_info)
861+
for klass_info in klass_info.get('related_klass_infos', [])
862+
)
863+
result = []
864+
invalid_names = []
865+
for name in self.query.select_for_update_of:
866+
parts = [] if name == 'self' else name.split(LOOKUP_SEP)
867+
klass_info = self.klass_info
868+
for part in parts:
869+
for related_klass_info in klass_info.get('related_klass_infos', []):
870+
if related_klass_info['field'].name == part:
871+
klass_info = related_klass_info
872+
break
873+
else:
874+
klass_info = None
875+
break
876+
if klass_info is None:
877+
invalid_names.append(name)
878+
continue
879+
select_index = klass_info['select_fields'][0]
880+
col = self.select[select_index][0]
881+
if self.connection.features.select_for_update_of_column:
882+
result.append(self.compile(col)[0])
883+
else:
884+
result.append(self.quote_name_unless_alias(col.alias))
885+
if invalid_names:
886+
raise FieldError(
887+
'Invalid field name(s) given in select_for_update(of=(...)): %s. '
888+
'Only relational fields followed in the query are allowed. '
889+
'Choices are: %s.' % (
890+
', '.join(invalid_names),
891+
', '.join(_get_field_choices()),
892+
)
893+
)
894+
return result
895+
835896
def deferred_to_columns(self):
836897
"""
837898
Convert the self.deferred_loading data structure to mapping of table

django/db/models/sql/query.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def __init__(self, model, where=WhereNode):
161161
self.select_for_update = False
162162
self.select_for_update_nowait = False
163163
self.select_for_update_skip_locked = False
164+
self.select_for_update_of = ()
164165

165166
self.select_related = False
166167
# Arbitrary limit for select_related to prevents infinite recursion.
@@ -288,6 +289,7 @@ def clone(self, klass=None, memo=None, **kwargs):
288289
obj.select_for_update = self.select_for_update
289290
obj.select_for_update_nowait = self.select_for_update_nowait
290291
obj.select_for_update_skip_locked = self.select_for_update_skip_locked
292+
obj.select_for_update_of = self.select_for_update_of
291293
obj.select_related = self.select_related
292294
obj.values_select = self.values_select
293295
obj._annotations = self._annotations.copy() if self._annotations is not None else None

docs/ref/databases.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,9 @@ both MySQL and Django will attempt to convert the values from UTC to local time.
629629
Row locking with ``QuerySet.select_for_update()``
630630
-------------------------------------------------
631631

632-
MySQL does not support the ``NOWAIT`` and ``SKIP LOCKED`` options to the
633-
``SELECT ... FOR UPDATE`` statement. If ``select_for_update()`` is used with
634-
``nowait=True`` or ``skip_locked=True``, then a
632+
MySQL does not support the ``NOWAIT``, ``SKIP LOCKED``, and ``OF`` options to
633+
the ``SELECT ... FOR UPDATE`` statement. If ``select_for_update()`` is used
634+
with ``nowait=True``, ``skip_locked=True``, or ``of`` then a
635635
:exc:`~django.db.NotSupportedError` is raised.
636636

637637
Automatic typecasting can cause unexpected results

docs/ref/models/querysets.txt

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,7 +1611,7 @@ For example::
16111611
``select_for_update()``
16121612
~~~~~~~~~~~~~~~~~~~~~~~
16131613

1614-
.. method:: select_for_update(nowait=False, skip_locked=False)
1614+
.. method:: select_for_update(nowait=False, skip_locked=False, of=())
16151615

16161616
Returns a queryset that will lock rows until the end of the transaction,
16171617
generating a ``SELECT ... FOR UPDATE`` SQL statement on supported databases.
@@ -1635,14 +1635,21 @@ queryset is evaluated. You can also ignore locked rows by using
16351635
``select_for_update()`` with both options enabled will result in a
16361636
:exc:`ValueError`.
16371637

1638+
By default, ``select_for_update()`` locks all rows that are selected by the
1639+
query. For example, rows of related objects specified in :meth:`select_related`
1640+
are locked in addition to rows of the queryset's model. If this isn't desired,
1641+
specify the related objects you want to lock in ``select_for_update(of=(...))``
1642+
using the same fields syntax as :meth:`select_related`. Use the value ``'self'``
1643+
to refer to the queryset's model.
1644+
16381645
Currently, the ``postgresql``, ``oracle``, and ``mysql`` database
16391646
backends support ``select_for_update()``. However, MySQL doesn't support the
1640-
``nowait`` and ``skip_locked`` arguments.
1647+
``nowait``, ``skip_locked``, and ``of`` arguments.
16411648

1642-
Passing ``nowait=True`` or ``skip_locked=True`` to ``select_for_update()``
1643-
using database backends that do not support these options, such as MySQL,
1644-
raises a :exc:`~django.db.NotSupportedError`. This prevents code from
1645-
unexpectedly blocking.
1649+
Passing ``nowait=True``, ``skip_locked=True``, or ``of`` to
1650+
``select_for_update()`` using database backends that do not support these
1651+
options, such as MySQL, raises a :exc:`~django.db.NotSupportedError`. This
1652+
prevents code from unexpectedly blocking.
16461653

16471654
Evaluating a queryset with ``select_for_update()`` in autocommit mode on
16481655
backends which support ``SELECT ... FOR UPDATE`` is a
@@ -1670,6 +1677,10 @@ raised if ``select_for_update()`` is used in autocommit mode.
16701677

16711678
The ``skip_locked`` argument was added.
16721679

1680+
.. versionchanged:: 2.0
1681+
1682+
The ``of`` argument was added.
1683+
16731684
``raw()``
16741685
~~~~~~~~~
16751686

docs/releases/2.0.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ Models
252252
:class:`~django.db.models.functions.datetime.Extract` now works with
253253
:class:`~django.db.models.DurationField`.
254254

255+
* Added the ``of`` argument to :meth:`.QuerySet.select_for_update()`, supported
256+
on PostgreSQL and Oracle, to lock only rows from specific tables rather than
257+
all selected tables. It may be helpful particularly when
258+
:meth:`~.QuerySet.select_for_update()` is used in conjunction with
259+
:meth:`~.QuerySet.select_related()`.
260+
255261
Requests and Responses
256262
~~~~~~~~~~~~~~~~~~~~~~
257263

@@ -331,6 +337,11 @@ backends.
331337
* The first argument of ``SchemaEditor._create_index_name()`` is now
332338
``table_name`` rather than ``model``.
333339

340+
* To enable ``FOR UPDATE OF`` support, set
341+
``DatabaseFeatures.has_select_for_update_of = True``. If the database
342+
requires that the arguments to ``OF`` be columns rather than tables, set
343+
``DatabaseFeatures.select_for_update_of_column = True``.
344+
334345
Dropped support for Oracle 11.2
335346
-------------------------------
336347

tests/select_for_update/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
from django.db import models
22

33

4+
class Country(models.Model):
5+
name = models.CharField(max_length=30)
6+
7+
8+
class City(models.Model):
9+
name = models.CharField(max_length=30)
10+
country = models.ForeignKey(Country, models.CASCADE)
11+
12+
413
class Person(models.Model):
514
name = models.CharField(max_length=30)
15+
born = models.ForeignKey(City, models.CASCADE, related_name='+')
16+
died = models.ForeignKey(City, models.CASCADE, related_name='+')

0 commit comments

Comments
 (0)