template updates

This commit is contained in:
John Lancaster
2026-08-06 23:17:50 -05:00
parent 0dc06f72ca
commit 7ac90d29dd
8 changed files with 352 additions and 295 deletions
@@ -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.