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

Commit ef194c1

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

8 files changed

Lines changed: 288 additions & 29 deletions

File tree

databases/sync_tests/test_transactions.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import time
2-
from typing import Optional
2+
from typing import Dict, Optional
33
from datetime import timedelta
44

55
import pytest
@@ -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, RawQueries
1212

1313

1414
def test_model_query(client: Prisma) -> None:
@@ -201,3 +201,89 @@ 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', 'expected_level_mapping'),
208+
[
209+
pytest.param(
210+
'READ_UNCOMMITTED',
211+
{
212+
'postgresql': 'read uncommitted',
213+
'cockroachdb': None,
214+
'mysql': 'READ-UNCOMMITTED',
215+
'mariadb': 'READ-UNCOMMITTED',
216+
'sqlite': None,
217+
},
218+
id='read uncommitted',
219+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
220+
),
221+
pytest.param(
222+
'READ_COMMITTED',
223+
{
224+
'postgresql': 'read committed',
225+
'cockroachdb': None,
226+
'mysql': 'READ-COMMITTED',
227+
'mariadb': 'READ-COMMITTED',
228+
'sqlite': None,
229+
},
230+
id='read committed',
231+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
232+
),
233+
pytest.param(
234+
'REPEATABLE_READ',
235+
{
236+
'postgresql': 'repeatable read',
237+
'cockroachdb': None,
238+
'mysql': 'REPEATABLE-READ',
239+
'mariadb': 'REPEATABLE-READ',
240+
'sqlite': None,
241+
},
242+
id='repeatable read',
243+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
244+
),
245+
pytest.param(
246+
'SERIALIZABLE',
247+
{
248+
'postgresql': 'serializable',
249+
'cockroachdb': 'SERIALIZABLE',
250+
'mysql': 'SERIALIZABLE',
251+
'mariadb': 'SERIALIZABLE',
252+
'sqlite': None,
253+
},
254+
id='serializable',
255+
marks=pytest.mark.skipif(
256+
CURRENT_DATABASE == 'sqlite',
257+
reason="SQLite doesn't have the way to query the current transaction isolation level",
258+
),
259+
),
260+
],
261+
)
262+
@pytest.mark.skipif(
263+
CURRENT_DATABASE in ['mysql', 'mariadb'],
264+
reason="""
265+
MySQL 8.0 doesn't have the way to query the current transaction isolation level.
266+
See https://bugs.mysql.com/bug.php?id=53341
267+
268+
Refs:
269+
* https://github.com/prisma/prisma/issues/22890
270+
""",
271+
)
272+
def test_isolation_level(
273+
client: Prisma,
274+
database: str,
275+
raw_queries: RawQueries,
276+
input_level: str,
277+
expected_level_mapping: Dict[str, Optional[str]],
278+
) -> None:
279+
"""Ensure that transaction isolation level is set correctly"""
280+
with client.tx(isolation_level=getattr(prisma.TransactionIsolationLevel, input_level)) as tx:
281+
results = tx.query_raw(raw_queries.select_tx_isolation)
282+
283+
assert len(results) == 1
284+
285+
row = results[0]
286+
assert any(row)
287+
288+
level = next(iter(row.values()))
289+
assert level == expected_level_mapping[database]

databases/tests/test_transactions.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
from typing import Optional
2+
from typing import Dict, Optional
33
from datetime import timedelta
44

55
import pytest
@@ -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, RawQueries
1212

1313

1414
@pytest.mark.asyncio
@@ -212,3 +212,90 @@ 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', 'expected_level_mapping'),
220+
[
221+
pytest.param(
222+
'READ_UNCOMMITTED',
223+
{
224+
'postgresql': 'read uncommitted',
225+
'cockroachdb': None,
226+
'mysql': 'READ-UNCOMMITTED',
227+
'mariadb': 'READ-UNCOMMITTED',
228+
'sqlite': None,
229+
},
230+
id='read uncommitted',
231+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
232+
),
233+
pytest.param(
234+
'READ_COMMITTED',
235+
{
236+
'postgresql': 'read committed',
237+
'cockroachdb': None,
238+
'mysql': 'READ-COMMITTED',
239+
'mariadb': 'READ-COMMITTED',
240+
'sqlite': None,
241+
},
242+
id='read committed',
243+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
244+
),
245+
pytest.param(
246+
'REPEATABLE_READ',
247+
{
248+
'postgresql': 'repeatable read',
249+
'cockroachdb': None,
250+
'mysql': 'REPEATABLE-READ',
251+
'mariadb': 'REPEATABLE-READ',
252+
'sqlite': None,
253+
},
254+
id='repeatable read',
255+
marks=pytest.mark.skipif(CURRENT_DATABASE in ['cockroachdb', 'sqlite'], reason='Not available'),
256+
),
257+
pytest.param(
258+
'SERIALIZABLE',
259+
{
260+
'postgresql': 'serializable',
261+
'cockroachdb': 'SERIALIZABLE',
262+
'mysql': 'SERIALIZABLE',
263+
'mariadb': 'SERIALIZABLE',
264+
'sqlite': None,
265+
},
266+
id='serializable',
267+
marks=pytest.mark.skipif(
268+
CURRENT_DATABASE == 'sqlite',
269+
reason="SQLite doesn't have the way to query the current transaction isolation level",
270+
),
271+
),
272+
],
273+
)
274+
@pytest.mark.skipif(
275+
CURRENT_DATABASE in ['mysql', 'mariadb'],
276+
reason="""
277+
MySQL 8.0 doesn't have the way to query the current transaction isolation level.
278+
See https://bugs.mysql.com/bug.php?id=53341
279+
280+
Refs:
281+
* https://github.com/prisma/prisma/issues/22890
282+
""",
283+
)
284+
async def test_isolation_level(
285+
client: Prisma,
286+
database: str,
287+
raw_queries: RawQueries,
288+
input_level: str,
289+
expected_level_mapping: Dict[str, Optional[str]],
290+
) -> None:
291+
"""Ensure that transaction isolation level is set correctly"""
292+
async with client.tx(isolation_level=getattr(prisma.TransactionIsolationLevel, input_level)) as tx:
293+
results = await tx.query_raw(raw_queries.select_tx_isolation)
294+
295+
assert len(results) == 1
296+
297+
row = results[0]
298+
assert any(row)
299+
300+
level = next(iter(row.values()))
301+
assert level == expected_level_mapping[database]

databases/utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,8 @@ class RawQueries(BaseModel):
245254
SET title = 'updated title'
246255
WHERE id = 'sdldsd'
247256
""",
257+
select_tx_isolation="""
258+
Not avaliable
259+
""",
248260
),
249261
}

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)