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

Commit cca0e85

Browse files
feat(client): Add support for interactive transactions (#613)
## Change Summary closes #53 This PR also bumps the `typing-extensions` version for `NewType` support. TODO: - [x] tests for behaviour wrt rollback - [x] Check what `max_wait` does and document both options - [x] Check if we need to bump the `typing-extensions` dep - [x] Support for model based access - [x] Fix synchronous tests - [x] Docs (include timeout information) ## Checklist - [ ] Unit tests for the changes exist - [ ] Tests pass without significant drop in coverage - [ ] Documentation reflects changes where applicable - [ ] Test snapshots have been [updated](https://prisma-client-py.readthedocs.io/en/latest/contributing/contributing/#snapshot-tests) if applicable ## Agreement By submitting this pull request, I confirm that you can use, modify, copy and redistribute this contribution, under the terms of your choice. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 50f01ed commit cca0e85

33 files changed

+1311
-84
lines changed

.github/workflows/test.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ jobs:
112112
run: |
113113
nox -s typesafety-mypy
114114
115-
116115
lint:
117116
name: lint
118117
runs-on: ubuntu-latest

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
bootstrap:
33
pip install -U wheel
44
pip install -U -e .
5-
pip install -U -r pipelines/requirements/lint.txt
5+
pip install -U -r pipelines/requirements/all.txt
66
python -m prisma_cleanup
77
prisma db push --schema=tests/data/schema.prisma
88
cp tests/data/dev.db dev.db

databases/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def _fromdir(path: str) -> list[str]:
3838
unsupported_features={
3939
'json_arrays',
4040
'array_push',
41+
'transactions',
4142
},
4243
),
4344
'sqlite': DatabaseConfig(
@@ -102,6 +103,7 @@ def _fromdir(path: str) -> list[str]:
102103
'arrays': [*_fromdir('arrays'), *_fromdir('types/raw_queries/arrays')],
103104
'array_push': _fromdir('arrays/push'),
104105
'json_arrays': ['arrays/test_json.py', 'arrays/push/test_json.py'],
106+
'transactions': ['test_transactions.py'],
105107
# not yet implemented
106108
'date': [],
107109
'create_many': ['test_create_many.py'],

databases/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ distro
55

66
-r ../pipelines/requirements/deps/pyright.txt
77
-r ../pipelines/requirements/deps/pytest.txt
8+
-r ../pipelines/requirements/deps/pytest-mock.txt
89
-r ../pipelines/requirements/deps/pytest-asyncio.txt
910
-r ../pipelines/requirements/deps/syrupy.txt
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import time
2+
from typing import Optional
3+
4+
import pytest
5+
6+
import prisma
7+
from prisma import Prisma
8+
from prisma.models import User
9+
from ..utils import CURRENT_DATABASE
10+
11+
12+
def test_context_manager(client: Prisma) -> None:
13+
"""Basic usage within a context manager"""
14+
with client.tx(timeout=10 * 100) as transaction:
15+
user = transaction.user.create({'name': 'Robert'})
16+
assert user.name == 'Robert'
17+
18+
# ensure not commited outside transaction
19+
assert client.user.count() == 0
20+
21+
transaction.profile.create(
22+
{
23+
'description': 'Hello, there!',
24+
'country': 'Scotland',
25+
'user': {
26+
'connect': {
27+
'id': user.id,
28+
},
29+
},
30+
},
31+
)
32+
33+
found = client.user.find_unique(
34+
where={'id': user.id}, include={'profile': True}
35+
)
36+
assert found is not None
37+
assert found.name == 'Robert'
38+
assert found.profile is not None
39+
assert found.profile.description == 'Hello, there!'
40+
41+
42+
def test_context_manager_auto_rollback(client: Prisma) -> None:
43+
"""An error being thrown when within a context manager causes the transaction to be rolled back"""
44+
user: Optional[User] = None
45+
46+
with pytest.raises(RuntimeError) as exc:
47+
with client.tx() as tx:
48+
user = tx.user.create({'name': 'Tegan'})
49+
raise RuntimeError('Error ocurred mid transaction.')
50+
51+
assert exc.match('Error ocurred mid transaction.')
52+
53+
assert user is not None
54+
found = client.user.find_unique(where={'id': user.id})
55+
assert found is None
56+
57+
58+
def test_batch_within_transaction(client: Prisma) -> None:
59+
"""Query batching can be used within transactions"""
60+
with client.tx(timeout=10000) as transaction:
61+
with transaction.batch_() as batcher:
62+
batcher.user.create({'name': 'Tegan'})
63+
batcher.user.create({'name': 'Robert'})
64+
65+
assert client.user.count() == 0
66+
assert transaction.user.count() == 2
67+
68+
assert client.user.count() == 2
69+
70+
71+
def test_timeout(client: Prisma) -> None:
72+
"""A `TransactionExpiredError` is raised when the transaction times out."""
73+
# this outer block is necessary becuse to the context manager it appears that no error
74+
# ocurred so it will attempt to commit the transaction, triggering the expired error again
75+
with pytest.raises(prisma.errors.TransactionExpiredError):
76+
with client.tx(timeout=50) as transaction:
77+
time.sleep(0.05)
78+
79+
with pytest.raises(prisma.errors.TransactionExpiredError) as exc:
80+
transaction.user.create({'name': 'Robert'})
81+
82+
raise exc.value
83+
84+
85+
@pytest.mark.skipif(
86+
CURRENT_DATABASE == 'sqlite', reason='This is currently broken...'
87+
)
88+
def test_concurrent_transactions(client: Prisma) -> None:
89+
"""Two separate transactions can be used independently of each other at the same time"""
90+
timeout = 15000
91+
with client.tx(timeout=timeout) as tx1, client.tx(timeout=timeout) as tx2:
92+
user1 = tx1.user.create({'name': 'Tegan'})
93+
user2 = tx2.user.create({'name': 'Robert'})
94+
95+
assert tx1.user.find_first(where={'name': 'Robert'}) is None
96+
assert tx2.user.find_first(where={'name': 'Tegan'}) is None
97+
98+
found = tx1.user.find_first(where={'name': 'Tegan'})
99+
assert found is not None
100+
assert found.id == user1.id
101+
102+
found = tx2.user.find_first(where={'name': 'Robert'})
103+
assert found is not None
104+
assert found.id == user2.id
105+
106+
# ensure not leaked
107+
assert client.user.count() == 0
108+
assert (tx1.user.find_first(where={'name': user2.name})) is None
109+
assert (tx2.user.find_first(where={'name': user1.name})) is None
110+
111+
assert client.user.count() == 2
112+
113+
114+
def test_transaction_raises_original_error(client: Prisma) -> None:
115+
"""If an error is raised during the execution of the transaction, it is raised"""
116+
with pytest.raises(RuntimeError, match=r'Test error!'):
117+
with client.tx():
118+
raise RuntimeError('Test error!')
119+
120+
121+
def test_transaction_within_transaction_warning(client: Prisma) -> None:
122+
"""A warning is raised if a transaction is started from another transaction client"""
123+
tx1 = client.tx().start()
124+
with pytest.warns(UserWarning) as warnings:
125+
tx1.tx().start()
126+
127+
assert len(warnings) == 1
128+
record = warnings[0]
129+
assert not isinstance(record.message, str)
130+
assert (
131+
record.message.args[0]
132+
== 'The current client is already in a transaction. This can lead to surprising behaviour.'
133+
)
134+
assert record.filename == __file__
135+
136+
137+
def test_transaction_within_transaction_context_warning(
138+
client: Prisma,
139+
) -> None:
140+
"""A warning is raised if a transaction is started from another transaction client"""
141+
with client.tx() as tx1:
142+
with pytest.warns(UserWarning) as warnings:
143+
with tx1.tx():
144+
pass
145+
146+
assert len(warnings) == 1
147+
record = warnings[0]
148+
assert not isinstance(record.message, str)
149+
assert (
150+
record.message.args[0]
151+
== 'The current client is already in a transaction. This can lead to surprising behaviour.'
152+
)
153+
assert record.filename == __file__
154+
155+
156+
def test_transaction_not_started(client: Prisma) -> None:
157+
"""A `TransactionNotStartedError` is raised when attempting to call `commit()` or `rollback()`
158+
on a transaction that hasn't been started yet.
159+
"""
160+
tx = client.tx()
161+
162+
with pytest.raises(prisma.errors.TransactionNotStartedError):
163+
tx.commit()
164+
165+
with pytest.raises(prisma.errors.TransactionNotStartedError):
166+
tx.rollback()
167+
168+
169+
def test_transaction_already_closed(client: Prisma) -> None:
170+
"""Attempting to use a transaction outside of the context block raises an error"""
171+
with client.tx() as transaction:
172+
pass
173+
174+
with pytest.raises(prisma.errors.TransactionExpiredError) as exc:
175+
transaction.user.delete_many()
176+
177+
assert exc.match('Transaction already closed')

0 commit comments

Comments
 (0)