Skip to content
This repository was archived by the owner on Apr 15, 2025. It is now read-only.

Commit 69cc396

Browse files
RobertCraigiejonathanblade
authored andcommitted
feat(client): add transaction isolation level
1 parent 22cc236 commit 69cc396

File tree

8 files changed

+298
-29
lines changed

8 files changed

+298
-29
lines changed

databases/sync_tests/test_transactions.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from prisma import Prisma
99
from prisma.models import User, Profile
1010

11-
from ..utils import CURRENT_DATABASE
11+
from ..utils import CURRENT_DATABASE, ISOLATION_LEVELS_MAPPING, RawQueries
1212

1313

1414
def test_model_query(client: Prisma) -> None:
@@ -201,3 +201,66 @@ def test_transaction_already_closed(client: Prisma) -> None:
201201
transaction.user.delete_many()
202202

203203
assert exc.match('Transaction already closed')
204+
205+
206+
@pytest.mark.parametrize(
207+
('input_level',),
208+
[
209+
pytest.param(
210+
'READ_UNCOMMITTED',
211+
id='read uncommitted',
212+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
213+
),
214+
pytest.param(
215+
'READ_COMMITTED',
216+
id='read committed',
217+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
218+
),
219+
pytest.param(
220+
'REPEATABLE_READ',
221+
id='repeatable read',
222+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
223+
),
224+
pytest.param(
225+
'SNAPSHOT',
226+
id='snapshot',
227+
marks=pytest.mark.skipif(CURRENT_DATABASE != 'sqlserver', reason='Not available'),
228+
),
229+
pytest.param(
230+
'SERIALIZABLE',
231+
id='serializable',
232+
marks=pytest.mark.skipif(
233+
CURRENT_DATABASE == 'sqlite',
234+
reason="SQLite doesn't have the way to query the current transaction isolation level",
235+
),
236+
),
237+
],
238+
)
239+
@pytest.mark.skipif(CURRENT_DATABASE == 'mongodb', reason='Not available')
240+
@pytest.mark.skipif(
241+
CURRENT_DATABASE in ['mysql', 'mariadb'],
242+
reason="""
243+
MySQL 8.0 doesn't have the way to query the current transaction isolation level.
244+
See https://bugs.mysql.com/bug.php?id=53341
245+
246+
Refs:
247+
* https://github.com/prisma/prisma/issues/22890
248+
""",
249+
)
250+
def test_isolation_level(
251+
client: Prisma,
252+
database: str,
253+
raw_queries: RawQueries,
254+
input_level: str,
255+
) -> None:
256+
"""Ensure that transaction isolation level is set correctly"""
257+
with client.tx(isolation_level=getattr(prisma.TransactionIsolationLevel, input_level)) as tx:
258+
results = tx.query_raw(raw_queries.select_tx_isolation)
259+
260+
assert len(results) == 1
261+
262+
row = results[0]
263+
assert any(row)
264+
265+
level = next(iter(row.values()))
266+
assert level == ISOLATION_LEVELS_MAPPING[input_level][database]

databases/tests/test_transactions.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from prisma import Prisma
99
from prisma.models import User, Profile
1010

11-
from ..utils import CURRENT_DATABASE
11+
from ..utils import CURRENT_DATABASE, ISOLATION_LEVELS_MAPPING, RawQueries
1212

1313

1414
@pytest.mark.asyncio
@@ -212,3 +212,67 @@ async def test_transaction_already_closed(client: Prisma) -> None:
212212
await transaction.user.delete_many()
213213

214214
assert exc.match('Transaction already closed')
215+
216+
217+
@pytest.mark.asyncio
218+
@pytest.mark.parametrize(
219+
('input_level',),
220+
[
221+
pytest.param(
222+
'READ_UNCOMMITTED',
223+
id='read uncommitted',
224+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
225+
),
226+
pytest.param(
227+
'READ_COMMITTED',
228+
id='read committed',
229+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
230+
),
231+
pytest.param(
232+
'REPEATABLE_READ',
233+
id='repeatable read',
234+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
235+
),
236+
pytest.param(
237+
'SNAPSHOT',
238+
id='snapshot',
239+
marks=pytest.mark.skipif(CURRENT_DATABASE != 'sqlserver', reason='Not available'),
240+
),
241+
pytest.param(
242+
'SERIALIZABLE',
243+
id='serializable',
244+
marks=pytest.mark.skipif(
245+
CURRENT_DATABASE == 'sqlite',
246+
reason="SQLite doesn't have the way to query the current transaction isolation level",
247+
),
248+
),
249+
],
250+
)
251+
@pytest.mark.skipif(CURRENT_DATABASE == 'mongodb', reason='Not available')
252+
@pytest.mark.skipif(
253+
CURRENT_DATABASE in ['mysql', 'mariadb'],
254+
reason="""
255+
MySQL 8.0 doesn't have the way to query the current transaction isolation level.
256+
See https://bugs.mysql.com/bug.php?id=53341
257+
258+
Refs:
259+
* https://github.com/prisma/prisma/issues/22890
260+
""",
261+
)
262+
async def test_isolation_level(
263+
client: Prisma,
264+
database: str,
265+
raw_queries: RawQueries,
266+
input_level: str,
267+
) -> None:
268+
"""Ensure that transaction isolation level is set correctly"""
269+
async with client.tx(isolation_level=getattr(prisma.TransactionIsolationLevel, input_level)) as tx:
270+
results = await tx.query_raw(raw_queries.select_tx_isolation)
271+
272+
assert len(results) == 1
273+
274+
row = results[0]
275+
assert any(row)
276+
277+
level = next(iter(row.values()))
278+
assert level == ISOLATION_LEVELS_MAPPING[input_level][database]

