simplified a bit

This commit is contained in:
John Lancaster
2026-07-26 19:45:25 -05:00
parent aed2e41ef0
commit b09409d842
2 changed files with 14 additions and 51 deletions
@@ -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.
@@ -106,15 +106,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 +122,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 +139,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 +146,7 @@ async def transaction_scope(
yield session
return
active_factory = session_factory or get_session_factory(database_url)
async with active_factory.begin() as owned_session:
async with get_session_factory(database_url).begin() as owned_session:
yield owned_session
```
@@ -161,7 +154,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 +175,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 +188,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 +200,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 +207,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 +322,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.