sqla-fancy-core 1.1.2__tar.gz → 1.2.0__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.

Files changed (22) hide show
  1. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/.gitignore +1 -0
  2. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/PKG-INFO +80 -3
  3. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/README.md +79 -2
  4. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/pyproject.toml +1 -1
  5. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/sqla_fancy_core/__init__.py +7 -1
  6. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/sqla_fancy_core/wrappers.py +188 -2
  7. sqla_fancy_core-1.2.0/tests/test_async_atomic.py +96 -0
  8. sqla_fancy_core-1.2.0/tests/test_atomic.py +92 -0
  9. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/uv.lock +1 -1
  10. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/.github/workflows/ci.yaml +0 -0
  11. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/LICENSE +0 -0
  12. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/sqla_fancy_core/decorators.py +0 -0
  13. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/sqla_fancy_core/factories.py +0 -0
  14. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/__init__.py +0 -0
  15. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/test_async_fancy_engine.py +0 -0
  16. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/test_connect.py +0 -0
  17. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/test_decorators.py +0 -0
  18. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/test_fancy_engine.py +0 -0
  19. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/test_field.py +0 -0
  20. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/test_table_factory.py +0 -0
  21. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/test_table_factory_async.py +0 -0
  22. {sqla_fancy_core-1.1.2 → sqla_fancy_core-1.2.0}/tests/test_transact.py +0 -0
@@ -158,3 +158,4 @@ cython_debug/
158
158
  # and can be added to the global gitignore or merged into this file. For a more nuclear
159
159
  # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
160
  #.idea/
161
+ .vscode/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqla-fancy-core
3
- Version: 1.1.2
3
+ Version: 1.2.0
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 two primary methods:
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 two primary methods:
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,6 +1,6 @@
1
1
  [project]
2
2
  name = 'sqla-fancy-core'
3
- version = '1.1.2'
3
+ version = '1.2.0'
4
4
  description = 'SQLAlchemy core, but fancier'
5
5
  readme = 'README.md'
6
6
  requires-python = ">=3.7"
@@ -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 FancyEngineWrapper, AsyncFancyEngineWrapper, fancy # noqa
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, create a new one.
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,95 @@ 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
+ with self.atomic() as connection:
210
+ return connection.execute(
211
+ statement, parameters, execution_options=execution_options
212
+ )
213
+
112
214
 
113
215
  class AsyncFancyEngineWrapper:
114
216
  """A wrapper around SQLAlchemy AsyncEngine with additional features."""
115
217
 
218
+ _ATOMIC_TX_CONN: ContextVar[Optional[AsyncConnection]] = ContextVar( # type: ignore
219
+ "fancy_global_transaction", default=None
220
+ )
221
+
116
222
  def __init__(self, engine: AsyncEngine) -> None:
117
223
  self.engine = engine
118
224
 
225
+ @asynccontextmanager
226
+ async def atomic(self):
227
+ """An async context manager that provides a transactional connection."""
228
+ global_txn_conn = self._ATOMIC_TX_CONN.get()
229
+ if global_txn_conn is not None:
230
+ yield global_txn_conn
231
+ else:
232
+ async with self.engine.begin() as connection:
233
+ token = self._ATOMIC_TX_CONN.set(connection)
234
+ try:
235
+ yield connection
236
+ finally:
237
+ self._ATOMIC_TX_CONN.reset(token)
238
+
239
+ @overload
240
+ async def ax(
241
+ self,
242
+ statement: TypedReturnsRows[_T],
243
+ parameters: Optional[_CoreAnyExecuteParams] = None,
244
+ *,
245
+ execution_options: Optional[CoreExecuteOptionsParameter] = None,
246
+ ) -> CursorResult[_T]: ...
247
+ @overload
248
+ async def ax(
249
+ self,
250
+ statement: Executable,
251
+ parameters: Optional[_CoreAnyExecuteParams] = None,
252
+ *,
253
+ execution_options: Optional[CoreExecuteOptionsParameter] = None,
254
+ ) -> CursorResult[Any]: ...
255
+ async def ax(
256
+ self,
257
+ statement: Executable,
258
+ parameters: Optional[_CoreAnyExecuteParams] = None,
259
+ *,
260
+ execution_options: Optional[CoreExecuteOptionsParameter] = None,
261
+ ) -> CursorResult[Any]:
262
+ """Execute the query within the atomic context and return the result.
263
+
264
+ It must be called within the `atomic` context manager. Else an error is raised.
265
+ """
266
+ connection = self._ATOMIC_TX_CONN.get()
267
+ if connection:
268
+ return await connection.execute(
269
+ statement, parameters, execution_options=execution_options
270
+ )
271
+ else:
272
+ raise AtomicContextError()
273
+
119
274
  @overload
120
275
  async def x(
121
276
  self,
@@ -184,8 +339,10 @@ class AsyncFancyEngineWrapper:
184
339
  ) -> CursorResult[Any]:
185
340
  """Execute the query within a transaction and return the result.
186
341
 
187
- If a connection is provided, use it; otherwise, create a new one.
342
+ If a connection is provided, use it; otherwise, use the global atomic
343
+ context or create a new one.
188
344
  """
345
+ connection = connection or self._ATOMIC_TX_CONN.get()
189
346
  if connection:
190
347
  if connection.in_transaction():
191
348
  return await connection.execute(
@@ -202,6 +359,35 @@ class AsyncFancyEngineWrapper:
202
359
  statement, parameters, execution_options=execution_options
203
360
  )
204
361
 
362
+ @overload
363
+ async def atx(
364
+ self,
365
+ statement: TypedReturnsRows[_T],
366
+ parameters: Optional[_CoreAnyExecuteParams] = None,
367
+ *,
368
+ execution_options: Optional[CoreExecuteOptionsParameter] = None,
369
+ ) -> CursorResult[_T]: ...
370
+ @overload
371
+ async def atx(
372
+ self,
373
+ statement: Executable,
374
+ parameters: Optional[_CoreAnyExecuteParams] = None,
375
+ *,
376
+ execution_options: Optional[CoreExecuteOptionsParameter] = None,
377
+ ) -> CursorResult[Any]: ...
378
+ async def atx(
379
+ self,
380
+ statement: Executable,
381
+ parameters: Optional[_CoreAnyExecuteParams] = None,
382
+ *,
383
+ execution_options: Optional[CoreExecuteOptionsParameter] = None,
384
+ ) -> CursorResult[Any]:
385
+ """If within an atomic context, execute the query there; else, create a new transaction."""
386
+ async with self.atomic() as connection:
387
+ return await connection.execute(
388
+ statement, parameters, execution_options=execution_options
389
+ )
390
+
205
391
 
206
392
  @overload
207
393
  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
@@ -2968,7 +2968,7 @@ wheels = [
2968
2968
 
2969
2969
  [[package]]
2970
2970
  name = "sqla-fancy-core"
2971
- version = "1.1.2"
2971
+ version = "1.2.0"
2972
2972
  source = { editable = "." }
2973
2973
  dependencies = [
2974
2974
  { name = "sqlalchemy" },
File without changes