203 lines
7.1 KiB
Markdown
203 lines
7.1 KiB
Markdown
# FastAPI Database Integration
|
|
|
|
!!! info "Primary sources"
|
|
- [FastAPI lifespan events](https://fastapi.tiangolo.com/advanced/events/)
|
|
- [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)
|
|
|
|
---
|
|
|
|
## Purpose
|
|
|
|
Connect the framework-independent database tools to FastAPI:
|
|
|
|
- lifespan enters one application-owned `database_scope()`,
|
|
- application state holds the resulting session factory,
|
|
- dependencies create one session per request,
|
|
- `Annotated` aliases make route ownership concise and explicit.
|
|
|
|
The underlying resource and transaction rules remain in [engine lifecycle](engine.md), [session management](session.md), and [transaction boundaries](transactions.md).
|
|
|
|
---
|
|
|
|
## Lifespan Ownership
|
|
|
|
Enter `database_scope()` once for the complete application lifecycle. Store the session factory, not the engine, because request code needs sessions rather than direct pool access:
|
|
|
|
```python
|
|
from collections.abc import AsyncGenerator
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
|
|
from .engine import database_scope
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
database_url = app.state.settings.database_url
|
|
|
|
async with database_scope(database_url) as session_factory:
|
|
app.state.session_factory = session_factory
|
|
try:
|
|
yield
|
|
finally:
|
|
del app.state.session_factory
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
```
|
|
|
|
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.
|
|
|
|
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`.
|
|
|
|
---
|
|
|
|
## Session Factory Dependency
|
|
|
|
A synchronous dependency retrieves the already-created factory from application state:
|
|
|
|
```python
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends
|
|
from fastapi import Request
|
|
|
|
from .session import SessionFactory
|
|
from .session import transaction_scope
|
|
|
|
|
|
def get_session_factory(request: Request) -> SessionFactory:
|
|
return request.app.state.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.
|
|
|
|
---
|
|
|
|
## Request Session Dependencies
|
|
|
|
Use a session-only dependency for reads and other request conversations that must not commit implicitly:
|
|
|
|
```python
|
|
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
|
|
|
|
|
|
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
|
|
|
|
Read route:
|
|
|
|
```python
|
|
@router.get("/items/{item_id}")
|
|
async def get_item(item_id: int, session: SessionDep) -> Item | None:
|
|
return await find_item(session, item_id)
|
|
```
|
|
|
|
Write route:
|
|
|
|
```python
|
|
@router.post("/items")
|
|
async def create_item(payload: ItemCreate, session: TransactionSessionDep) -> Item:
|
|
return await insert_item(session, payload)
|
|
```
|
|
|
|
Choose one 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.
|
|
|
|
Do not combine both conventions in one route. Lower-level data-access functions continue to require an existing session and remain unaware of FastAPI.
|
|
|
|
---
|
|
|
|
## Background Work
|
|
|
|
A request session belongs to that request and must not be retained by a background task. Inject or otherwise provide the application session factory, then create a new session inside the task:
|
|
|
|
```python
|
|
async def run_background_job(session_factory: SessionFactory) -> None:
|
|
async with session_factory.begin() as session:
|
|
await process_pending_items(session)
|
|
```
|
|
|
|
If work must survive application shutdown, it needs an independently owned worker lifecycle rather than the FastAPI lifespan-owned factory.
|
|
|
|
---
|
|
|
|
## Testing and Overrides
|
|
|
|
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.
|
|
- 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
|
|
try:
|
|
yield app
|
|
finally:
|
|
app.dependency_overrides.pop(get_session, None)
|
|
```
|
|
|
|
See [database testing](testing.md) for outer transactions, SAVEPOINT-backed fixtures, and database target selection.
|
|
|
|
---
|
|
|
|
## Anti-Patterns
|
|
|
|
- Creating an engine or session factory in a request dependency.
|
|
- 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`.
|
|
- Keeping `app.state.session_factory` after its `database_scope()` exits.
|
|
- Using deprecated startup and shutdown event handlers alongside lifespan.
|
|
|
|
---
|
|
|
|
## Integration Checklist
|
|
|
|
- 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.
|
|
- 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.
|