databases/utils.py

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

33
import os
4-
from typing import Set
4+
from typing import Set, Optional
55
from pathlib import Path
6-
from typing_extensions import Literal, get_args, override
6+
from typing_extensions import Literal, TypedDict, get_args, override
77

88
from pydantic import BaseModel
99
from syrupy.extensions.amber import AmberSnapshotExtension
@@ -85,6 +85,8 @@ class RawQueries(BaseModel):
8585
test_query_raw_no_result: LiteralString
8686
test_execute_raw_no_result: LiteralString
8787

88+
select_tx_isolation: LiteralString
89+
8890

8991
_mysql_queries = RawQueries(
9092
count_posts="""
@@ -136,8 +138,12 @@ class RawQueries(BaseModel):
136138
SET title = 'updated title'
137139
WHERE id = 'sdldsd'
138140
""",
141+
select_tx_isolation="""
142+
SELECT @@transaction_isolation
143+
""",
139144
)
140145

146+
141147
_postgresql_queries = RawQueries(
142148
count_posts="""
143149
SELECT COUNT(*) as count
@@ -188,6 +194,9 @@ class RawQueries(BaseModel):
188194
SET title = 'updated title'
189195
WHERE id = 'sdldsd'
190196
""",
197+
select_tx_isolation="""
198+
SHOW transaction_isolation
199+
""",
191200
)
192201

