Transaction Management¶
Pico-SQLAlchemy provides a robust transaction management system inspired by enterprise frameworks (like Spring Data), but adapted for Python's asyncio ecosystem.
It supports Implicit Transactions (Zero-Boilerplate) and Explicit Transactions (Fine-grained control).
1. Implicit Transactions (Zero-Boilerplate)¶
To reduce code verbosity, pico-sqlalchemy applies transactional behavior automatically based on component types.
Repositories (@repository)¶
By default, any public async method within a class decorated with @repository runs inside a Read-Write transaction.
- Propagation:
REQUIRED(Joins existing or creates new). - Mode: Read-Write.
- Use Case: Standard CRUD operations (
save,update,delete).
@repository
class UserRepository:
# Implicitly transactional (Read-Write)
async def save(self, user: User):
...
Declarative Queries (@query)¶
Methods decorated with @query are automatically wrapped in a Read-Only transaction.
- Propagation:
REQUIRED(Joins existing or creates new). - Mode: Read-Only.
- Use Case: Fetching data efficiently.
@repository(entity=User)
class UserRepository:
# Implicitly transactional (Read-Only)
@query(expr="active = true")
async def find_active(self):
...
2. Configuration Priority¶
Since multiple rules can apply to a single method (e.g., a method in a repository that also has @transactional), the library follows a strict priority order (highest wins).
| Priority | Decorator | Default Behavior | Description |
|---|---|---|---|
| 1 (High) | @transactional | User Defined | Explicit configuration always overrides implicit rules. |
| 2 | @query | read_only=True | Specific query definition implies read-only intent. |
| 3 (Low) | @repository | read_only=False | General repository methods assume write capability by default. |
Examples¶
Scenario A: Overriding Repository Default You want a complex reporting method inside a repository to be Read-Only for performance, but it doesn't use @query.
@repository
class ReportRepository:
# Default is Read-Write...
# Override to Read-Only!
@transactional(read_only=True)
async def generate_complex_stats(self):
# ... logic ...
Scenario B: Overriding Query Default Rare case where a @query (usually read-only) involves a stored procedure that writes data.
@query(sql="CALL update_stats()")
@transactional(read_only=False) # Force Read-Write
async def update_statistics(self):
...
3. The @transactional Decorator¶
Use @transactional when you need to define boundaries in your Service Layer or when you need to override defaults in Repositories.
from pico_sqlalchemy import transactional
class UserService:
@transactional(propagation="REQUIRES_NEW")
async def create_user(self, name: str):
# ...
Configuration Options¶
| Parameter | Type | Default | Description |
|---|---|---|---|
propagation | str | "REQUIRED" | Defines behavior regarding existing transactions. |
read_only | bool | False | If True, avoids explicit commit (optimization). |
isolation_level | str | None | Sets DB isolation (e.g., "SERIALIZABLE"). |
rollback_for | tuple | (Exception,) | Exception types that trigger rollback. |
no_rollback_for | tuple | () | Exception types that ignore rollback. |
4. Propagation Levels¶
The library implements strict propagation logic:
REQUIRED(Default): Joins an existing transaction or creates a new one.REQUIRES_NEW: Suspends the current transaction (if any) and starts a fresh, independent transaction.SUPPORTS: Joins an existing transaction if available; otherwise, executes without a transaction context.MANDATORY: Requires an existing transaction; raisesRuntimeErrorotherwise.NEVER: Requires no active transaction; raisesRuntimeErrorif one exists.NOT_SUPPORTED: Suspends the current transaction and executes non-transactionally.
5. Rollback Rules¶
By default, any exception (inheriting from Exception) triggers a rollback().
Customizing Rollback¶
@transactional(
rollback_for=(MyCriticalError,),
no_rollback_for=(ValidationWarning,)
)
async def business_logic():
# ...
Note: If an exception matches no_rollback_for, the library skips the explicit rollback(), but the commit() is also skipped (the session closes naturally).
6. Accessing the Session¶
Within any transactional method (Implicit or Explicit), the AsyncSession is bound to the current context.
from pico_sqlalchemy import SessionManager, get_session
@component
class MyService:
def __init__(self, sm: SessionManager):
self.sm = sm
@transactional
async def do_work(self):
# Safely retrieve the current session
session = get_session(self.sm)
# ... use session ...
Auto-generated API¶
pico_sqlalchemy.interceptor ¶
AOP interceptor that manages transaction boundaries.
TransactionalInterceptor is the first link in the interceptor chain for every @transactional, @repository, and @query method. It inspects decorator metadata to determine the correct propagation mode and opens (or joins) a transaction via SessionManager.transaction() before delegating to the next interceptor or the original method body.
Priority resolution order (highest wins):
@transactionalmetadata (explicit, user-defined).@querymetadata (implicitread_only=True).@repositorymetadata (implicitread_only=False).
TransactionalInterceptor ¶
Bases: MethodInterceptor
Opens or joins a transaction for intercepted methods.
Registered as a @component and injected with a SessionManager. Applied to methods via @intercepted_by(TransactionalInterceptor) (which is done automatically by the @transactional, @repository, and @query decorators).
The interceptor determines the transaction configuration from decorator metadata in the following priority order:
@transactional(highest) -- user-defined propagation/read_only.@query--REQUIREDpropagation,read_only=True.@repository--REQUIREDpropagation,read_only=False.
If none of these markers are present, the method is invoked directly without opening a transaction.
In addition to the SQLAlchemy transaction, this interceptor binds pico-ioc's "transaction" DI scope to the same boundary: whenever a new transaction is started (REQUIRES_NEW, or REQUIRED with no enclosing transaction) it activates a fresh "transaction" scope for the duration of the call, so scope="transaction" components live exactly one transaction and are released (running their @cleanup hooks) when it ends. Joins reuse the enclosing scope.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_manager | SessionManager | The | required |
container | PicoContainer | The pico-ioc container, used to activate the | required |
Source code in src/pico_sqlalchemy/interceptor.py
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | |
invoke(ctx, call_next) async ¶
Intercept the method call and wrap it in a transaction if needed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ctx | MethodCtx | The AOP method context containing target class, method name, and arguments. | required |
call_next | Callable[[MethodCtx], Any] | Callback to invoke the next interceptor or the original method. | required |
Returns:
| Type | Description |
|---|---|
Any | The return value of the intercepted method. |