template updates
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: async-fastapi-sqlmodel
|
||||
description: 'Explain and apply async database principles for FastAPI, SQLAlchemy 2.x, and SQLModel. Use when: learning or reviewing AsyncEngine and AsyncSession lifecycles, FastAPI lifespan and yield dependencies, transaction boundaries, concurrency safety, implicit ORM I/O, AsyncExitStack, pooling, testing, or SQLModel integration.'
|
||||
description: 'Explain and apply async database principles for FastAPI, SQLAlchemy 2.x, and SQLModel. Use when: learning or reviewing cached AsyncEngine and session-factory lifecycles, AsyncSession scopes and injection, FastAPI lifespan and yield dependencies, transaction boundaries, concurrency safety, implicit ORM I/O, pooling, testing, or SQLModel integration.'
|
||||
x-personal-mcp:
|
||||
id: async-fastapi-sqlmodel
|
||||
version: 1.1.0
|
||||
version: 1.2.0
|
||||
tags:
|
||||
- fastapi
|
||||
- sqlalchemy
|
||||
@@ -24,6 +24,8 @@ Use this skill to explain how an async database layer works, why the recommended
|
||||
|
||||
Primary targets: PostgreSQL with asyncpg and SQLite with aiosqlite.
|
||||
|
||||
Engine and session mechanics mirror the [`nicegui-db` template repository](https://forgejo.john-stream.com/john/nicegui-db). Treat that template as the implementation baseline, then explain the rationale, lifecycle constraints, and tradeoffs behind its cached engines, session factories, context managers, dependency wiring, and `with_session` decorator. Source-specific claims in the references link to the reviewed template commit so behavior remains auditable as the template evolves.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Explain an async engine, session factory, session, connection, or transaction.
|
||||
@@ -51,7 +53,7 @@ Keep three ownership scopes distinct:
|
||||
|
||||
| Scope | Object | Purpose | Typical owner |
|
||||
|---|---|---|---|
|
||||
| Application process | `AsyncEngine` and `async_sessionmaker` | Dialect, connection pool, and repeatable session configuration | FastAPI lifespan |
|
||||
| Application process | Cached `AsyncEngine` and lifespan-owned `async_sessionmaker` | Dialect, connection pool, schema initialization, and repeatable session configuration | FastAPI lifespan |
|
||||
| Request or concurrent task | `AsyncSession` | Mutable ORM identity map and transactional state | A `yield` dependency or explicit unit of work |
|
||||
| Atomic operation | `SessionTransaction` | Commit all changes together or roll them back together | Service or use-case boundary |
|
||||
|
||||
@@ -61,16 +63,16 @@ The engine is a long-lived factory and pool, not a single database connection. T
|
||||
|
||||
### Match lifetime to ownership
|
||||
|
||||
- Create one `AsyncEngine` per process and database configuration in the normal case.
|
||||
- Dispose it explicitly in an awaitable shutdown path; garbage collection cannot reliably await async driver cleanup.
|
||||
- Configure `async_sessionmaker` once inside the engine lifecycle and call it to create short-lived sessions.
|
||||
- Resolve one cached `AsyncEngine` per database URL during the active application lifecycle.
|
||||
- Enter one owning engine scope per URL; initialize registered SQLModel metadata by default, then dispose the engine and clear cached resolution on exit.
|
||||
- Configure the application `async_sessionmaker` inside the engine lifecycle; use the template's cached factory resolver only for standalone helpers that cannot receive the application factory.
|
||||
- Close each session deterministically with `async with` or a FastAPI dependency that yields once.
|
||||
|
||||
See [engine lifecycle](references/engine.md) and [session management](references/session.md).
|
||||
|
||||
### Isolate mutable session state
|
||||
|
||||
An `AsyncSession` represents one stateful transaction in progress. Never use one session in multiple concurrent tasks, including branches of `asyncio.gather()`. Give each task its own session and pass sessions explicitly rather than relying on mutable scoped globals.
|
||||
An `AsyncSession` represents one stateful transaction in progress. Never use one session in multiple concurrent tasks, including branches of `asyncio.gather()`. Give each task its own session. Template-style `@with_session` functions inject one only when the `session` argument is omitted; a supplied session remains caller-owned, and explicit `None` is forwarded unchanged.
|
||||
|
||||
See [session management](references/session.md).
|
||||
|
||||
@@ -94,7 +96,7 @@ FastAPI lifespan owns resources shared by many requests. A dependency with one `
|
||||
|
||||
Use `AsyncExitStack` when lifespan acquires a variable, conditional, or mixed collection of context-managed resources. It records cleanup as resources are acquired and unwinds callbacks in reverse order. A single engine should use the direct engine context manager; `AsyncExitStack` is a composition tool, not a requirement.
|
||||
|
||||
See [engine lifecycle](references/engine.md).
|
||||
See [FastAPI database integration](references/fastapi.md).
|
||||
|
||||
### Use SQLModel as the primary modeling layer
|
||||
|
||||
@@ -125,8 +127,8 @@ See [database testing and fixture data](references/testing.md).
|
||||
| Engine lifecycle and ownership | [Engine lifecycle reference](references/engine.md) |
|
||||
| Session factory and scope | [Session management reference](references/session.md) |
|
||||
| Transaction boundaries | [Transaction boundaries reference](references/transactions.md) |
|
||||
| Lifespan composition | [Engine lifecycle reference](references/engine.md) |
|
||||
| Dependency injection | [Session management reference](references/session.md) |
|
||||
| FastAPI lifespan composition | [FastAPI integration reference](references/fastapi.md) |
|
||||
| FastAPI dependency injection | [FastAPI integration reference](references/fastapi.md) |
|
||||
| Implicit I/O control in ORM | [Implicit I/O reference](references/implicit_io.md) |
|
||||
| Observability and resilience | [Observability reference](references/observability.md) |
|
||||
| SQLModel-first modeling | [SQLModel integration reference](references/sqlmodel.md) |
|
||||
@@ -135,29 +137,9 @@ See [database testing and fixture data](references/testing.md).
|
||||
|
||||
## Canonical Composition Pattern
|
||||
|
||||
This example shows the ownership boundaries. Adapt state storage and dependency wiring to the application's conventions.
|
||||
The framework-independent primitives live in [engine lifecycle](references/engine.md), [session management](references/session.md), and [transaction boundaries](references/transactions.md). Their canonical FastAPI adaptation, including lifespan state and `Annotated` dependencies, lives in [FastAPI database integration](references/fastapi.md).
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
||||
async with engine_scope(settings.database_url) as engine:
|
||||
app.state.session_factory = create_session_factory(engine)
|
||||
yield
|
||||
|
||||
|
||||
async def get_session(request: Request) -> AsyncGenerator[AsyncSession]:
|
||||
async with request.app.state.session_factory() as session:
|
||||
yield session
|
||||
```
|
||||
|
||||
`engine_scope()` and `create_session_factory()` are defined in the engine and session references. For background work that outlives a request, inject the shared factory and create a new session inside that task instead of retaining the request's session.
|
||||
For background work that outlives a request, inject the shared factory and create a new session inside that task instead of retaining the request's session.
|
||||
|
||||
## Explanation Procedure
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
- [SQLModel update-data tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/update-extra-data/)
|
||||
- [SQLModel select tutorial](https://sqlmodel.tiangolo.com/tutorial/select/)
|
||||
- [SQLAlchemy `AsyncSession` API](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncSession)
|
||||
- [`nicegui-db` service functions](https://forgejo.john-stream.com/john/nicegui-db/src/commit/126bc26ad8635a86bacf684d7bda409230347597/src/nicegui_db/services/my_table.py)
|
||||
|
||||
??? abstract "Decision metadata"
|
||||
- Status: adopted
|
||||
- Decision level: advisory
|
||||
- Applies to: api-runtime, workers, tests
|
||||
- Last reviewed: 2026-07-26
|
||||
- Last reviewed: 2026-08-06
|
||||
|
||||
---
|
||||
|
||||
@@ -21,17 +22,17 @@ Show a small SQLModel CRUD layer in two forms:
|
||||
- independent functions for convenient standalone or composed operations;
|
||||
- a repository object that groups those functions behind one domain-oriented interface.
|
||||
|
||||
Every public operation accepts an optional `AsyncSession`. When omitted, reads resolve the cached session factory and own a short-lived session, while writes resolve the same factory and own a complete session-and-transaction scope. When supplied, reads borrow the session and writes borrow its already-active caller-owned transaction. The repository stores configuration and delegates to the same functions without changing those semantics.
|
||||
Template-style public functions use `@with_session` and accept an optional `AsyncSession`. When the argument is omitted, the decorator resolves the cached session factory and owns a short-lived session. When supplied, the function borrows the session without controlling its lifetime or transaction. The decorator does not commit, so standalone writes need a visible transaction strategy; repository methods remain explicit-session operations for predictable composition.
|
||||
|
||||
Use the same vocabulary at every layer:
|
||||
|
||||
| Operation | Function | Repository method | Scope when session is omitted | Missing-row result |
|
||||
|---|---|---|---|---|
|
||||
| Create | `create_widget()` | `create()` | Owned transaction | Not applicable |
|
||||
| Create | `create_widget()` | `create()` | Owned session; no implicit commit | Not applicable |
|
||||
| Read one | `get_widget()` | `get()` | Owned session | `None` |
|
||||
| Read many | `list_widgets()` | `list()` | Owned session | Empty list |
|
||||
| Update | `update_widget()` | `update()` | Owned transaction | `None` |
|
||||
| Delete | `delete_widget()` | `delete()` | Owned transaction | `None` |
|
||||
| Update | `update_widget()` | `update()` | Owned session; no implicit commit | `None` |
|
||||
| Delete | `delete_widget()` | `delete()` | Owned session; no implicit commit | `None` |
|
||||
|
||||
Functions and repository methods both put domain arguments first. Database configuration and sessions are keyword-only infrastructure arguments. This keeps call sites analogous and makes ownership choices visible.
|
||||
|
||||
@@ -58,36 +59,47 @@ This reference uses direct field arguments and full-update semantics to keep the
|
||||
|
||||
## Independent CRUD Functions
|
||||
|
||||
Functions are the simplest default when grouping state or behavior in an object adds no value. Keep them in the session-required data-access layer so transaction ownership remains external and several calls can compose under one boundary.
|
||||
Functions are the simplest default when grouping state or behavior in an object adds no value. Decorate public service functions when both standalone reads and explicit composition are useful. The assertion narrows the optional type after decorator injection and catches accidental explicit `None` calls.
|
||||
|
||||
```python
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .session import with_session
|
||||
|
||||
|
||||
@with_session
|
||||
async def create_widget(
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
widget = Widget(name=name, description=description)
|
||||
session.add(widget)
|
||||
await session.flush()
|
||||
return widget
|
||||
|
||||
|
||||
@with_session
|
||||
async def get_widget(
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
return await session.get(Widget, widget_id)
|
||||
|
||||
|
||||
@with_session
|
||||
async def list_widgets(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
session: AsyncSession | None = None,
|
||||
) -> list[Widget]:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
if offset < 0:
|
||||
raise ValueError("offset must be non-negative")
|
||||
if not 1 <= limit <= 100:
|
||||
@@ -97,12 +109,15 @@ async def list_widgets(
|
||||
return list(await session.scalars(statement))
|
||||
|
||||
|
||||
@with_session
|
||||
async def update_widget(
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
name: str,
|
||||
description: str | None,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
widget = await session.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None
|
||||
@@ -113,10 +128,13 @@ async def update_widget(
|
||||
return widget
|
||||
|
||||
|
||||
@with_session
|
||||
async def delete_widget(
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
assert session is not None, "Session must be provided by with_session decorator"
|
||||
widget = await session.get(Widget, widget_id)
|
||||
if widget is None:
|
||||
return None
|
||||
@@ -128,7 +146,7 @@ async def delete_widget(
|
||||
|
||||
Update and delete load the row through the same session that mutates it. This avoids accepting detached instances from an earlier standalone read and gives both operations an explicit `None` result that the application layer can map to a domain or HTTP error. Delete returns the loaded object for callers that need its values, but that object represents a row scheduled for deletion and must not be reused as persistent state. List operations validate their bounds and order by the primary key so pagination is deterministic. Add a unique tiebreaker whenever ordering by a non-unique field.
|
||||
|
||||
`flush()` sends pending writes and populates ordinary generated primary keys. It does not itself commit. The caller's transaction retains commit and rollback ownership. Use `await session.refresh(widget)` only when the operation deliberately needs database-generated state that was not returned during the flush; an unconditional refresh adds another query.
|
||||
`flush()` sends pending writes and populates ordinary generated primary keys. It does not itself commit. A decorated write called without a session will therefore roll back when its owned session closes unless the function explicitly commits. Prefer passing a transaction-scoped session so several writes compose atomically. Use `await session.refresh(widget)` only when the operation deliberately needs database-generated state that was not returned during the flush; an unconditional refresh adds another query.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,9 +165,9 @@ class WidgetRepository:
|
||||
description: str | None = None,
|
||||
) -> Widget:
|
||||
return await create_widget(
|
||||
session,
|
||||
name,
|
||||
description,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def get(
|
||||
@@ -157,7 +175,7 @@ class WidgetRepository:
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
) -> Widget | None:
|
||||
return await get_widget(session, widget_id)
|
||||
return await get_widget(widget_id, session=session)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
@@ -167,9 +185,9 @@ class WidgetRepository:
|
||||
limit: int = 100,
|
||||
) -> list[Widget]:
|
||||
return await list_widgets(
|
||||
session,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def update(
|
||||
@@ -180,10 +198,10 @@ class WidgetRepository:
|
||||
description: str | None,
|
||||
) -> Widget | None:
|
||||
return await update_widget(
|
||||
session,
|
||||
widget_id,
|
||||
name,
|
||||
description,
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def delete(
|
||||
@@ -191,7 +209,7 @@ class WidgetRepository:
|
||||
session: AsyncSession,
|
||||
widget_id: int,
|
||||
) -> Widget | None:
|
||||
return await delete_widget(session, widget_id)
|
||||
return await delete_widget(widget_id, session=session)
|
||||
```
|
||||
|
||||
The object is intentionally thin. Tests pass a transaction-scoped test session directly. The caller always owns that session and its transaction, and the repository never closes or commits it.
|
||||
@@ -202,29 +220,19 @@ If a read participates in a later write, pass the same session and place both op
|
||||
|
||||
## Transaction Ownership
|
||||
|
||||
Compose multiple calls under one use-case transaction. Only the complete use case accepts an optional session: a supplied session joins its caller's active transaction, while omitting the session creates and owns a standalone session and transaction. CRUD functions and repository methods simply use the resulting `active_session`.
|
||||
Compose multiple calls under one use-case transaction. `db_transaction_scope()` owns the standalone engine, factory, session, and transaction lifetimes. Decorated CRUD functions detect the supplied session and borrow it; repository methods receive it directly.
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from .session import transaction_scope
|
||||
|
||||
type SessionFactory = async_sessionmaker[AsyncSession]
|
||||
from .session import db_transaction_scope
|
||||
|
||||
|
||||
async def replace_widget(
|
||||
session_factory: SessionFactory,
|
||||
repository: WidgetRepository,
|
||||
widget_id: int,
|
||||
replacement_name: str,
|
||||
replacement_description: str | None = None,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Widget | None:
|
||||
async with transaction_scope(
|
||||
session_factory,
|
||||
session=session,
|
||||
) as active_session:
|
||||
async with db_transaction_scope() as active_session:
|
||||
deleted_widget = await repository.delete(
|
||||
active_session,
|
||||
widget_id,
|
||||
@@ -239,17 +247,17 @@ async def replace_widget(
|
||||
)
|
||||
```
|
||||
|
||||
If creation fails, deletion rolls back with it. For a caller-owned transaction, wrap the call in `async with session.begin():` and pass that session. For a standalone use case, omit the session; the outer `transaction_scope()` commits on successful exit, rolls back on exception, and closes its owned session. Do not add direct `commit()` calls to CRUD functions or repository methods because that prevents callers from composing several operations atomically. See [transaction boundaries](transactions.md) and [session management](session.md) for ownership details.
|
||||
If creation fails, deletion rolls back with it. Inside an already-running application, prefer `async with session_factory.begin()` or `async with session.begin()` over `db_transaction_scope()` so the application-owned engine and factory remain in use. Do not add direct `commit()` calls to CRUD functions or repository methods because that prevents callers from composing several operations atomically. See [transaction boundaries](transactions.md) and [session management](session.md) for ownership details.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Storing one mutable `AsyncSession` on a long-lived repository object.
|
||||
- Creating sessions or transactions inside CRUD functions and repository methods.
|
||||
- Creating sessions manually inside functions already using `@with_session`.
|
||||
- Passing database configuration through every CRUD call instead of injecting a session at the data-access boundary.
|
||||
- Accepting a supplied session for a write without requiring an active caller-owned transaction.
|
||||
- Calling `commit()` or `rollback()` directly instead of expressing ownership through `transaction_scope()`.
|
||||
- Assuming decorator-owned write sessions commit on close.
|
||||
- Forwarding explicit `session=None` when decorator injection was intended.
|
||||
- Accepting unbounded list queries.
|
||||
- Accepting detached ORM instances for update or delete when an identifier can be resolved in the active session.
|
||||
- Accessing unloaded attributes after a standalone repository read has closed its owned session.
|
||||
@@ -260,13 +268,13 @@ If creation fails, deletion rolls back with it. For a caller-owned transaction,
|
||||
|
||||
- Every CRUD call receives a task-local `AsyncSession`.
|
||||
- Standalone reads create and close a session at the service or application boundary.
|
||||
- Standalone writes own commit, rollback, and session cleanup through `transaction_scope()` at the service or use-case boundary.
|
||||
- Supplied write sessions already have an active caller-owned transaction.
|
||||
- Each complete operation, service, or use-case boundary borrows an active transaction or owns a complete session-and-transaction scope.
|
||||
- Standalone reads may omit `session`; decorated writes receive a transaction-scoped session or explicitly own their commit policy.
|
||||
- Supplied write sessions remain caller-owned.
|
||||
- Each complete write operation declares a visible transaction boundary.
|
||||
- List operations have pagination and deterministic ordering where required.
|
||||
- Update requires values for both mutable fields; passing `None` explicitly clears the nullable description.
|
||||
- Get, update, and delete use the same identifier and missing-row semantics.
|
||||
- Functions and repository methods take the session explicitly and do not accept database configuration.
|
||||
- Decorated functions accept an optional keyword-only session; repository methods require one explicitly.
|
||||
- Standalone service reads load all state needed after their owned session closes.
|
||||
- Repository objects hold query policy when useful, never database configuration or request-scoped session state.
|
||||
|
||||
@@ -282,5 +290,5 @@ If creation fails, deletion rolls back with it. For a caller-owned transaction,
|
||||
- Delete tests verify the returned row and its absence after commit.
|
||||
- Failure tests verify that a surrounding transaction rolls back all composed CRUD calls.
|
||||
- Optional-session read tests verify borrowed sessions remain open and owned sessions close without committing.
|
||||
- Optional-session write tests verify supplied transactions remain caller-owned and standalone transactions commit or roll back before closing.
|
||||
- Decorated write tests verify supplied transactions remain caller-owned and omitted sessions do not imply a commit.
|
||||
- Composition tests pass one active session through several CRUD calls and verify one atomic commit or rollback.
|
||||
@@ -2,73 +2,106 @@
|
||||
|
||||
!!! 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)
|
||||
- [SQLAlchemy connections](https://docs.sqlalchemy.org/en/21/core/connections.html)
|
||||
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||
- [SQLAlchemy pooling and multiprocessing](https://docs.sqlalchemy.org/en/21/core/pooling.html#pooling-multiprocessing)
|
||||
- [SQLAlchemy SQLite transaction control](https://docs.sqlalchemy.org/en/21/dialects/sqlite.html#enabling-non-legacy-sqlite-transactional-modes-with-the-sqlite3-or-aiosqlite-driver)
|
||||
- [SQLAlchemy SQLite foreign-key support](https://docs.sqlalchemy.org/en/21/dialects/sqlite.html#foreign-key-support)
|
||||
- [SQLite PRAGMA reference](https://www.sqlite.org/pragma.html)
|
||||
- [`nicegui-db` engine implementation](https://forgejo.john-stream.com/john/nicegui-db/src/commit/126bc26ad8635a86bacf684d7bda409230347597/src/nicegui_db/db/engine.py)
|
||||
|
||||
---
|
||||
|
||||
## Engine Ownership Model
|
||||
|
||||
Create one async engine for each application, worker, command, or test lifecycle.
|
||||
Resolve one async engine for each database URL within an application, worker, command, or test lifecycle.
|
||||
|
||||
- SQLAlchemy guidance: the engine is intended as a long-lived, concurrent registry over pooled DB connections, not a per-operation object.
|
||||
- The composition root owns engine creation and disposal.
|
||||
- `get_engine(database_url)` owns URL-keyed engine construction and caching.
|
||||
- The composition root enters `engine_scope(database_url)` once and therefore owns initialization and disposal.
|
||||
- Services and repositories receive a session or session factory; they do not resolve an engine.
|
||||
|
||||
!!! tip "Practical rule"
|
||||
- Exactly one `create_async_engine(...)` call for each application-owned engine lifecycle.
|
||||
- Exactly one cached engine for each database URL during an active application-owned lifecycle.
|
||||
- Exactly one active owning `engine_scope()` for a given URL.
|
||||
- Zero `create_async_engine(...)` calls in feature code.
|
||||
- Zero engine lookup or disposal calls in repository code.
|
||||
|
||||
---
|
||||
|
||||
## One Engine Context Manager
|
||||
## Cached Engine Resolution
|
||||
|
||||
Use one [`asynccontextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager) to pair engine creation with disposal:
|
||||
[`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) makes the database URL the engine identity:
|
||||
|
||||
```python
|
||||
from functools import cache
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
|
||||
@cache
|
||||
def get_engine(database_url: str) -> AsyncEngine:
|
||||
engine = create_async_engine(database_url, pool_pre_ping=True)
|
||||
if engine.dialect.name == "sqlite":
|
||||
configure_aiosqlite_engine(engine)
|
||||
return engine
|
||||
```
|
||||
|
||||
Repeated calls with the same exact URL return the same `AsyncEngine`; different URLs produce independent cache entries. Construction configures the dialect and pool but normally does not open a database connection until the first operation. SQLite event listeners are installed only when a new cached engine is constructed, before its first connection.
|
||||
|
||||
Resolve settings into the final URL before calling `get_engine()`. Services and repositories should not call it directly: the cache controls construction identity, not ownership.
|
||||
|
||||
## Owning Engine Scope
|
||||
|
||||
Use one [`asynccontextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager) to pair cached resolution and optional schema initialization with disposal:
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
type SessionFactory = async_sessionmaker[AsyncSession]
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def database_scope(database_url: str) -> AsyncGenerator[SessionFactory]:
|
||||
async with engine_scope(database_url) as engine:
|
||||
yield async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def engine_scope(database_url: str) -> AsyncGenerator[AsyncEngine]:
|
||||
engine = create_async_engine(database_url, pool_pre_ping=True)
|
||||
if engine.dialect.name == "sqlite":
|
||||
configure_aiosqlite_engine(engine)
|
||||
async def engine_scope(
|
||||
database_url: str,
|
||||
*,
|
||||
initialize: bool = True,
|
||||
) -> AsyncGenerator[AsyncEngine]:
|
||||
engine = get_engine(database_url)
|
||||
if initialize:
|
||||
await initialize_db(database_url)
|
||||
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await dispose_engine(database_url)
|
||||
|
||||
|
||||
async def initialize_db(database_url: str) -> None:
|
||||
from . import models # noqa: F401
|
||||
|
||||
engine = get_engine(database_url)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
|
||||
async def dispose_engine(database_url: str) -> None:
|
||||
engine = get_engine(database_url)
|
||||
try:
|
||||
await engine.dispose()
|
||||
finally:
|
||||
get_engine.cache_clear()
|
||||
```
|
||||
|
||||
The code that enters `engine_scope()` owns the engine. It keeps that scope open for the complete application, worker, command, or test lifecycle and passes the yielded engine into session-factory construction. Successful and exceptional exits both dispose the pool.
|
||||
The code that enters `engine_scope()` owns the engine. It keeps that scope open for the complete application, worker, command, or test lifecycle and passes the yielded engine into session-factory construction. Successful and exceptional exits both dispose the pool and invalidate cached engine resolution.
|
||||
|
||||
Creating an `AsyncEngine` configures its dialect and pool; the first database operation normally establishes a connection. No cache is required when the application composition root enters this context exactly once. Removing the cache also removes cache-key, refresh, and invalidation behavior that otherwise must remain synchronized with the session factory.
|
||||
Initialization imports the model package so every table is registered, then runs `SQLModel.metadata.create_all()` in `engine.begin()`. This is suitable for the template and focused tests. Use migrations instead when schema evolution is part of the deployment contract. Pass `initialize=False` only when another owner provisions the schema or a test is directly exercising construction without schema setup.
|
||||
|
||||
Resolve settings before entering the scope. Do not hide settings lookup or engine creation inside feature code.
|
||||
`dispose_engine()` clears the complete function cache, not only the requested URL. This matches the template and is safe under its intended single-database lifecycle. Applications that own several simultaneously active database URLs need per-key lifecycle management rather than this global invalidation behavior.
|
||||
|
||||
Workers, scripts, and other composition roots enter `database_scope()` directly:
|
||||
|
||||
@@ -77,9 +110,11 @@ async with database_scope(settings.database_url) as session_factory:
|
||||
await run_worker(session_factory)
|
||||
```
|
||||
|
||||
For several fixed databases, nest one scope per engine. Use [`AsyncExitStack`](https://docs.python.org/3/library/contextlib.html#contextlib.AsyncExitStack) only when the number of engines is dynamic or conditional.
|
||||
`database_scope()` is defined in [session management](session.md). It enters `engine_scope()` and creates the factory bound to the yielded engine.
|
||||
|
||||
When directly testing engine construction or lifecycle behavior, enter `engine_scope()` in the test or fixture. Exiting the context disposes the engine even when the test fails; no global cache reset is needed.
|
||||
Do not overlap two owning scopes for the same URL. Both resolve the same cached engine, and the first scope to exit disposes it and clears the cache while the other still refers to it. For several fixed databases, use one non-overlapping owner per URL and account for global cache invalidation; use [`AsyncExitStack`](https://docs.python.org/3/library/contextlib.html#contextlib.AsyncExitStack) only after adopting lifecycle semantics that support several simultaneous owners.
|
||||
|
||||
When directly testing engine construction or lifecycle behavior, enter `engine_scope()` in the test or fixture. Exiting the context disposes the engine even when the test fails and clears the cache for the next lifecycle.
|
||||
|
||||
See [FastAPI database integration](fastapi.md) for adapting `database_scope()` to application lifespan and dependency injection.
|
||||
|
||||
@@ -192,10 +227,11 @@ engine = create_async_engine(
|
||||
|
||||
## Disposal Semantics
|
||||
|
||||
`engine.dispose()` replaces/disposes the pool, but only checked-in connections are immediately closed.
|
||||
`dispose_engine(database_url)` resolves the cached engine, awaits `engine.dispose()`, and clears the engine cache in a `finally` block. `engine.dispose()` replaces/disposes the pool, but only checked-in connections are immediately closed.
|
||||
|
||||
Rules:
|
||||
- Dispose when the app is shutting down.
|
||||
- Clear cached resolution even when disposal raises, so a later lifecycle cannot receive the failed engine object.
|
||||
- Dispose before reusing an engine across event loops.
|
||||
- In forked child-process initialization, use `engine.dispose(close=False)` (sync API guidance) so child processes do not touch parent-held connections.
|
||||
|
||||
@@ -221,7 +257,9 @@ This prevents broken socket state and cross-process connection corruption.
|
||||
- Resolve an engine from repositories instead of injecting a session dependency.
|
||||
- Keep engine creation as a hidden side effect of import-time module globals.
|
||||
- Keep a session factory alive after its bound engine scope exits.
|
||||
- Add process-global engine caching when one composition root already owns the lifecycle.
|
||||
- Enter overlapping engine scopes for the same cached URL.
|
||||
- Treat `cache_clear()` as per-URL invalidation when it clears every cached engine.
|
||||
- Use `metadata.create_all()` as a substitute for required production migrations.
|
||||
- Install the same SQLite event listeners more than once on one engine.
|
||||
- Enable WAL blindly for in-memory SQLite or treat a busy timeout as a concurrency guarantee.
|
||||
|
||||
@@ -229,13 +267,15 @@ This prevents broken socket state and cross-process connection corruption.
|
||||
|
||||
## Engine Design Checklist
|
||||
|
||||
- One engine scope per application-owned database lifecycle.
|
||||
- Engine creation and disposal paired by one framework-independent context manager.
|
||||
- One cached engine per exact database URL during an active lifecycle.
|
||||
- One owning engine scope per URL, with no overlapping owners.
|
||||
- Cached resolution, optional initialization, disposal, and cache invalidation follow one framework-independent lifecycle.
|
||||
- The composition root enters the database scope once and keeps it open until shutdown.
|
||||
- Session factory created inside, and never outlives, its engine scope.
|
||||
- Model registration occurs before `metadata.create_all()` when initialization is enabled.
|
||||
- Async driver URL matches backend (`asyncpg` or `aiosqlite`).
|
||||
- `aiosqlite` foreign-key and transaction listeners installed once before first use.
|
||||
- WAL enabled only as an explicit policy for a file-backed SQLite database.
|
||||
- Pooling strategy is explicit for non-default needs.
|
||||
- No feature-path engine creation.
|
||||
- Tests enter the same scope and receive deterministic disposal without global cache cleanup.
|
||||
- Tests enter the same scope and receive deterministic disposal plus cache cleanup.
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
- [FastAPI dependencies with `yield`](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/)
|
||||
- [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/)
|
||||
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||
- [`nicegui-db` application lifespan](https://forgejo.john-stream.com/john/nicegui-db/src/commit/126bc26ad8635a86bacf684d7bda409230347597/src/nicegui_db/ui/app.py)
|
||||
- [`nicegui-db` database dependencies](https://forgejo.john-stream.com/john/nicegui-db/src/commit/126bc26ad8635a86bacf684d7bda409230347597/src/nicegui_db/ui/dependency.py)
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +15,7 @@
|
||||
Connect the framework-independent database tools to FastAPI:
|
||||
|
||||
- lifespan enters one application-owned `database_scope()`,
|
||||
- application state holds the resulting session factory,
|
||||
- application state holds settings and the resulting session factory,
|
||||
- dependencies create one session per request,
|
||||
- `Annotated` aliases make route ownership concise and explicit.
|
||||
|
||||
@@ -31,25 +33,28 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .engine import database_scope
|
||||
from .config import Settings
|
||||
from .config import get_database_url
|
||||
from .db import database_scope
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
||||
database_url = app.state.settings.database_url
|
||||
async def lifespan(settings: Settings, app: FastAPI) -> AsyncGenerator[None]:
|
||||
app.state.settings = settings
|
||||
db_url = get_database_url(settings)
|
||||
|
||||
async with database_scope(database_url) as session_factory:
|
||||
app.state.session_factory = session_factory
|
||||
try:
|
||||
try:
|
||||
async with database_scope(db_url) as session_factory:
|
||||
app.state.session_factory = session_factory
|
||||
yield
|
||||
finally:
|
||||
del app.state.session_factory
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
finally:
|
||||
del app.state.settings
|
||||
del app.state.session_factory
|
||||
```
|
||||
|
||||
Lifespan does not construct resources per request. It enters the same framework-independent scope used by scripts, workers, and tests, keeps that scope open while requests are served, and lets it dispose the engine during shutdown.
|
||||
The application factory binds `settings` to lifespan, for example with `partial(lifespan, settings)`. Lifespan does not construct resources per request. It enters the same framework-independent scope used by scripts, workers, and tests, keeps that scope open while requests are served, and lets it dispose the engine and clear cached engine resolution during shutdown.
|
||||
|
||||
The template's unconditional `del app.state.session_factory` mirrors an expected successful startup. If `database_scope()` raises before assignment, cleanup can raise `AttributeError` and obscure the startup error. A production hardening option is to assign a sentinel before the `try` or delete conditionally; that changes failure behavior and is not part of the exact template mechanics.
|
||||
|
||||
Only store the engine too when application-level code genuinely needs direct Core operations, pool instrumentation, or engine-specific diagnostics. Routes and repositories should normally receive an `AsyncSession`.
|
||||
|
||||
@@ -66,14 +71,13 @@ from fastapi import Depends
|
||||
from fastapi import Request
|
||||
|
||||
from .session import SessionFactory
|
||||
from .session import transaction_scope
|
||||
|
||||
|
||||
def get_session_factory(request: Request) -> SessionFactory:
|
||||
def _get_session_factory(request: Request) -> SessionFactory:
|
||||
return request.app.state.session_factory
|
||||
|
||||
|
||||
type SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
||||
type SessionFactoryDep = Annotated[SessionFactory, Depends(_get_session_factory)]
|
||||
```
|
||||
|
||||
`Depends()` does not create or cache a factory here. It only exposes the lifespan-owned object. This function is also the narrow seam that tests can override when they need a different factory.
|
||||
@@ -90,31 +94,16 @@ from collections.abc import AsyncGenerator
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
async def get_session(session_factory: SessionFactoryDep) -> AsyncGenerator[AsyncSession]:
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
async def _get_session(session_factory: SessionFactoryDep) -> AsyncGenerator[AsyncSession]:
|
||||
async with session_factory() as owned_session:
|
||||
yield owned_session
|
||||
|
||||
|
||||
type SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
type SessionDep = Annotated[AsyncSession, Depends(_get_session)]
|
||||
```
|
||||
|
||||
The dependency creates and closes one session per request. Closing rolls back any unfinished autobegun transaction; it does not commit.
|
||||
|
||||
Use a separate dependency when the whole route is one write transaction:
|
||||
|
||||
```python
|
||||
async def get_transaction_session(session_factory: SessionFactoryDep) -> AsyncGenerator[AsyncSession]:
|
||||
async with transaction_scope(session_factory) as session:
|
||||
yield session
|
||||
|
||||
|
||||
type TransactionSessionDep = Annotated[AsyncSession, Depends(get_transaction_session)]
|
||||
```
|
||||
|
||||
This adapter uses `transaction_scope()` from [session management](session.md), so the same root transaction ownership applies inside and outside FastAPI.
|
||||
|
||||
Successful dependency exit commits and closes the session. Exceptional exit rolls back and closes it. Route and service code using `TransactionSessionDep` must not call `commit()`, `rollback()`, or `close()`.
|
||||
|
||||
---
|
||||
|
||||
## Route Usage
|
||||
@@ -131,16 +120,17 @@ Write route:
|
||||
|
||||
```python
|
||||
@router.post("/items")
|
||||
async def create_item(payload: ItemCreate, session: TransactionSessionDep) -> Item:
|
||||
return await insert_item(session, payload)
|
||||
async def create_item(payload: ItemCreate, session: SessionDep) -> Item:
|
||||
async with session.begin():
|
||||
return await insert_item(session, payload)
|
||||
```
|
||||
|
||||
Choose one write convention per application:
|
||||
The template exposes only `SessionDep`; it does not hide commit behavior in dependency teardown. Choose one visible write convention per application:
|
||||
|
||||
- inject `TransactionSessionDep` when the route itself is the complete transaction boundary, or
|
||||
- inject `SessionDep` and place `async with session.begin():` visibly around the service call.
|
||||
- place `async with session.begin():` around a complete write unit, which commits on success and rolls back on exception; or
|
||||
- call `await session.commit()` explicitly after all writes when the route is the complete unit, as the template's simple UI action does.
|
||||
|
||||
Do not combine both conventions in one route. Lower-level data-access functions continue to require an existing session and remain unaware of FastAPI.
|
||||
The context-manager form scales better to several statements and makes exception rollback visible. Direct `commit()` is concise but requires the route to preserve the single-commit invariant and handle any recovery needs. Do not combine both conventions in one route. Lower-level data-access functions receive the existing session and remain unaware of FastAPI.
|
||||
|
||||
---
|
||||
|
||||
@@ -162,17 +152,17 @@ If work must survive application shutdown, it needs an independently owned worke
|
||||
|
||||
Override the narrow dependency that matches the test objective:
|
||||
|
||||
- Override `get_session_factory` to preserve production request-session behavior with a test factory.
|
||||
- Override `get_session` when a test must inject one transaction-scoped session directly.
|
||||
- Override `_get_session_factory` to preserve production request-session behavior with a test factory.
|
||||
- Override `_get_session` when a test must inject one transaction-scoped session directly.
|
||||
- Verify each lifespan receives a fresh engine and session factory and removes application state during teardown.
|
||||
- Remove overrides during teardown so mutable application state does not leak between tests.
|
||||
|
||||
```python
|
||||
app.dependency_overrides[get_session] = get_test_session
|
||||
app.dependency_overrides[_get_session] = get_test_session
|
||||
try:
|
||||
yield app
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_session, None)
|
||||
app.dependency_overrides.pop(_get_session, None)
|
||||
```
|
||||
|
||||
See [database testing](testing.md) for outer transactions, SAVEPOINT-backed fixtures, and database target selection.
|
||||
@@ -185,7 +175,7 @@ See [database testing](testing.md) for outer transactions, SAVEPOINT-backed fixt
|
||||
- Reading settings and constructing database resources from repositories.
|
||||
- Storing one mutable `AsyncSession` on `app.state`.
|
||||
- Sharing a request session with concurrent or background tasks.
|
||||
- Calling `commit()` inside a route that uses `TransactionSessionDep`.
|
||||
- Assuming `SessionDep` commits when dependency cleanup runs.
|
||||
- Keeping `app.state.session_factory` after its `database_scope()` exits.
|
||||
- Using deprecated startup and shutdown event handlers alongside lifespan.
|
||||
|
||||
@@ -196,7 +186,7 @@ See [database testing](testing.md) for outer transactions, SAVEPOINT-backed fixt
|
||||
- Lifespan enters exactly one `database_scope()` for each application lifecycle.
|
||||
- Application state stores the yielded session factory.
|
||||
- Session dependencies create and close one session per request.
|
||||
- Read and transactional dependencies have distinct commit semantics.
|
||||
- The session dependency owns request session closure but not commit behavior.
|
||||
- Routes use `Annotated` aliases and receive sessions, not engines.
|
||||
- Background tasks create their own sessions from a still-live factory.
|
||||
- Tests override and restore dependencies deterministically.
|
||||
|
||||
@@ -8,15 +8,15 @@ Purpose: concept registry for the principles, mechanics, and implementation guid
|
||||
|
||||
| Concept | File | Status | Decision Level | Owner | Last Reviewed |
|
||||
|---|---|---|---|---|---|
|
||||
| Engine lifecycle and ownership | [engine.md](engine.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
||||
| Session factory and scope | [session.md](session.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
||||
| FastAPI lifespan and dependency injection | [fastapi.md](fastapi.md) | adopted | mandatory | platform/backend | 2026-07-31 |
|
||||
| Engine lifecycle and ownership | [engine.md](engine.md) | adopted | mandatory | platform/backend | 2026-08-06 |
|
||||
| Session factory and scope | [session.md](session.md) | adopted | mandatory | platform/backend | 2026-08-06 |
|
||||
| FastAPI lifespan and dependency injection | [fastapi.md](fastapi.md) | adopted | mandatory | platform/backend | 2026-08-06 |
|
||||
| Transaction boundaries | [transactions.md](transactions.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
||||
| Implicit ORM I/O under asyncio | [implicit_io.md](implicit_io.md) | adopted | advisory | platform/backend | 2026-06-17 |
|
||||
| Observability and resilience | [observability.md](observability.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
||||
| SQLModel modeling and async boundaries | [sqlmodel.md](sqlmodel.md) | adopted | mandatory | platform/backend | 2026-07-26 |
|
||||
| Basic CRUD repository and functions | [crud.md](crud.md) | adopted | advisory | platform/backend | 2026-07-26 |
|
||||
| Test database targets and fixture data | [testing.md](testing.md) | adopted | mandatory | platform/backend | 2026-07-30 |
|
||||
| SQLModel modeling and async boundaries | [sqlmodel.md](sqlmodel.md) | adopted | mandatory | platform/backend | 2026-08-06 |
|
||||
| Basic CRUD repository and functions | [crud.md](crud.md) | adopted | advisory | platform/backend | 2026-08-06 |
|
||||
| Test database targets and fixture data | [testing.md](testing.md) | adopted | mandatory | platform/backend | 2026-08-06 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- Status: adopted
|
||||
- Decision level: mandatory
|
||||
- Applies to: api-runtime, workers, tests
|
||||
- Last reviewed: 2026-07-26
|
||||
- Last reviewed: 2026-08-06
|
||||
|
||||
---
|
||||
|
||||
@@ -74,13 +74,12 @@ class UserRead(UserBase):
|
||||
```python
|
||||
from sqlmodel import select
|
||||
|
||||
async with engine_scope(settings.database_url) as engine:
|
||||
session_factory = create_session_factory(engine)
|
||||
async with database_scope(settings.database_url) as session_factory:
|
||||
async with session_factory() as session:
|
||||
users = (await session.scalars(select(User))).all()
|
||||
users = (await session.exec(select(User))).all()
|
||||
```
|
||||
|
||||
`engine_scope()` and `create_session_factory()` preserve the canonical lifecycle while SQLModel supplies the model and statement layer. `sqlmodel.select()` keeps SQLModel's typing-oriented statement construction, and SQLModel's `AsyncSession` adds typed `exec()` results while retaining SQLAlchemy's async lifecycle and transaction behavior. Import `AsyncSession` from `sqlmodel.ext.asyncio.session` when working with SQLModel models; use SQLAlchemy's `AsyncSession` only when the code intentionally has no SQLModel dependency.
|
||||
`database_scope()` enters the cached engine lifecycle, initializes registered SQLModel metadata by default, and yields the application session factory while SQLModel supplies the model and statement layer. `sqlmodel.select()` keeps SQLModel's typing-oriented statement construction, and SQLModel's `AsyncSession` adds typed `exec()` results while retaining SQLAlchemy's async lifecycle and transaction behavior. Import `AsyncSession` from `sqlmodel.ext.asyncio.session` when working with SQLModel models; use SQLAlchemy's `AsyncSession` only when the code intentionally has no SQLModel dependency.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ SQLite is a useful fast target, not a drop-in PostgreSQL substitute. Keep a smal
|
||||
|
||||
## Shared Construction Primitives
|
||||
|
||||
Make the application factory accept a database URL or settings object. Production, workers, and ordinary integration tests enter the same [`database_scope()`](engine.md#one-engine-context-manager). Tests enter the lower-level `engine_scope()` only when they need direct engine or connection ownership for schema setup, an outer transaction, or engine-specific assertions:
|
||||
Make the application factory accept a database URL or settings object. Production, workers, and ordinary integration tests enter the same [`database_scope()`](session.md#database-and-convenience-scopes). Tests enter the lower-level [`engine_scope()`](engine.md#owning-engine-scope) only when they need direct engine or connection ownership for schema setup, an outer transaction, or engine-specific assertions:
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncGenerator
|
||||
@@ -25,13 +25,13 @@ from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from .engine import engine_scope
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def test_engine(database_url: str) -> AsyncGenerator[AsyncEngine]:
|
||||
async with engine_scope(database_url) as engine:
|
||||
yield engine
|
||||
```
|
||||
|
||||
Production passes its `postgresql+asyncpg://...` URL to `database_scope()`. A local SQLite run passes `sqlite+aiosqlite:///./app.db`. Tests pass a dedicated test URL to `database_scope()` or `engine_scope()` and receive deterministic disposal when the context exits. Do not create an engine during module import: that makes it easy for tests to retain the production URL before an override is applied.
|
||||
Production passes its `postgresql+asyncpg://...` URL to `database_scope()`. A local SQLite run passes `sqlite+aiosqlite:///./app.db`. Tests pass a dedicated test URL to `database_scope()` or `engine_scope()` and receive schema initialization, deterministic disposal, and engine-cache cleanup when the context exits. Do not create an engine during module import: that makes it easy for tests to retain the production URL before an override is applied.
|
||||
|
||||
Use migrations to provision an integration database when migrations are part of the release contract. `metadata.create_all()` is appropriate for focused ORM tests only when it accurately represents the schema under test. Import all table models before creating metadata; [SQLModel documents that model-registration order matters](https://sqlmodel.tiangolo.com/tutorial/fastapi/tests/#import-table-models).
|
||||
|
||||
@@ -50,7 +50,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from .session import SessionFactory
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@pytest_asyncio.fixture(scope="function", loop_scope="session")
|
||||
async def session_factory(test_engine: AsyncEngine) -> AsyncGenerator[SessionFactory]:
|
||||
async with test_engine.connect() as connection:
|
||||
transaction = await connection.begin()
|
||||
@@ -68,19 +68,19 @@ async def session_factory(test_engine: AsyncEngine) -> AsyncGenerator[SessionFac
|
||||
|
||||
Each factory call still creates a distinct `AsyncSession`, matching [session factory mechanics](session.md#session-factory-mechanics). The factory belongs to the fixture's engine and outer transaction and must not escape either scope.
|
||||
|
||||
For service tests that need to pass a caller-owned active session into `transaction_scope(session=...)`, derive that session from the same factory:
|
||||
For service tests that pass a caller-owned session into decorated or undecorated service functions, derive that session from the same factory:
|
||||
|
||||
```python
|
||||
@pytest_asyncio.fixture
|
||||
@pytest_asyncio.fixture(scope="function", loop_scope="session")
|
||||
async def session(session_factory: SessionFactory) -> AsyncGenerator[AsyncSession]:
|
||||
async with session_factory() as test_session:
|
||||
await test_session.begin()
|
||||
yield test_session
|
||||
```
|
||||
|
||||
The explicit `begin()` satisfies the supplied-session contract from [session management](session.md#one-optional-ownership-helper). Session closure rolls back unfinished work; the outer connection transaction remains the final isolation boundary even if application code commits its SAVEPOINT.
|
||||
The explicit `begin()` gives test code one visible transaction from the start. Session closure rolls back unfinished work; the outer connection transaction remains the final isolation boundary even if application code commits its SAVEPOINT.
|
||||
|
||||
For FastAPI request tests, override `get_session_factory`, not only `get_session`. Both `SessionDep` and `TransactionSessionDep` then retain their production ownership behavior while receiving the test-bound factory. Always remove the override after the test because [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) are stored in a mutable application-level dictionary.
|
||||
For FastAPI request tests, override `_get_session_factory` so the production `SessionDep` retains its session-creation and cleanup behavior while receiving the test-bound factory. Always remove the override after the test because [FastAPI dependency overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/) are stored in a mutable application-level dictionary.
|
||||
|
||||
```python
|
||||
from collections.abc import Generator
|
||||
@@ -88,7 +88,7 @@ from collections.abc import Generator
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .fastapi import get_session_factory
|
||||
from .fastapi import _get_session_factory
|
||||
from .session import SessionFactory
|
||||
|
||||
|
||||
@@ -97,11 +97,11 @@ def app_with_test_database(app: FastAPI, session_factory: SessionFactory) -> Gen
|
||||
def get_test_session_factory() -> SessionFactory:
|
||||
return session_factory
|
||||
|
||||
app.dependency_overrides[get_session_factory] = get_test_session_factory
|
||||
app.dependency_overrides[_get_session_factory] = get_test_session_factory
|
||||
try:
|
||||
yield app
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_session_factory, None)
|
||||
app.dependency_overrides.pop(_get_session_factory, None)
|
||||
```
|
||||
|
||||
Construct `app` with test settings before lifespan starts so startup cannot resolve the production URL. The override changes request session creation; it does not prevent lifespan from entering its configured `database_scope()`.
|
||||
@@ -114,23 +114,19 @@ The connection-bound factory is deliberately serial even though it creates disti
|
||||
|
||||
Use `sqlite+aiosqlite://` for a fresh in-memory database when the test runs all database work serially. SQLAlchemy's `aiosqlite` dialect uses a single-connection `StaticPool` for this target, so all sessions share one SQLite transaction state. One session's rollback can discard another session's uncommitted work.
|
||||
|
||||
Create the schema and dispose the engine deterministically:
|
||||
`engine_scope()` imports the model package and creates the schema by default, then disposes the engine and clears cached resolution deterministically:
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from .engine import engine_scope
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def test_engine() -> AsyncGenerator[AsyncEngine]:
|
||||
async with engine_scope("sqlite+aiosqlite://") as engine:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
yield engine
|
||||
```
|
||||
|
||||
@@ -161,8 +157,8 @@ For both SQLite forms, enable and test the constraints your application depends
|
||||
|
||||
- A test run cannot reach the production URL; production credentials are absent from the test environment.
|
||||
- Production PostgreSQL, local SQLite, and in-memory SQLite all use `database_scope()` unless a test explicitly needs lower-level engine or connection ownership.
|
||||
- Every test owns its override, session factory, connection, transaction, session, and engine cleanup.
|
||||
- Request tests override `get_session_factory`, preserving both read-session and transactional-session dependency behavior.
|
||||
- Every test or fixture scope owns its override, session factory, connection, transaction, and session cleanup; the session-scoped engine fixture owns disposal and cache cleanup.
|
||||
- Request tests override `_get_session_factory`, preserving production request-session creation and cleanup behavior.
|
||||
- Test data is deterministic, minimal, and expresses the scenario under test.
|
||||
- PostgreSQL integration tests cover every PostgreSQL-specific contract and run against migrations where migrations are shipped.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user