template updates
This commit is contained in:
@@ -2,8 +2,11 @@
|
||||
|
||||
!!! info "Primary sources"
|
||||
- [Python `asynccontextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager)
|
||||
- [Python `functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache)
|
||||
- [Python `inspect.signature`](https://docs.python.org/3/library/inspect.html#inspect.signature)
|
||||
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||
- [SQLAlchemy session basics](https://docs.sqlalchemy.org/en/21/orm/session_basics.html)
|
||||
- [`nicegui-db` session implementation](https://forgejo.john-stream.com/john/nicegui-db/src/commit/126bc26ad8635a86bacf684d7bda409230347597/src/nicegui_db/db/session.py)
|
||||
|
||||
---
|
||||
|
||||
@@ -11,8 +14,9 @@
|
||||
|
||||
Define one canonical session model for SQLAlchemy asyncio:
|
||||
|
||||
- configure one shared session factory,
|
||||
- configure a lifespan-owned factory or resolve a URL-keyed cached factory,
|
||||
- create one AsyncSession per task or unit of work,
|
||||
- let callers supply a session when they already own the scope,
|
||||
- never share one AsyncSession across concurrent tasks.
|
||||
|
||||
---
|
||||
@@ -26,17 +30,16 @@ Define one canonical session model for SQLAlchemy asyncio:
|
||||
|
||||
## Rules
|
||||
|
||||
- Create one `async_sessionmaker` inside each app-owned engine scope.
|
||||
- Resolve the configured `async_sessionmaker` at the application composition boundary and inject it where standalone operations begin.
|
||||
- Create the application `async_sessionmaker` inside `database_scope()` and store it in application state for request dependencies.
|
||||
- Use `get_session_factory(db_url)` and `resolve_session_factory()` for standalone decorated operations that do not receive the application factory.
|
||||
- Use a fresh AsyncSession for each task or explicit unit of work.
|
||||
- Pass an `AsyncSession` directly to data-access functions.
|
||||
- Require lower-level data-access functions to receive an `AsyncSession`; they must not create sessions or control transactions.
|
||||
- Treat a supplied session as an explicit declaration that the caller owns an active transaction.
|
||||
- Let reusable service functions accept `AsyncSession | None` and apply `@with_session` when standalone invocation is useful.
|
||||
- Pass an `AsyncSession` directly when composing several calls under one caller-owned scope.
|
||||
- Borrow a caller-provided session without beginning, closing, committing, or rolling it back.
|
||||
- Do not share AsyncSession across `asyncio.gather()` or parallel tasks.
|
||||
- Prefer direct dependency injection over global scoped-session patterns in new code.
|
||||
- Use explicit transaction boundaries (`async with session.begin():`) for writes.
|
||||
- When a complete operation accepts an optional session, borrow the caller's active transaction or own the complete session-and-transaction scope.
|
||||
- Use `db_transaction_scope()` when a standalone operation must own engine, factory, session, and transaction lifetimes together.
|
||||
- Use `begin_nested()` directly and only when partial rollback through a database SAVEPOINT is required.
|
||||
|
||||
---
|
||||
@@ -77,25 +80,68 @@ For most read-only operations, a session context is sufficient. Use an explicit
|
||||
|
||||
An `async_sessionmaker[AsyncSession]` is a reusable configuration object and callable session producer. It stores how sessions should be created, including the engine binding and options such as `expire_on_commit=False`. It is not itself a session, connection, or transaction, and calling it does not make a shared global `AsyncSession`.
|
||||
|
||||
Create it once from the application-owned engine and inject it into application services and dependencies:
|
||||
The template exposes two construction paths with the same session options.
|
||||
|
||||
The application-owned path creates a factory inside the engine lifecycle:
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .engine import engine_scope
|
||||
|
||||
type SessionFactory = async_sessionmaker[AsyncSession]
|
||||
|
||||
|
||||
def create_session_factory(engine: AsyncEngine) -> SessionFactory:
|
||||
return async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
@asynccontextmanager
|
||||
async def database_scope(
|
||||
db_url: str,
|
||||
*,
|
||||
auto_flush: bool = True,
|
||||
) -> AsyncGenerator[SessionFactory]:
|
||||
async with engine_scope(db_url) as engine:
|
||||
yield async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=auto_flush,
|
||||
)
|
||||
```
|
||||
|
||||
The maker is cheap configuration and has no independent connection pool or async cleanup method. Its bound engine owns the pool, so construct the maker inside that engine's lifecycle and do not retain it after the engine scope exits. A global cache adds no value when the composition root creates both resources once.
|
||||
FastAPI lifespan enters this path once and stores the yielded factory on application state. The factory must not outlive the scope because its bound engine is disposed on exit.
|
||||
|
||||
The standalone path caches a factory by URL and `auto_flush` policy:
|
||||
|
||||
```python
|
||||
from functools import cache
|
||||
|
||||
from .engine import get_engine
|
||||
|
||||
|
||||
@cache
|
||||
def get_session_factory(
|
||||
db_url: str,
|
||||
*,
|
||||
auto_flush: bool = True,
|
||||
) -> SessionFactory:
|
||||
return async_sessionmaker(
|
||||
bind=get_engine(db_url),
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=auto_flush,
|
||||
)
|
||||
|
||||
|
||||
def resolve_session_factory(settings: Settings | None = None) -> SessionFactory:
|
||||
settings = settings or get_settings()
|
||||
db_url = get_database_url(settings)
|
||||
return get_session_factory(db_url)
|
||||
```
|
||||
|
||||
This path lets framework-independent helpers resolve one stable factory without receiving it through every call. The tradeoff is hidden configuration resolution and a second lifecycle mechanism. `dispose_engine()` clears `get_engine`'s cache but does not clear `get_session_factory`'s cache in the template. A cached factory remains bound to the disposed engine object; SQLAlchemy can create a new pool when that engine is used again, but a later `database_scope()` for the same URL can own a different engine. Treat cached standalone resolution as process-lifetime convenience, avoid repeated application lifecycles in one process, and clear both caches together if the template evolves to support them.
|
||||
|
||||
Each call to `session_factory()` creates a distinct `AsyncSession`. The caller that invokes the factory owns that session lifetime and must close it, normally with `async with`:
|
||||
|
||||
@@ -106,145 +152,140 @@ async with session_factory() as session:
|
||||
|
||||
The factory can be shared across operations and tasks. Sessions produced by it cannot be shared across concurrent tasks.
|
||||
|
||||
Passing the factory directly has three useful consequences:
|
||||
Passing the application factory directly has three useful consequences:
|
||||
|
||||
- Lower layers do not resolve settings or global resources.
|
||||
- Tests can inject a test factory directly.
|
||||
- Tests can inject a test factory directly through `session_scope(session_factory=...)` or FastAPI state.
|
||||
- Transaction ownership remains independent of engine construction.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Scope Model
|
||||
## Database and Convenience Scopes
|
||||
|
||||
Most applications need only these three forms:
|
||||
|
||||
1. `session_factory()` for a standalone read or other session-only conversation.
|
||||
2. One `transaction_scope()` helper for a complete operation that may either own a transaction or join its caller's transaction.
|
||||
3. `session.begin_nested()` at the exact call site that needs partial rollback through a SAVEPOINT.
|
||||
|
||||
Do not add a general `atomic_scope()` abstraction. The word "atomic" does not reveal whether the scope joins an outer transaction, creates and commits a root transaction, or creates a SAVEPOINT. Those behaviors have different failure and ownership semantics and should remain visible.
|
||||
|
||||
### One optional-ownership helper
|
||||
The template provides three framework-independent context managers:
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
type SessionFactory = async_sessionmaker[AsyncSession]
|
||||
@asynccontextmanager
|
||||
async def db_session_scope(
|
||||
db_url: str | None = None,
|
||||
) -> AsyncGenerator[AsyncSession]:
|
||||
db_url = db_url or resolve_database_url()
|
||||
async with database_scope(db_url) as session_factory, session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction_scope(
|
||||
session_factory: SessionFactory,
|
||||
async def db_transaction_scope(
|
||||
db_url: str | None = None,
|
||||
) -> AsyncGenerator[AsyncSession]:
|
||||
db_url = db_url or resolve_database_url()
|
||||
async with database_scope(db_url) as session_factory, session_factory.begin() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_scope(
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
session_factory: SessionFactory | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> AsyncGenerator[AsyncSession]:
|
||||
if session is not None:
|
||||
if not session.in_transaction():
|
||||
raise RuntimeError("A supplied session must have an active transaction")
|
||||
|
||||
yield session
|
||||
return
|
||||
|
||||
async with session_factory.begin() as owned_session:
|
||||
session_factory = session_factory or resolve_session_factory(settings=settings)
|
||||
async with session_factory() as owned_session:
|
||||
yield owned_session
|
||||
```
|
||||
|
||||
The explicit branch is preferable to compressing both paths through `nullcontext()` or a mode-driven helper. It makes the ownership transition obvious and keeps type narrowing straightforward. Its runtime cost is negligible compared with database I/O.
|
||||
`db_session_scope()` owns a complete temporary database lifecycle and a session but does not commit. `db_transaction_scope()` owns the same resources plus a root transaction that commits on successful exit and rolls back on exception. Both initialize the schema by default because `database_scope()` enters `engine_scope()` with its default `initialize=True`. They are appropriate for scripts, commands, and isolated operations, not per-request use inside an already-running application.
|
||||
|
||||
The two paths have deliberately different responsibilities:
|
||||
`session_scope()` is the borrow-or-create helper. Its precedence is supplied session, supplied factory, then settings-based cached factory resolution. A supplied session remains entirely caller-owned; the helper does not require an active transaction and does not begin, commit, roll back, or close it. An owned session is closed on exit, and unfinished autobegun work rolls back.
|
||||
|
||||
| Input | Session owner | Transaction owner | Successful exit | Exceptional exit |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `session=None` | Helper | Helper | Flush, commit, then close | Roll back, then close |
|
||||
| Existing `session` | Caller | Caller | Yield control back to caller | Propagate to caller without cleanup |
|
||||
Passing `session=None` is the same as omitting the session for `session_scope()` and therefore creates a session. This differs from `with_session`, which tests whether the argument name was bound rather than whether its value is non-null.
|
||||
|
||||
[`async_sessionmaker.begin()`](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.async_sessionmaker.begin) is the right primitive for the owned path because it creates the session and root transaction together, commits on successful exit, rolls back on exceptional exit, and closes the session. It is equivalent in ownership terms to nesting `session_factory()` and `owned_session.begin()` context managers.
|
||||
## Signature-Aware Session Injection
|
||||
|
||||
Do not call `session.begin()` when a session is supplied. A supplied session means the caller has already chosen the transaction boundary. Silently beginning a transaction would make commit ownership depend on hidden branch behavior and would fail when the session was already active.
|
||||
`with_session` allows one async function to support standalone calls and explicit composition:
|
||||
|
||||
### Autobegin and the defensive check
|
||||
```python
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
|
||||
The `session.in_transaction()` check catches the obvious contract violation of passing an unused session without an outer transaction. It does not prove that the caller intentionally opened a transaction.
|
||||
|
||||
SQLAlchemy's [autobegin](https://docs.sqlalchemy.org/en/21/orm/session_basics.html#auto-begin) behavior starts transactional state after operations such as `execute()`, `add()`, or modifying a persistent object. A preceding read can therefore make `in_transaction()` return `True`. The real ownership signal is the API call itself: passing `session=` declares that the caller owns the active transaction.
|
||||
def with_session[**P, R](
|
||||
func: Callable[P, Awaitable[R]],
|
||||
) -> Callable[P, Awaitable[R]]:
|
||||
sig = signature(func)
|
||||
|
||||
Applications that require mechanical enforcement can construct sessions with `autobegin=False`, but then every database conversation, including reads and every post-commit reuse, must begin explicitly. That stricter policy is valid but is not the minimalist default.
|
||||
@wraps(func)
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
bound = sig.bind_partial(*args, **kwargs)
|
||||
|
||||
if "session" in bound.arguments:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
async with resolve_session_factory()() as session:
|
||||
bound.arguments["session"] = session
|
||||
return await func(*bound.args, **bound.kwargs)
|
||||
|
||||
return wrapper
|
||||
```
|
||||
|
||||
The function must be async and expose a parameter named exactly `session`. The decorator preserves metadata with `wraps()`, binds positional and keyword arguments through the original signature, and injects a fresh session only when the caller omitted that argument.
|
||||
|
||||
The distinction between omitted and explicit `None` is deliberate in the implementation:
|
||||
|
||||
- `await operation()` injects and owns a session.
|
||||
- `await operation(session=existing_session)` borrows the caller's session.
|
||||
- `await operation(None)` or `await operation(session=None)` forwards `None` without injection.
|
||||
|
||||
The decorated function therefore types the parameter as `AsyncSession | None = None` but should assert or guard after decoration. Explicit `None` is not a request for injection. This preserves ordinary Python call binding, but it means wrappers or callers must omit the argument instead of forwarding a nullable value.
|
||||
|
||||
`with_session` owns session lifetime only. It does not begin or commit a transaction, so it is naturally suited to reads. Decorated writes must either manage a visible transaction or be called with a session from `db_transaction_scope()` or another caller-owned transaction. Prefer explicit factory or session injection when lifecycle transparency and test substitution matter more than call-site convenience.
|
||||
|
||||
---
|
||||
|
||||
## Function and Service Boundaries
|
||||
|
||||
Lower-level functions should require a session and contain only data-access behavior:
|
||||
Template service functions support both standalone and composed use by combining `@with_session` with an optional parameter:
|
||||
|
||||
```python
|
||||
from sqlalchemy import select
|
||||
from sqlmodel import func
|
||||
from sqlmodel import select
|
||||
|
||||
|
||||
async def find_item(session: AsyncSession, item_id: int) -> Item | None:
|
||||
statement = select(Item).where(Item.id == item_id)
|
||||
return await session.scalar(statement)
|
||||
|
||||
|
||||
async def insert_order(session: AsyncSession, payload: OrderCreate) -> Order:
|
||||
order = Order.model_validate(payload)
|
||||
session.add(order)
|
||||
await session.flush()
|
||||
return order
|
||||
@with_session
|
||||
async def count_items(session: AsyncSession | None = None) -> int:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
result = await session.exec(select(func.count()).select_from(Item))
|
||||
return result.one()
|
||||
```
|
||||
|
||||
These functions do not create, close, commit, roll back, or nest transactions. This keeps them composable and makes transaction behavior a property of the calling use case rather than the query helper.
|
||||
|
||||
A complete write operation may accept an optional session and use `transaction_scope()`:
|
||||
The standalone call injects and closes a session:
|
||||
|
||||
```python
|
||||
async def create_order(
|
||||
session_factory: SessionFactory,
|
||||
payload: OrderCreate,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Order:
|
||||
async with transaction_scope(
|
||||
session_factory,
|
||||
session=session,
|
||||
) as active_session:
|
||||
return await insert_order(active_session, payload)
|
||||
count = await count_items()
|
||||
```
|
||||
|
||||
The standalone call owns and commits its work:
|
||||
A larger use case passes one caller-owned session through several decorated functions:
|
||||
|
||||
```python
|
||||
order = await create_order(session_factory, payload)
|
||||
async with session_factory.begin() as session:
|
||||
count = await count_items(session)
|
||||
await create_item(payload, session=session)
|
||||
```
|
||||
|
||||
A larger use case owns one transaction and passes the same session through every operation:
|
||||
The decorator sees the bound `session` argument and leaves all ownership with the caller. It never creates a SAVEPOINT or nested transaction.
|
||||
|
||||
```python
|
||||
async with transaction_scope(session_factory) as session:
|
||||
order = await create_order(
|
||||
session_factory,
|
||||
payload,
|
||||
session=session,
|
||||
)
|
||||
await reserve_inventory(session, order)
|
||||
await create_audit_entry(session, order)
|
||||
```
|
||||
|
||||
The inner `create_order()` scope joins the existing transaction; it does not commit and does not create a SAVEPOINT. If inventory reservation or audit creation fails, the outer scope rolls back all three operations together. This is ordinary service composition, not a nested database transaction.
|
||||
|
||||
For standalone reads, use the factory directly rather than routing through a transaction-owning helper:
|
||||
|
||||
```python
|
||||
async with session_factory() as session:
|
||||
item = await find_item(session, item_id)
|
||||
```
|
||||
|
||||
The session context closes the session and rolls back any unfinished autobegun transaction. It does not commit. If a public read operation supports a caller-supplied session, keep the small borrow-or-create branch in that operation; do not disguise it as transaction ownership.
|
||||
|
||||
Application service objects that represent standalone operations may store the immutable session factory, but they must not store a mutable session:
|
||||
For low-level helpers that should never resolve settings, require a non-optional session and leave them undecorated. Application service objects may store the immutable session factory, but they must not store a mutable session:
|
||||
|
||||
```python
|
||||
class ItemService:
|
||||
@@ -265,7 +306,7 @@ Code that already owns a transaction should call the session-required function d
|
||||
Use [`begin_nested()`](https://docs.sqlalchemy.org/en/21/orm/session_transaction.html#using-savepoint) only when failure inside one portion of an operation should roll back that portion while preserving the outer transaction:
|
||||
|
||||
```python
|
||||
async with transaction_scope(session_factory) as session:
|
||||
async with db_transaction_scope() as session:
|
||||
order = await insert_order(session, payload)
|
||||
|
||||
try:
|
||||
@@ -324,12 +365,11 @@ Keep framework adapters outside these session primitives. See [FastAPI database
|
||||
- Passing an application-global AsyncSession to a repository constructor.
|
||||
- Creating a new `async_sessionmaker` in each operation.
|
||||
- Retaining a session factory after its bound engine scope exits.
|
||||
- Calling the session factory inside low-level access functions such as `find_item()`.
|
||||
- Hidden session creation in lower access functions with no caller control.
|
||||
- Using cached standalone factory resolution when the application factory is already available.
|
||||
- Assuming `with_session` starts or commits a transaction.
|
||||
- Forwarding `session=None` to a decorated function when injection was intended.
|
||||
- Closing or committing a session supplied by the caller.
|
||||
- Starting a new transaction inside a helper that may receive a session already in a transaction.
|
||||
- Silently starting or committing a transaction on a supplied session.
|
||||
- Treating `in_transaction()` as proof that a caller intentionally owns the transaction.
|
||||
- Creating a SAVEPOINT for ordinary nested service calls.
|
||||
- Hiding root transaction, joined transaction, and SAVEPOINT behavior behind one mode-driven `atomic_scope()` helper.
|
||||
- Calling `session.commit()` inside a SAVEPOINT scope.
|
||||
@@ -339,9 +379,10 @@ Keep framework adapters outside these session primitives. See [FastAPI database
|
||||
|
||||
## Operational Checks
|
||||
|
||||
- Exactly one `async_sessionmaker` is configured inside each application engine scope.
|
||||
- The session factory does not outlive its bound engine.
|
||||
- Application operations receive sessions from one canonical session factory.
|
||||
- The FastAPI application factory is created inside `database_scope()` and does not outlive its bound engine.
|
||||
- Cached standalone factories are used only where application-state injection is unavailable.
|
||||
- `session_scope()` precedence is supplied session, supplied factory, then settings-based resolution.
|
||||
- Decorated functions receive injection only when the `session` argument is omitted.
|
||||
- No code path creates AsyncSession in module import side effects.
|
||||
- Concurrent jobs and operations each create task-local sessions.
|
||||
|
||||
@@ -351,12 +392,13 @@ Keep framework adapters outside these session primitives. See [FastAPI database
|
||||
|
||||
- Service constructors accept a test session factory without framework startup.
|
||||
- Session-taking access functions accept a transaction-scoped test session directly.
|
||||
- Transaction-scope tests verify supplied sessions require an active transaction and remain caller-owned.
|
||||
- Transaction-scope tests verify owned transactions commit on success, roll back on failure, and close their sessions.
|
||||
- Composition tests verify nested service calls join one outer transaction without committing it.
|
||||
- `session_scope()` tests cover supplied-session, supplied-factory, and settings-resolution precedence.
|
||||
- `db_transaction_scope()` tests verify commit on success, rollback on failure, session closure, engine disposal, and cache cleanup.
|
||||
- `with_session` tests cover omitted, positional, keyword, and explicit-`None` session arguments.
|
||||
- Composition tests verify decorated service calls borrow one caller-owned session without committing it.
|
||||
- SAVEPOINT tests verify local rollback preserves the outer transaction and successful exit does not commit it.
|
||||
- Tests that depend on SAVEPOINT timing account for `begin_nested()` flushing pending state on entry.
|
||||
- Rollback behavior is verified for failed write units.
|
||||
- Parallel-task tests verify no shared AsyncSession instances.
|
||||
- Lifecycle tests confirm the session factory is initialized and teardown-safe.
|
||||
- Lifecycle tests confirm schema initialization, factory availability, deterministic teardown, and expected cache behavior.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user