simplified a bit
This commit is contained in:
@@ -29,7 +29,7 @@ Define one canonical session model for FastAPI + SQLAlchemy asyncio:
|
||||
## Rules
|
||||
|
||||
- Create one cached `async_sessionmaker` per app-owned AsyncEngine.
|
||||
- Let repositories resolve the cached maker by database URL, with an injectable factory override for tests.
|
||||
- Let repositories resolve the cached maker by database URL.
|
||||
- Use a fresh AsyncSession for each request or explicit unit-of-work.
|
||||
- Pass an `AsyncSession` directly to data-access functions.
|
||||
- Borrow a caller-provided session without closing or committing it.
|
||||
@@ -40,6 +40,38 @@ Define one canonical session model for FastAPI + SQLAlchemy asyncio:
|
||||
|
||||
---
|
||||
|
||||
## Sessions and Transactions
|
||||
|
||||
A session and a transaction solve related but different problems:
|
||||
|
||||
| Concept | Responsibility | Typical lifetime |
|
||||
| --- | --- | --- |
|
||||
| `AsyncSession` | Provides the ORM workspace: executes queries, tracks loaded and changed objects in its identity map, and flushes pending changes. It also coordinates access to a database connection. | One request, task, or explicit unit of work. |
|
||||
| Transaction | Defines the atomic database boundary: all work inside it commits together on success or rolls back together on failure. | One complete operation that must have a single outcome. |
|
||||
|
||||
A transaction belongs to a session; it is not an alternative to one. The session is the interface used by application and data-access code, while the transaction determines when that work becomes permanent. A session may coordinate sequential transactions during its lifetime, although short-lived application scopes commonly use one session for one transaction.
|
||||
|
||||
Use a session without a helper-owned commit boundary for independent reads or lower-level functions that must participate in whatever transaction their caller controls:
|
||||
|
||||
```python
|
||||
async with session_factory() as session:
|
||||
item = await find_item(session, item_id)
|
||||
```
|
||||
|
||||
Use an explicit transaction for writes, read-modify-write operations, or several statements that must succeed or fail as one unit:
|
||||
|
||||
```python
|
||||
async with session_factory.begin() as session:
|
||||
order = await create_order(session, order_data)
|
||||
await reserve_inventory(session, order)
|
||||
```
|
||||
|
||||
SQLAlchemy sessions use [autobegin](https://docs.sqlalchemy.org/en/21/orm/session_basics.html#auto-begin), so the first database operation normally starts a transaction even for a read. Therefore, “session-only” means that the surrounding helper owns only session lifetime and does not promise to commit; it does not mean that no database transaction exists. Closing such a session releases its resources and rolls back any unfinished transaction. An explicit `begin()` is valuable when application code must make the atomic boundary and commit ownership visible.
|
||||
|
||||
For most read-only operations, a session context is sufficient. Use an explicit transaction for reads when they need a defined consistency boundary, participate in a larger atomic operation, or use locking such as `SELECT ... FOR UPDATE`.
|
||||
|
||||
---
|
||||
|
||||
## Session Factory Mechanics
|
||||
|
||||
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`.
|
||||
@@ -106,15 +138,12 @@ async def session_scope(
|
||||
*,
|
||||
database_url: str,
|
||||
session: AsyncSession | None = None,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
if session is not None:
|
||||
yield session
|
||||
return
|
||||
|
||||
active_factory = session_factory or get_session_factory(database_url)
|
||||
|
||||
async with active_factory() as owned_session:
|
||||
async with get_session_factory(database_url)() as owned_session:
|
||||
yield owned_session
|
||||
```
|
||||
|
||||
@@ -125,8 +154,7 @@ This helper manages session lifetime only:
|
||||
- It does not close, commit, or roll back a supplied session; the caller owns it.
|
||||
- It closes a session that it creates. Closing releases resources and rolls back an unfinished transaction; it does not commit.
|
||||
- It does not start a transaction. Put `session.begin()` at the use-case boundary.
|
||||
- A supplied session wins; neither the factory override nor cached factory is used.
|
||||
- A supplied factory overrides cached resolution, which keeps tests and specialized wiring explicit.
|
||||
- A supplied session wins; the cached factory is not resolved.
|
||||
- Otherwise, `database_url` selects the cached factory returned by `get_session_factory()`.
|
||||
|
||||
Do not turn this into an implicit unit-of-work helper that sometimes commits. Whether work joins an existing transaction or creates a new one must remain visible to the caller.
|
||||
@@ -143,7 +171,6 @@ async def transaction_scope(
|
||||
*,
|
||||
database_url: str,
|
||||
session: AsyncSession | None = None,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
if session is not None:
|
||||
if not session.in_transaction():
|
||||
@@ -151,9 +178,22 @@ async def transaction_scope(
|
||||
yield session
|
||||
return
|
||||
|
||||
active_factory = session_factory or get_session_factory(database_url)
|
||||
session_factory = get_session_factory(database_url)
|
||||
async with session_factory.begin() as owned_session:
|
||||
yield owned_session
|
||||
```
|
||||
|
||||
async with active_factory.begin() as owned_session:
|
||||
Here, `begin()` is intentionally called on the [`async_sessionmaker`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.async_sessionmaker.begin), not on an existing `AsyncSession`. The related APIs have different ownership semantics:
|
||||
|
||||
- `session_factory()` creates a session whose lifetime the surrounding code must manage; it does not commit automatically.
|
||||
- `session_factory.begin()` creates a new session and transaction together, commits on successful exit or rolls back on exceptional exit, and then closes the session.
|
||||
- `session.begin()` manages a transaction on an existing session but does not own or close that session.
|
||||
|
||||
The factory form is equivalent in ownership terms to creating a session and then entering that session's transaction:
|
||||
|
||||
```python
|
||||
async with session_factory() as owned_session:
|
||||
async with owned_session.begin():
|
||||
yield owned_session
|
||||
```
|
||||
|
||||
@@ -161,7 +201,6 @@ This helper makes transaction ownership follow the same explicit borrow-or-own m
|
||||
|
||||
- A supplied session and its active transaction remain caller-owned. The helper does not commit, roll back, or close them.
|
||||
- Without a supplied session, the helper owns the session and transaction. Successful exit commits; exceptional exit rolls back; either path closes the session.
|
||||
- A supplied factory overrides cached resolution only when the helper must create a session.
|
||||
- Use this helper only at a complete operation, service, or use-case boundary. A public CRUD function or repository method may be such a boundary when its optional-session contract explicitly states that omitting the session owns and commits one transaction. Never use it inside a lower-level session-required helper.
|
||||
- Do not silently begin a transaction on a supplied session. That would make commit ownership depend on hidden helper behavior.
|
||||
|
||||
@@ -183,11 +222,11 @@ await run_use_case(...)
|
||||
|
||||
## Repository and Function Boundaries
|
||||
|
||||
Pass the database URL to repository constructors. The repository stores repeatable database configuration, not mutable session state, and `session_scope()` resolves the cached factory when a standalone operation needs a session. An optional factory override keeps tests independent:
|
||||
Pass the database URL to repository constructors. The repository stores repeatable database configuration, not mutable session state, and `session_scope()` resolves the cached factory when a standalone operation needs a session:
|
||||
|
||||
```python
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def find_item(session: AsyncSession, item_id: int) -> Item | None:
|
||||
@@ -196,13 +235,8 @@ async def find_item(session: AsyncSession, item_id: int) -> Item | None:
|
||||
|
||||
|
||||
class ItemRepository:
|
||||
def __init__(
|
||||
self,
|
||||
database_url: str,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
) -> None:
|
||||
def __init__(self, database_url: str) -> None:
|
||||
self.database_url = database_url
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def find(
|
||||
self,
|
||||
@@ -213,7 +247,6 @@ class ItemRepository:
|
||||
async with session_scope(
|
||||
database_url=self.database_url,
|
||||
session=session,
|
||||
session_factory=self.session_factory,
|
||||
) as active_session:
|
||||
return await find_item(active_session, item_id)
|
||||
```
|
||||
@@ -221,11 +254,11 @@ class ItemRepository:
|
||||
This split gives each layer one job:
|
||||
|
||||
- The repository object identifies its database configuration and creates a session only for a standalone call.
|
||||
- Production calls reuse the cached factory; tests can inject a factory override.
|
||||
- Standalone calls reuse the cached factory selected by database URL.
|
||||
- A caller can pass a session to join an existing unit of work; the repository borrows it.
|
||||
- The access function owns only the query and requires an existing `AsyncSession`.
|
||||
- Application wiring supplies the production factory.
|
||||
- Tests can supply a factory bound to a test engine or call `find_item()` with a transaction-scoped test session.
|
||||
- Tests can use a test database URL or call `find_item()` with a transaction-scoped test session.
|
||||
|
||||
When several repository operations must share one transaction, pass the same session through each call. Put the transaction at the use-case boundary:
|
||||
|
||||
@@ -336,7 +369,7 @@ async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict:
|
||||
|
||||
## Testing Checks
|
||||
|
||||
- Repository constructors accept a test session factory without FastAPI startup.
|
||||
- Repository constructors accept a test database URL without FastAPI startup.
|
||||
- Session-taking access functions accept a transaction-scoped test session directly.
|
||||
- Optional-session tests verify that borrowed sessions remain open and created sessions close.
|
||||
- Optional-session tests verify that neither path commits implicitly.
|
||||
|
||||
Reference in New Issue
Block a user