Skip to content

Commit 65a61a2

Browse files
authored
Add SELECT ... FOR NO KEY UPDATE support for Postgres (#1949)
Allow slightly reducing the lock level when using `select_for_update` on Postgres DBs. This lock level allows concurrent inserts and deletes on tables that have foreign key references to the locked rows. The parameters follow the Django ORM design: https://code.djangoproject.com/ticket/30375 * Update pypika-tortoise version requirement
1 parent 5fbeae7 commit 65a61a2

File tree

9 files changed

+66
-15
lines changed

9 files changed

+66
-15
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ Changed
1515
^^^^^
1616
- Force async task switch every 2000 rows when converting db objects to python objects to avoid blocking the event loop (#1939)
1717

18+
Added
19+
^^^^^
20+
- Add `no_key` parameter to `queryset.select_for_update`.
21+
1822
0.25.0
1923
------
2024
Fixed

CONTRIBUTORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ Contributors
6464
* Lance Moe ``@lancemoe``
6565
* Markus Beckschulte ``@markus-96``
6666
* Frederic Aoustin ``@fraoustin``
67+
* Ludwig Hähne ``@pankrat``
6768

6869
Special Thanks
6970
==============

poetry.lock

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ include = ["CHANGELOG.rst", "LICENSE", "README.rst"]
1111
dynamic = [ "classifiers" ]
1212
requires-python = ">=3.9"
1313
dependencies = [
14-
"pypika-tortoise (>=0.5.0,<1.0.0); python_version < '4.0'",
14+
"pypika-tortoise (>=0.6.0,<1.0.0); python_version < '4.0'",
1515
"iso8601 (>=2.1.0,<3.0.0); python_version < '4.0'",
1616
"aiosqlite (>=0.16.0,<1.0.0)",
1717
"pytz",

tests/test_queryset.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,7 @@ async def test_select_for_update(self):
674674
sql2 = IntFields.filter(pk=1).only("id").select_for_update(nowait=True).sql()
675675
sql3 = IntFields.filter(pk=1).only("id").select_for_update(skip_locked=True).sql()
676676
sql4 = IntFields.filter(pk=1).only("id").select_for_update(of=("intfields",)).sql()
677+
sql5 = IntFields.filter(pk=1).only("id").select_for_update(no_key=True).sql()
677678

678679
dialect = self.db.schema_generator.DIALECT
679680
if dialect == "postgres":
@@ -694,6 +695,10 @@ async def test_select_for_update(self):
694695
sql4,
695696
'SELECT "id" "id" FROM "intfields" WHERE "id"=%s FOR UPDATE OF "intfields"',
696697
)
698+
self.assertEqual(
699+
sql5,
700+
'SELECT "id" "id" FROM "intfields" WHERE "id"=%s FOR NO KEY UPDATE',
701+
)
697702
else:
698703
self.assertEqual(
699704
sql1,
@@ -711,6 +716,10 @@ async def test_select_for_update(self):
711716
sql4,
712717
'SELECT "id" "id" FROM "intfields" WHERE "id"=$1 FOR UPDATE OF "intfields"',
713718
)
719+
self.assertEqual(
720+
sql5,
721+
'SELECT "id" "id" FROM "intfields" WHERE "id"=$1 FOR NO KEY UPDATE',
722+
)
714723
elif dialect == "mysql":
715724
self.assertEqual(
716725
sql1,
@@ -728,6 +737,10 @@ async def test_select_for_update(self):
728737
sql4,
729738
"SELECT `id` `id` FROM `intfields` WHERE `id`=%s FOR UPDATE OF `intfields`",
730739
)
740+
self.assertEqual(
741+
sql5,
742+
"SELECT `id` `id` FROM `intfields` WHERE `id`=%s FOR UPDATE",
743+
)
731744

732745
async def test_select_related(self):
733746
tournament = await Tournament.create(name="1")

tortoise/backends/base/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class Capabilities:
3434
DDL statement, and not as a separate statement.
3535
:param supports_transactions: Indicates that this DB supports transactions.
3636
:param support_for_update: Indicates that this DB supports SELECT ... FOR UPDATE SQL statement.
37+
:param support_for_update_no_key: Indicates that this DB supports SELECT ... FOR NO KEY UPDATE SQL statement.
3738
:param support_index_hint: Support force index or use index.
3839
:param support_update_limit_order_by: support update/delete with limit and order by.
3940
:param: support_for_posix_regex_queries: indicated if the db supports posix regex queries
@@ -50,6 +51,7 @@ def __init__(
5051
inline_comment: bool = False,
5152
supports_transactions: bool = True,
5253
support_for_update: bool = True,
54+
support_for_no_key_update: bool = False,
5355
# Support force index or use index?
5456
support_index_hint: bool = False,
5557
# support update/delete with limit and order by
@@ -64,6 +66,7 @@ def __init__(
6466
self.inline_comment = inline_comment
6567
self.supports_transactions = supports_transactions
6668
self.support_for_update = support_for_update
69+
self.support_for_no_key_update = support_for_no_key_update
6770
self.support_index_hint = support_index_hint
6871
self.support_update_limit_order_by = support_update_limit_order_by
6972
self.support_for_posix_regex_queries = support_for_posix_regex_queries

tortoise/backends/base_postgres/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ class BasePostgresClient(BaseDBAsyncClient, abc.ABC):
4545
executor_class: type[BasePostgresExecutor] = BasePostgresExecutor
4646
schema_generator: type[BasePostgresSchemaGenerator] = BasePostgresSchemaGenerator
4747
capabilities = Capabilities(
48-
"postgres", support_update_limit_order_by=False, support_for_posix_regex_queries=True
48+
"postgres",
49+
support_update_limit_order_by=False,
50+
support_for_posix_regex_queries=True,
51+
support_for_no_key_update=True,
4952
)
5053
connection_class: AsyncConnection | Connection | None = None
5154
loop: AbstractEventLoop | None = None

tortoise/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1147,14 +1147,17 @@ def select_for_update(
11471147
skip_locked: bool = False,
11481148
of: tuple[str, ...] = (),
11491149
using_db: BaseDBAsyncClient | None = None,
1150+
no_key: bool = False,
11501151
) -> QuerySet[Self]:
11511152
"""
11521153
Make QuerySet select for update.
11531154
11541155
Returns a queryset that will lock rows until the end of the transaction,
11551156
generating a SELECT ... FOR UPDATE SQL statement on supported databases.
11561157
"""
1157-
return cls._db_queryset(using_db, for_write=True).select_for_update(nowait, skip_locked, of)
1158+
return cls._db_queryset(using_db, for_write=True).select_for_update(
1159+
nowait, skip_locked, of, no_key=no_key
1160+
)
11581161

11591162
@classmethod
11601163
async def update_or_create(

tortoise/queryset.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

3-
from collections import defaultdict
43
import types
4+
from collections import defaultdict
55
from collections.abc import AsyncIterator, Callable, Collection, Generator, Iterable
66
from copy import copy
77
from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar, cast, overload
@@ -326,6 +326,7 @@ class QuerySet(AwaitableQuery[MODEL]):
326326
"_select_for_update_nowait",
327327
"_select_for_update_skip_locked",
328328
"_select_for_update_of",
329+
"_select_for_update_no_key",
329330
"_select_related",
330331
"_select_related_idx",
331332
"_use_indexes",
@@ -351,6 +352,7 @@ def __init__(self, model: type[MODEL]) -> None:
351352
self._select_for_update_nowait: bool = False
352353
self._select_for_update_skip_locked: bool = False
353354
self._select_for_update_of: set[str] = set()
355+
self._select_for_update_no_key: bool = False
354356
self._select_related: set[str] = set()
355357
self._select_related_idx: list[
356358
tuple[type[Model], int, Table | str, type[Model], Iterable[str | None]]
@@ -385,6 +387,7 @@ def _clone(self) -> QuerySet[MODEL]:
385387
queryset._select_for_update_nowait = self._select_for_update_nowait
386388
queryset._select_for_update_skip_locked = self._select_for_update_skip_locked
387389
queryset._select_for_update_of = self._select_for_update_of
390+
queryset._select_for_update_no_key = self._select_for_update_no_key
388391
queryset._select_related = self._select_related
389392
queryset._select_related_idx = self._select_related_idx
390393
queryset._force_indexes = self._force_indexes
@@ -572,20 +575,40 @@ def distinct(self) -> QuerySet[MODEL]:
572575
return queryset
573576

574577
def select_for_update(
575-
self, nowait: bool = False, skip_locked: bool = False, of: tuple[str, ...] = ()
578+
self,
579+
nowait: bool = False,
580+
skip_locked: bool = False,
581+
of: tuple[str, ...] = (),
582+
no_key: bool = False,
576583
) -> QuerySet[MODEL]:
577584
"""
578585
Make QuerySet select for update.
579586
580587
Returns a queryset that will lock rows until the end of the transaction,
581588
generating a SELECT ... FOR UPDATE SQL statement on supported databases.
589+
590+
:param nowait:
591+
If `True`, raise an error if the lock cannot be obtained immediately.
592+
:param skip_locked:
593+
If `True`, skip rows that are already locked by other transactions instead of waiting.
594+
:param of:
595+
Specify the tables to lock when dealing with multiple related tables, e.g. when using `select_related`.
596+
Provide a tuple of table names to indicate which tables' rows should be locked. By default, all fetched
597+
rows are locked.
598+
:param no_key:
599+
If `True`, use the lower SELECT ... FOR NO KEY UPDATE lock strength on PostgreSQL to allow creating or
600+
deleting rows in other tables that reference the locked rows via foreign keys. The parameter is ignored
601+
on other backends.
582602
"""
583603
if self.capabilities.support_for_update:
584604
queryset = self._clone()
585605
queryset._select_for_update = True
586606
queryset._select_for_update_nowait = nowait
587607
queryset._select_for_update_skip_locked = skip_locked
588608
queryset._select_for_update_of = set(of)
609+
queryset._select_for_update_no_key = (
610+
no_key and self.capabilities.support_for_no_key_update
611+
)
589612
return queryset
590613
return self
591614

@@ -1186,6 +1209,7 @@ def _make_query(self) -> None:
11861209
self._select_for_update_nowait,
11871210
self._select_for_update_skip_locked,
11881211
self._select_for_update_of,
1212+
self._select_for_update_no_key,
11891213
)
11901214
if self._select_related:
11911215
for select_related in self._select_related:

0 commit comments

Comments
 (0)