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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user