193202
RAW_QUERIES_MAPPING: DatabaseMapping[RawQueries] = {
@@ -245,5 +254,55 @@ class RawQueries(BaseModel):
245254
SET title = 'updated title'
246255
WHERE id = 'sdldsd'
247256
""",
257+
select_tx_isolation="""
258+
Not avaliable
259+
""",
248260
),
249261
}
262+
263+
264+
class IsolationLevelsMapping(TypedDict):
265+
READ_UNCOMMITTED: DatabaseMapping[Optional[LiteralString]]
266+
READ_COMMITTED: DatabaseMapping[Optional[LiteralString]]
267+
REPEATABLE_READ: DatabaseMapping[Optional[LiteralString]]
268+
SNAPSHOT: DatabaseMapping[Optional[LiteralString]]
269+
SERIALIZABLE: DatabaseMapping[Optional[LiteralString]]
270+
271+
272+
ISOLATION_LEVELS_MAPPING: IsolationLevelsMapping = {
273+
'READ_UNCOMMITTED': {
274+
'postgresql': 'read uncommitted',
275+
'cockroachdb': None,
276+
'mysql': 'READ-UNCOMMITTED',
277+
'mariadb': 'READ-UNCOMMITTED',
278+
'sqlite': None,
279+
},
280+
'READ_COMMITTED': {
281+
'postgresql': 'read committed',
282+
'cockroachdb': None,
283+
'mysql': 'READ-COMMITTED',
284+
'mariadb': 'READ-COMMITTED',
285+
'sqlite': None,
286+
},
287+
'REPEATABLE_READ': {
288+
'postgresql': 'repeatable read',
289+
'cockroachdb': None,
290+
'mysql': 'REPEATABLE-READ',
291+
'mariadb': 'REPEATABLE-READ',
292+
'sqlite': None,
293+
},
294+
'SNAPSHOT': {
295+
'postgresql': None,
296+
'cockroachdb': None,
297+
'mysql': None,
298+
'mariadb': None,
299+
'sqlite': None,
300+
},
301+
'SERIALIZABLE': {
302+
'postgresql': 'serializable',
303+
'cockroachdb': 'SERIALIZABLE',
304+
'mysql': 'SERIALIZABLE',
305+
'mariadb': 'SERIALIZABLE',
306+
'sqlite': None,
307+
},
308+
}

docs/reference/transactions.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,23 @@ In the case that this example runs successfully, then both database writes are c
114114
)
115115
```
116116

117+
## Isolation levels
118+
119+
By default, Prisma sets the isolation level to the value currently configured in the database. You can modify this
120+
default with the `isolation_level` argument (see [supported isolation levels](https://www.prisma.io/docs/orm/prisma-client/queries/transactions#supported-isolation-levels)).
121+
122+
!!! note
123+
Prisma Client Python generates `TransactionIsolationLevel` enumeration that includes only the options supported by the current database.
124+
125+
```py
126+
from prisma import Prisma, TransactionIsolationLevel
127+
128+
client = Prisma()
129+
client.tx(
130+
isolation_level=TransactionIsolationLevel.READ_UNCOMMITTED,
131+
)
132+
```
133+
117134
## Timeouts
118135

119136
You can pass the following options to configure how timeouts are applied to your transaction:

src/prisma/_transactions.py

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,31 @@
33
import logging
44
import warnings
55
from types import TracebackType
6-
from typing import TYPE_CHECKING, Generic, TypeVar
6+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
77
from datetime import timedelta
88

99
from ._types import TransactionId
1010
from .errors import TransactionNotStartedError
11+
from ._compat import StrEnum
1112
from ._builder import dumps
1213

1314
if TYPE_CHECKING:
1415
from ._base_client import SyncBasePrisma, AsyncBasePrisma
1516

1617
log: logging.Logger = logging.getLogger(__name__)
1718

19+
__all__ = (
20+
'AsyncTransactionManager',
21+
'SyncTransactionManager',
22+
)
23+
1824

1925
_SyncPrismaT = TypeVar('_SyncPrismaT', bound='SyncBasePrisma')
2026
_AsyncPrismaT = TypeVar('_AsyncPrismaT', bound='AsyncBasePrisma')
27+
_IsolationLevelT = TypeVar('_IsolationLevelT', bound=StrEnum)
2128

2229

23-
class AsyncTransactionManager(Generic[_AsyncPrismaT]):
30+
class AsyncTransactionManager(Generic[_AsyncPrismaT, _IsolationLevelT]):
2431
"""Context manager for wrapping a Prisma instance within a transaction.
2532
2633
This should never be created manually, instead it should be used
@@ -33,8 +40,10 @@ def __init__(
3340
client: _AsyncPrismaT,
3441
max_wait: int | timedelta,
3542
timeout: int | timedelta,
43+
isolation_level: _IsolationLevelT | None,
3644
) -> None:
3745
self.__client = client
46+
self._isolation_level = isolation_level
3847

3948
if isinstance(max_wait, int):
4049
message = (
@@ -71,14 +80,15 @@ async def start(self, *, _from_context: bool = False) -> _AsyncPrismaT:
7180
stacklevel=3 if _from_context else 2,
7281
)
7382

74-
tx_id = await self.__client._engine.start_transaction(
75-
content=dumps(
76-
{
77-
'timeout': int(self._timeout.total_seconds() * 1000),
78-
'max_wait': int(self._max_wait.total_seconds() * 1000),
79-
}
80-
),
81-
)
83+
content_dict: dict[str, Any] = {
84+
'timeout': int(self._timeout.total_seconds() * 1000),
85+
'max_wait': int(self._max_wait.total_seconds() * 1000),
86+
}
87+
if self._isolation_level is not None:
88+
content_dict['isolation_level'] = self._isolation_level.value
89+
90+
tx_id = await self.__client._engine.start_transaction(content=dumps(content_dict))
91+
8292
self._tx_id = tx_id
8393
client = self.__client._copy()
8494
client._tx_id = tx_id
@@ -122,7 +132,7 @@ async def __aexit__(
122132
)
123133

124134

125-
class SyncTransactionManager(Generic[_SyncPrismaT]):
135+
class SyncTransactionManager(Generic[_SyncPrismaT, _IsolationLevelT]):
126136
"""Context manager for wrapping a Prisma instance within a transaction.
127137
128138
This should never be created manually, instead it should be used
@@ -135,8 +145,10 @@ def __init__(
135145
client: _SyncPrismaT,
136146
max_wait: int | timedelta,
137147
timeout: int | timedelta,
148+
isolation_level: _IsolationLevelT | None,
138149
) -> None:
139150
self.__client = client
151+
self._isolation_level = isolation_level
140152

141153
if isinstance(max_wait, int):
142154
message = (
@@ -173,14 +185,15 @@ def start(self, *, _from_context: bool = False) -> _SyncPrismaT:
173185
stacklevel=3 if _from_context else 2,
174186
)
175187

176-
tx_id = self.__client._engine.start_transaction(
177-
content=dumps(
178-
{
179-
'timeout': int(self._timeout.total_seconds() * 1000),
180-
'max_wait': int(self._max_wait.total_seconds() * 1000),
181-
}
182-
),
183-
)
188+
content_dict: dict[str, Any] = {
189+
'timeout': int(self._timeout.total_seconds() * 1000),
190+
'max_wait': int(self._max_wait.total_seconds() * 1000),
191+
}
192+
if self._isolation_level is not None:
193+
content_dict['isolation_level'] = self._isolation_level.value
194+
195+
tx_id = self.__client._engine.start_transaction(content=dumps(content_dict))
196+
184197
self._tx_id = tx_id
185198
client = self.__client._copy()
186199
client._tx_id = tx_id

0 commit comments

Comments
 (0)