sqla-fancy-core 1.1.2__tar.gz → 1.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of sqla-fancy-core might be problematic. Click here for more details.
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/.gitignore +1 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/PKG-INFO +80 -3
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/README.md +79 -2
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/pyproject.toml +1 -1
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/sqla_fancy_core/__init__.py +7 -1
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/sqla_fancy_core/wrappers.py +192 -2
- sqla_fancy_core-1.2.1/tests/test_async_atomic.py +96 -0
- sqla_fancy_core-1.2.1/tests/test_atomic.py +92 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/uv.lock +1 -1
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/.github/workflows/ci.yaml +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/LICENSE +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/sqla_fancy_core/decorators.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/sqla_fancy_core/factories.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/__init__.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/test_async_fancy_engine.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/test_connect.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/test_decorators.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/test_fancy_engine.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/test_field.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/test_table_factory.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/test_table_factory_async.py +0 -0
- {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.1}/tests/test_transact.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqla-fancy-core
|
|
3
|
-
Version: 1.1
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: SQLAlchemy core, but fancier
|
|
5
5
|
Project-URL: Homepage, https://github.com/sayanarijit/sqla-fancy-core
|
|
6
6
|
Author-email: Arijit Basu <sayanarijit@gmail.com>
|
|
@@ -158,13 +158,18 @@ async with engine.begin() as txn:
|
|
|
158
158
|
|
|
159
159
|
## Fancy Engine Wrappers
|
|
160
160
|
|
|
161
|
-
`sqla-fancy-core` provides `fancy` engine wrappers that simplify database interactions by automatically managing connections and transactions. The `fancy` function wraps a SQLAlchemy `Engine` or `AsyncEngine` and returns a wrapper object with
|
|
161
|
+
`sqla-fancy-core` provides `fancy` engine wrappers that simplify database interactions by automatically managing connections and transactions. The `fancy` function wraps a SQLAlchemy `Engine` or `AsyncEngine` and returns a wrapper object with the following methods:
|
|
162
162
|
|
|
163
163
|
- `x(conn, query)`: Executes a query. It uses the provided `conn` if available, otherwise it creates a new connection.
|
|
164
|
-
- `tx(conn, query)`: Executes a query within a transaction. It uses the provided `conn` if available, otherwise it creates a new connection and begins a transaction.
|
|
164
|
+
- `tx(conn, query)`: Executes a query within a transaction. It uses the provided `conn` if available, otherwise it tries to use the atomic context if within one, else creates a new connection and begins a transaction.
|
|
165
|
+
- `atomic()`: A context manager for grouping multiple operations in a single transaction scope.
|
|
166
|
+
- `ax(query)`: Executes a query using the connection from the active `atomic()` context. Raises `AtomicContextError` if called outside an `atomic()` block.
|
|
167
|
+
- `atx(query)`: Executes a query inside a transaction automatically. If already inside `atomic()`, it reuses the same connection and transaction; otherwise it opens a new transaction just for this call.
|
|
165
168
|
|
|
166
169
|
This is particularly useful for writing connection-agnostic query functions.
|
|
167
170
|
|
|
171
|
+
### Basic Examples
|
|
172
|
+
|
|
168
173
|
**Sync Example:**
|
|
169
174
|
|
|
170
175
|
```python
|
|
@@ -208,6 +213,78 @@ async def main():
|
|
|
208
213
|
assert await get_data(conn) == 1
|
|
209
214
|
```
|
|
210
215
|
|
|
216
|
+
### Using the atomic() Context Manager
|
|
217
|
+
|
|
218
|
+
The `atomic()` context manager lets you group several database operations within one transactional scope. Queries executed with `ax()` inside this context all use the same connection. Nested `atomic()` contexts reuse the outer connection automatically.
|
|
219
|
+
|
|
220
|
+
**Sync Example:**
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
import sqlalchemy as sa
|
|
224
|
+
from sqla_fancy_core import fancy, TableFactory
|
|
225
|
+
|
|
226
|
+
tf = TableFactory()
|
|
227
|
+
|
|
228
|
+
class User:
|
|
229
|
+
id = tf.auto_id()
|
|
230
|
+
name = tf.string("name")
|
|
231
|
+
Table = tf("users")
|
|
232
|
+
|
|
233
|
+
engine = sa.create_engine("sqlite:///:memory:")
|
|
234
|
+
tf.metadata.create_all(engine)
|
|
235
|
+
fancy_engine = fancy(engine)
|
|
236
|
+
|
|
237
|
+
# Group operations in one transaction
|
|
238
|
+
with fancy_engine.atomic():
|
|
239
|
+
fancy_engine.ax(sa.insert(User.Table).values(name="Alice"))
|
|
240
|
+
fancy_engine.ax(sa.insert(User.Table).values(name="Bob"))
|
|
241
|
+
result = fancy_engine.ax(sa.select(sa.func.count()).select_from(User.Table))
|
|
242
|
+
count = result.scalar_one()
|
|
243
|
+
assert count == 2
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Async Example:**
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
import sqlalchemy as sa
|
|
250
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
251
|
+
from sqla_fancy_core import fancy, TableFactory
|
|
252
|
+
|
|
253
|
+
tf = TableFactory()
|
|
254
|
+
|
|
255
|
+
class User:
|
|
256
|
+
id = tf.auto_id()
|
|
257
|
+
name = tf.string("name")
|
|
258
|
+
Table = tf("users")
|
|
259
|
+
|
|
260
|
+
async def run_example():
|
|
261
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
262
|
+
async with engine.begin() as conn:
|
|
263
|
+
await conn.run_sync(tf.metadata.create_all)
|
|
264
|
+
|
|
265
|
+
fancy_engine = fancy(engine)
|
|
266
|
+
|
|
267
|
+
async with fancy_engine.atomic():
|
|
268
|
+
await fancy_engine.ax(sa.insert(User.Table).values(name="Alice"))
|
|
269
|
+
await fancy_engine.ax(sa.insert(User.Table).values(name="Bob"))
|
|
270
|
+
result = await fancy_engine.ax(sa.select(sa.func.count()).select_from(User.Table))
|
|
271
|
+
count = result.scalar_one()
|
|
272
|
+
assert count == 2
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Key Points:**
|
|
276
|
+
|
|
277
|
+
- `ax()` must be called inside an `atomic()` context. Calling it elsewhere raises `AtomicContextError`.
|
|
278
|
+
- `atx()` is a safe/ergonomic helper: it will run inside the current `atomic()` transaction when present, or create a short-lived transaction otherwise.
|
|
279
|
+
- Nesting `atomic()` contexts is safe. Inner contexts share the outer connection instead of creating a new transaction.
|
|
280
|
+
- On normal exit, the transaction commits automatically. On exception, it rolls back.
|
|
281
|
+
|
|
282
|
+
### ax vs atx vs tx
|
|
283
|
+
|
|
284
|
+
- `ax(q)`: Only valid inside `atomic()`. Uses the ambient transactional connection. Great for batch operations grouped by an outer context.
|
|
285
|
+
- `atx(q)`: Fire-and-forget in a transaction. Reuses the ambient `atomic()` connection if present; otherwise starts and commits its own transaction.
|
|
286
|
+
- `tx(conn, q)`: Low-level primitive. If `conn` is provided, it executes within it, creating a transaction when needed; if `None`, it prefers the `atomic()` connection when active or opens a new transactional connection.
|
|
287
|
+
|
|
211
288
|
## Decorators: Inject, connect, transact
|
|
212
289
|
|
|
213
290
|
When writing plain SQLAlchemy Core code, you often pass connections around and manage transactions manually. The decorators in `sqla-fancy-core` help you keep functions connection-agnostic and composable, while remaining explicit and safe.
|
|
@@ -106,13 +106,18 @@ async with engine.begin() as txn:
|
|
|
106
106
|
|
|
107
107
|
## Fancy Engine Wrappers
|
|
108
108
|
|
|
109
|
-
`sqla-fancy-core` provides `fancy` engine wrappers that simplify database interactions by automatically managing connections and transactions. The `fancy` function wraps a SQLAlchemy `Engine` or `AsyncEngine` and returns a wrapper object with
|
|
109
|
+
`sqla-fancy-core` provides `fancy` engine wrappers that simplify database interactions by automatically managing connections and transactions. The `fancy` function wraps a SQLAlchemy `Engine` or `AsyncEngine` and returns a wrapper object with the following methods:
|
|
110
110
|
|
|
111
111
|
- `x(conn, query)`: Executes a query. It uses the provided `conn` if available, otherwise it creates a new connection.
|
|
112
|
-
- `tx(conn, query)`: Executes a query within a transaction. It uses the provided `conn` if available, otherwise it creates a new connection and begins a transaction.
|
|
112
|
+
- `tx(conn, query)`: Executes a query within a transaction. It uses the provided `conn` if available, otherwise it tries to use the atomic context if within one, else creates a new connection and begins a transaction.
|
|
113
|
+
- `atomic()`: A context manager for grouping multiple operations in a single transaction scope.
|
|
114
|
+
- `ax(query)`: Executes a query using the connection from the active `atomic()` context. Raises `AtomicContextError` if called outside an `atomic()` block.
|
|
115
|
+
- `atx(query)`: Executes a query inside a transaction automatically. If already inside `atomic()`, it reuses the same connection and transaction; otherwise it opens a new transaction just for this call.
|
|
113
116
|
|
|
114
117
|
This is particularly useful for writing connection-agnostic query functions.
|
|
115
118
|
|
|
119
|
+
### Basic Examples
|
|
120
|
+
|
|
116
121
|
**Sync Example:**
|
|
117
122
|
|
|
118
123
|
```python
|
|
@@ -156,6 +161,78 @@ async def main():
|
|
|
156
161
|
assert await get_data(conn) == 1
|
|
157
162
|
```
|
|
158
163
|
|
|
164
|
+
### Using the atomic() Context Manager
|
|
165
|
+
|
|
166
|
+
The `atomic()` context manager lets you group several database operations within one transactional scope. Queries executed with `ax()` inside this context all use the same connection. Nested `atomic()` contexts reuse the outer connection automatically.
|
|
167
|
+
|
|
168
|
+
**Sync Example:**
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
import sqlalchemy as sa
|
|
172
|
+
from sqla_fancy_core import fancy, TableFactory
|
|
173
|
+
|
|
174
|
+
tf = TableFactory()
|
|
175
|
+
|
|
176
|
+
class User:
|
|
177
|
+
id = tf.auto_id()
|
|
178
|
+
name = tf.string("name")
|
|
179
|
+
Table = tf("users")
|
|
180
|
+
|
|
181
|
+
engine = sa.create_engine("sqlite:///:memory:")
|
|
182
|
+
tf.metadata.create_all(engine)
|
|
183
|
+
fancy_engine = fancy(engine)
|
|
184
|
+
|
|
185
|
+
# Group operations in one transaction
|
|
186
|
+
with fancy_engine.atomic():
|
|
187
|
+
fancy_engine.ax(sa.insert(User.Table).values(name="Alice"))
|
|
188
|
+
fancy_engine.ax(sa.insert(User.Table).values(name="Bob"))
|
|
189
|
+
result = fancy_engine.ax(sa.select(sa.func.count()).select_from(User.Table))
|
|
190
|
+
count = result.scalar_one()
|
|
191
|
+
assert count == 2
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Async Example:**
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
import sqlalchemy as sa
|
|
198
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
199
|
+
from sqla_fancy_core import fancy, TableFactory
|
|
200
|
+
|
|
201
|
+
tf = TableFactory()
|
|
202
|
+
|
|
203
|
+
class User:
|
|
204
|
+
id = tf.auto_id()
|
|
205
|
+
name = tf.string("name")
|
|
206
|
+
Table = tf("users")
|
|
207
|
+
|
|
208
|
+
async def run_example():
|
|
209
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
210
|
+
async with engine.begin() as conn:
|
|
211
|
+
await conn.run_sync(tf.metadata.create_all)
|
|
212
|
+
|
|
213
|
+
fancy_engine = fancy(engine)
|
|
214
|
+
|
|
215
|
+
async with fancy_engine.atomic():
|
|
216
|
+
await fancy_engine.ax(sa.insert(User.Table).values(name="Alice"))
|
|
217
|
+
await fancy_engine.ax(sa.insert(User.Table).values(name="Bob"))
|
|
218
|
+
result = await fancy_engine.ax(sa.select(sa.func.count()).select_from(User.Table))
|
|
219
|
+
count = result.scalar_one()
|
|
220
|
+
assert count == 2
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Key Points:**
|
|
224
|
+
|
|
225
|
+
- `ax()` must be called inside an `atomic()` context. Calling it elsewhere raises `AtomicContextError`.
|
|
226
|
+
- `atx()` is a safe/ergonomic helper: it will run inside the current `atomic()` transaction when present, or create a short-lived transaction otherwise.
|
|
227
|
+
- Nesting `atomic()` contexts is safe. Inner contexts share the outer connection instead of creating a new transaction.
|
|
228
|
+
- On normal exit, the transaction commits automatically. On exception, it rolls back.
|
|
229
|
+
|
|
230
|
+
### ax vs atx vs tx
|
|
231
|
+
|
|
232
|
+
- `ax(q)`: Only valid inside `atomic()`. Uses the ambient transactional connection. Great for batch operations grouped by an outer context.
|
|
233
|
+
- `atx(q)`: Fire-and-forget in a transaction. Reuses the ambient `atomic()` connection if present; otherwise starts and commits its own transaction.
|
|
234
|
+
- `tx(conn, q)`: Low-level primitive. If `conn` is provided, it executes within it, creating a transaction when needed; if `None`, it prefers the `atomic()` connection when active or opens a new transactional connection.
|
|
235
|
+
|
|
159
236
|
## Decorators: Inject, connect, transact
|
|
160
237
|
|
|
161
238
|
When writing plain SQLAlchemy Core code, you often pass connections around and manage transactions manually. The decorators in `sqla-fancy-core` help you keep functions connection-agnostic and composable, while remaining explicit and safe.
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
"""SQLAlchemy core, but fancier."""
|
|
2
2
|
|
|
3
3
|
from sqla_fancy_core.factories import TableFactory # noqa
|
|
4
|
-
from sqla_fancy_core.wrappers import
|
|
4
|
+
from sqla_fancy_core.wrappers import ( # noqa
|
|
5
|
+
FancyEngineWrapper,
|
|
6
|
+
AsyncFancyEngineWrapper,
|
|
7
|
+
fancy,
|
|
8
|
+
FancyError,
|
|
9
|
+
AtomicContextError,
|
|
10
|
+
)
|
|
5
11
|
from sqla_fancy_core.decorators import transact, Inject # noqa
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Some wrappers for fun times with SQLAlchemy core."""
|
|
2
2
|
|
|
3
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
4
|
+
from contextvars import ContextVar
|
|
3
5
|
from typing import Any, Optional, TypeVar, overload
|
|
4
6
|
|
|
5
7
|
from sqlalchemy import Connection, CursorResult, Engine, Executable
|
|
@@ -16,12 +18,80 @@ from sqlalchemy.sql.selectable import TypedReturnsRows
|
|
|
16
18
|
_T = TypeVar("_T", bound=Any)
|
|
17
19
|
|
|
18
20
|
|
|
21
|
+
class FancyError(Exception):
|
|
22
|
+
"""Custom error for FancyEngineWrapper."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AtomicContextError(FancyError):
|
|
28
|
+
"""Error raised when ax() is called outside of an atomic context."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
super().__init__("ax() must be called within the atomic() context manager")
|
|
32
|
+
|
|
33
|
+
|
|
19
34
|
class FancyEngineWrapper:
|
|
20
35
|
"""A wrapper around SQLAlchemy Engine with additional features."""
|
|
21
36
|
|
|
37
|
+
_ATOMIC_TX_CONN: ContextVar[Optional[Connection]] = ContextVar( # type: ignore
|
|
38
|
+
"fancy_global_transaction", default=None
|
|
39
|
+
)
|
|
40
|
+
|
|
22
41
|
def __init__(self, engine: Engine) -> None:
|
|
23
42
|
self.engine = engine
|
|
24
43
|
|
|
44
|
+
@contextmanager
|
|
45
|
+
def atomic(self):
|
|
46
|
+
"""A context manager that provides a transactional connection."""
|
|
47
|
+
global_txn_conn = self._ATOMIC_TX_CONN.get()
|
|
48
|
+
if global_txn_conn is not None:
|
|
49
|
+
# Reuse existing transaction connection
|
|
50
|
+
yield global_txn_conn
|
|
51
|
+
else:
|
|
52
|
+
with self.engine.begin() as connection:
|
|
53
|
+
token = self._ATOMIC_TX_CONN.set(connection)
|
|
54
|
+
try:
|
|
55
|
+
yield connection
|
|
56
|
+
finally:
|
|
57
|
+
# Restore previous ContextVar state
|
|
58
|
+
self._ATOMIC_TX_CONN.reset(token)
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
def ax(
|
|
62
|
+
self,
|
|
63
|
+
statement: TypedReturnsRows[_T],
|
|
64
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
65
|
+
*,
|
|
66
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
67
|
+
) -> CursorResult[_T]: ...
|
|
68
|
+
@overload
|
|
69
|
+
def ax(
|
|
70
|
+
self,
|
|
71
|
+
statement: Executable,
|
|
72
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
73
|
+
*,
|
|
74
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
75
|
+
) -> CursorResult[Any]: ...
|
|
76
|
+
def ax(
|
|
77
|
+
self,
|
|
78
|
+
statement: Executable,
|
|
79
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
80
|
+
*,
|
|
81
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
82
|
+
) -> CursorResult[Any]:
|
|
83
|
+
"""Execute the query within the atomic context and return the result.
|
|
84
|
+
|
|
85
|
+
It must be called within the `atomic` context manager. Else an error is raised.
|
|
86
|
+
"""
|
|
87
|
+
connection = self._ATOMIC_TX_CONN.get()
|
|
88
|
+
if connection:
|
|
89
|
+
return connection.execute(
|
|
90
|
+
statement, parameters, execution_options=execution_options
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
raise AtomicContextError()
|
|
94
|
+
|
|
25
95
|
@overload
|
|
26
96
|
def x(
|
|
27
97
|
self,
|
|
@@ -52,6 +122,7 @@ class FancyEngineWrapper:
|
|
|
52
122
|
|
|
53
123
|
If a connection is provided, use it; otherwise, create a new one.
|
|
54
124
|
"""
|
|
125
|
+
connection = connection
|
|
55
126
|
if connection:
|
|
56
127
|
return connection.execute(
|
|
57
128
|
statement, parameters, execution_options=execution_options
|
|
@@ -90,8 +161,10 @@ class FancyEngineWrapper:
|
|
|
90
161
|
) -> CursorResult[Any]:
|
|
91
162
|
"""Begin a transaction, execute the query, and return the result.
|
|
92
163
|
|
|
93
|
-
If a connection is provided, use it; otherwise,
|
|
164
|
+
If a connection is provided, use it; otherwise, use the global atomic
|
|
165
|
+
context or create a new one.
|
|
94
166
|
"""
|
|
167
|
+
connection = connection or self._ATOMIC_TX_CONN.get()
|
|
95
168
|
if connection:
|
|
96
169
|
if connection.in_transaction():
|
|
97
170
|
# Transaction is already active
|
|
@@ -109,13 +182,97 @@ class FancyEngineWrapper:
|
|
|
109
182
|
statement, parameters, execution_options=execution_options
|
|
110
183
|
)
|
|
111
184
|
|
|
185
|
+
@overload
|
|
186
|
+
def atx(
|
|
187
|
+
self,
|
|
188
|
+
statement: TypedReturnsRows[_T],
|
|
189
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
190
|
+
*,
|
|
191
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
192
|
+
) -> CursorResult[_T]: ...
|
|
193
|
+
@overload
|
|
194
|
+
def atx(
|
|
195
|
+
self,
|
|
196
|
+
statement: Executable,
|
|
197
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
198
|
+
*,
|
|
199
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
200
|
+
) -> CursorResult[Any]: ...
|
|
201
|
+
def atx(
|
|
202
|
+
self,
|
|
203
|
+
statement: Executable,
|
|
204
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
205
|
+
*,
|
|
206
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
207
|
+
) -> CursorResult[Any]:
|
|
208
|
+
"""If within an atomic context, execute the query there; else, create a new transaction."""
|
|
209
|
+
return self.tx(
|
|
210
|
+
self._ATOMIC_TX_CONN.get(),
|
|
211
|
+
statement,
|
|
212
|
+
parameters,
|
|
213
|
+
execution_options=execution_options,
|
|
214
|
+
)
|
|
215
|
+
|
|
112
216
|
|
|
113
217
|
class AsyncFancyEngineWrapper:
|
|
114
218
|
"""A wrapper around SQLAlchemy AsyncEngine with additional features."""
|
|
115
219
|
|
|
220
|
+
_ATOMIC_TX_CONN: ContextVar[Optional[AsyncConnection]] = ContextVar( # type: ignore
|
|
221
|
+
"fancy_global_transaction", default=None
|
|
222
|
+
)
|
|
223
|
+
|
|
116
224
|
def __init__(self, engine: AsyncEngine) -> None:
|
|
117
225
|
self.engine = engine
|
|
118
226
|
|
|
227
|
+
@asynccontextmanager
|
|
228
|
+
async def atomic(self):
|
|
229
|
+
"""An async context manager that provides a transactional connection."""
|
|
230
|
+
global_txn_conn = self._ATOMIC_TX_CONN.get()
|
|
231
|
+
if global_txn_conn is not None:
|
|
232
|
+
yield global_txn_conn
|
|
233
|
+
else:
|
|
234
|
+
async with self.engine.begin() as connection:
|
|
235
|
+
token = self._ATOMIC_TX_CONN.set(connection)
|
|
236
|
+
try:
|
|
237
|
+
yield connection
|
|
238
|
+
finally:
|
|
239
|
+
self._ATOMIC_TX_CONN.reset(token)
|
|
240
|
+
|
|
241
|
+
@overload
|
|
242
|
+
async def ax(
|
|
243
|
+
self,
|
|
244
|
+
statement: TypedReturnsRows[_T],
|
|
245
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
246
|
+
*,
|
|
247
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
248
|
+
) -> CursorResult[_T]: ...
|
|
249
|
+
@overload
|
|
250
|
+
async def ax(
|
|
251
|
+
self,
|
|
252
|
+
statement: Executable,
|
|
253
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
254
|
+
*,
|
|
255
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
256
|
+
) -> CursorResult[Any]: ...
|
|
257
|
+
async def ax(
|
|
258
|
+
self,
|
|
259
|
+
statement: Executable,
|
|
260
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
261
|
+
*,
|
|
262
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
263
|
+
) -> CursorResult[Any]:
|
|
264
|
+
"""Execute the query within the atomic context and return the result.
|
|
265
|
+
|
|
266
|
+
It must be called within the `atomic` context manager. Else an error is raised.
|
|
267
|
+
"""
|
|
268
|
+
connection = self._ATOMIC_TX_CONN.get()
|
|
269
|
+
if connection:
|
|
270
|
+
return await connection.execute(
|
|
271
|
+
statement, parameters, execution_options=execution_options
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
raise AtomicContextError()
|
|
275
|
+
|
|
119
276
|
@overload
|
|
120
277
|
async def x(
|
|
121
278
|
self,
|
|
@@ -184,8 +341,10 @@ class AsyncFancyEngineWrapper:
|
|
|
184
341
|
) -> CursorResult[Any]:
|
|
185
342
|
"""Execute the query within a transaction and return the result.
|
|
186
343
|
|
|
187
|
-
If a connection is provided, use it; otherwise,
|
|
344
|
+
If a connection is provided, use it; otherwise, use the global atomic
|
|
345
|
+
context or create a new one.
|
|
188
346
|
"""
|
|
347
|
+
connection = connection or self._ATOMIC_TX_CONN.get()
|
|
189
348
|
if connection:
|
|
190
349
|
if connection.in_transaction():
|
|
191
350
|
return await connection.execute(
|
|
@@ -202,6 +361,37 @@ class AsyncFancyEngineWrapper:
|
|
|
202
361
|
statement, parameters, execution_options=execution_options
|
|
203
362
|
)
|
|
204
363
|
|
|
364
|
+
@overload
|
|
365
|
+
async def atx(
|
|
366
|
+
self,
|
|
367
|
+
statement: TypedReturnsRows[_T],
|
|
368
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
369
|
+
*,
|
|
370
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
371
|
+
) -> CursorResult[_T]: ...
|
|
372
|
+
@overload
|
|
373
|
+
async def atx(
|
|
374
|
+
self,
|
|
375
|
+
statement: Executable,
|
|
376
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
377
|
+
*,
|
|
378
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
379
|
+
) -> CursorResult[Any]: ...
|
|
380
|
+
async def atx(
|
|
381
|
+
self,
|
|
382
|
+
statement: Executable,
|
|
383
|
+
parameters: Optional[_CoreAnyExecuteParams] = None,
|
|
384
|
+
*,
|
|
385
|
+
execution_options: Optional[CoreExecuteOptionsParameter] = None,
|
|
386
|
+
) -> CursorResult[Any]:
|
|
387
|
+
"""If within an atomic context, execute the query there; else, create a new transaction."""
|
|
388
|
+
return await self.tx(
|
|
389
|
+
self._ATOMIC_TX_CONN.get(),
|
|
390
|
+
statement,
|
|
391
|
+
parameters,
|
|
392
|
+
execution_options=execution_options,
|
|
393
|
+
)
|
|
394
|
+
|
|
205
395
|
|
|
206
396
|
@overload
|
|
207
397
|
def fancy(obj: Engine, /) -> FancyEngineWrapper: ...
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import pytest_asyncio
|
|
3
|
+
import sqlalchemy as sa
|
|
4
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
5
|
+
|
|
6
|
+
from sqla_fancy_core import TableFactory, fancy
|
|
7
|
+
from sqla_fancy_core.wrappers import AtomicContextError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
tf = TableFactory()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Counter:
|
|
14
|
+
id = tf.auto_id()
|
|
15
|
+
Table = tf("counter")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
q_insert = sa.insert(Counter.Table)
|
|
19
|
+
q_count = sa.select(sa.func.count()).select_from(Counter.Table)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest_asyncio.fixture
|
|
23
|
+
async def fancy_engine():
|
|
24
|
+
eng = fancy(create_async_engine("sqlite+aiosqlite:///:memory:"))
|
|
25
|
+
async with eng.engine.begin() as conn:
|
|
26
|
+
await conn.run_sync(tf.metadata.create_all)
|
|
27
|
+
try:
|
|
28
|
+
yield eng
|
|
29
|
+
finally:
|
|
30
|
+
async with eng.engine.begin() as conn:
|
|
31
|
+
await conn.run_sync(tf.metadata.drop_all)
|
|
32
|
+
await eng.engine.dispose()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_ax_raises_outside_atomic(fancy_engine):
|
|
37
|
+
with pytest.raises(AtomicContextError):
|
|
38
|
+
await fancy_engine.ax(q_count)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_ax_inside_atomic_commits_on_exit(fancy_engine):
|
|
43
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 0
|
|
44
|
+
async with fancy_engine.atomic() as conn:
|
|
45
|
+
await fancy_engine.ax(q_insert)
|
|
46
|
+
await fancy_engine.ax(q_insert)
|
|
47
|
+
assert (await fancy_engine.ax(q_count)).scalar_one() == 2
|
|
48
|
+
assert conn.in_transaction() is True
|
|
49
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 2
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_nested_atomic_reuses_same_connection(fancy_engine):
|
|
54
|
+
async with fancy_engine.atomic() as conn1:
|
|
55
|
+
async with fancy_engine.atomic() as conn2:
|
|
56
|
+
assert conn1 is conn2
|
|
57
|
+
await fancy_engine.ax(q_insert)
|
|
58
|
+
assert (await fancy_engine.ax(q_count)).scalar_one() == 1
|
|
59
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.asyncio
|
|
63
|
+
async def test_tx_uses_atomic_connection_when_inside(fancy_engine):
|
|
64
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 0
|
|
65
|
+
async with fancy_engine.atomic() as conn:
|
|
66
|
+
await fancy_engine.tx(None, q_insert)
|
|
67
|
+
assert (await fancy_engine.tx(conn, q_count)).scalar_one() == 1
|
|
68
|
+
assert conn.in_transaction() is True
|
|
69
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_atomic_rollback_on_exception(fancy_engine):
|
|
74
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 0
|
|
75
|
+
with pytest.raises(RuntimeError):
|
|
76
|
+
async with fancy_engine.atomic():
|
|
77
|
+
await fancy_engine.ax(q_insert)
|
|
78
|
+
assert (await fancy_engine.ax(q_count)).scalar_one() == 1
|
|
79
|
+
raise RuntimeError("boom")
|
|
80
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_atx_outside_atomic_commits(fancy_engine):
|
|
85
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 0
|
|
86
|
+
await fancy_engine.atx(q_insert)
|
|
87
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_atx_inside_atomic_reuses_same_connection(fancy_engine):
|
|
92
|
+
async with fancy_engine.atomic() as conn:
|
|
93
|
+
await fancy_engine.atx(q_insert)
|
|
94
|
+
assert (await fancy_engine.ax(q_count)).scalar_one() == 1
|
|
95
|
+
assert conn.in_transaction() is True
|
|
96
|
+
assert (await fancy_engine.x(None, q_count)).scalar_one() == 1
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import sqlalchemy as sa
|
|
3
|
+
|
|
4
|
+
from sqla_fancy_core import TableFactory, fancy
|
|
5
|
+
from sqla_fancy_core.wrappers import AtomicContextError
|
|
6
|
+
|
|
7
|
+
tf = TableFactory()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Counter:
|
|
11
|
+
id = tf.auto_id()
|
|
12
|
+
Table = tf("counter")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
q_insert = sa.insert(Counter.Table)
|
|
16
|
+
q_count = sa.select(sa.func.count()).select_from(Counter.Table)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def fancy_engine():
|
|
21
|
+
eng = fancy(sa.create_engine("sqlite:///:memory:"))
|
|
22
|
+
tf.metadata.create_all(eng.engine)
|
|
23
|
+
try:
|
|
24
|
+
yield eng
|
|
25
|
+
finally:
|
|
26
|
+
tf.metadata.drop_all(eng.engine)
|
|
27
|
+
eng.engine.dispose()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_ax_raises_outside_atomic(fancy_engine):
|
|
31
|
+
with pytest.raises(AtomicContextError):
|
|
32
|
+
fancy_engine.ax(q_count)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_ax_inside_atomic_commits_on_exit(fancy_engine):
|
|
36
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 0
|
|
37
|
+
with fancy_engine.atomic() as conn:
|
|
38
|
+
# multiple ax() calls share the same connection
|
|
39
|
+
fancy_engine.ax(q_insert)
|
|
40
|
+
fancy_engine.ax(q_insert)
|
|
41
|
+
# visibility within the same transaction
|
|
42
|
+
assert fancy_engine.ax(q_count).scalar_one() == 2
|
|
43
|
+
assert conn.in_transaction() is True
|
|
44
|
+
# committed after context exit
|
|
45
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 2
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_nested_atomic_reuses_same_connection(fancy_engine):
|
|
49
|
+
with fancy_engine.atomic() as conn1:
|
|
50
|
+
with fancy_engine.atomic() as conn2:
|
|
51
|
+
assert conn1 is conn2
|
|
52
|
+
fancy_engine.ax(q_insert)
|
|
53
|
+
assert fancy_engine.ax(q_count).scalar_one() == 1
|
|
54
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_tx_uses_atomic_connection_when_inside(fancy_engine):
|
|
58
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 0
|
|
59
|
+
with fancy_engine.atomic() as conn:
|
|
60
|
+
# tx(None, ...) should reuse the atomic connection/transaction
|
|
61
|
+
fancy_engine.tx(None, q_insert)
|
|
62
|
+
# The outer connection should see the uncommitted row
|
|
63
|
+
assert fancy_engine.tx(conn, q_count).scalar_one() == 1
|
|
64
|
+
assert conn.in_transaction() is True
|
|
65
|
+
# committed at outer context exit
|
|
66
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_atomic_rollback_on_exception(fancy_engine):
|
|
70
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 0
|
|
71
|
+
with pytest.raises(RuntimeError):
|
|
72
|
+
with fancy_engine.atomic():
|
|
73
|
+
fancy_engine.ax(q_insert)
|
|
74
|
+
assert fancy_engine.ax(q_count).scalar_one() == 1
|
|
75
|
+
raise RuntimeError("boom")
|
|
76
|
+
# rolled back
|
|
77
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_atx_outside_atomic_commits(fancy_engine):
|
|
81
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 0
|
|
82
|
+
fancy_engine.atx(q_insert)
|
|
83
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_atx_inside_atomic_reuses_same_connection(fancy_engine):
|
|
87
|
+
with fancy_engine.atomic() as conn:
|
|
88
|
+
fancy_engine.atx(q_insert)
|
|
89
|
+
# Same transactional visibility within the atomic connection
|
|
90
|
+
assert fancy_engine.ax(q_count).scalar_one() == 1
|
|
91
|
+
assert conn.in_transaction() is True
|
|
92
|
+
assert fancy_engine.x(None, q_count).scalar_one() == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|