mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-04 15:44:09 +00:00
276 lines
10 KiB
Markdown
276 lines
10 KiB
Markdown
# DB
|
|
|
|
SQLAlchemy async session management with transactions, table locking, advisory locking, and row-change polling.
|
|
|
|
!!! info
|
|
This module has been coded and tested to be compatible with PostgreSQL only.
|
|
|
|
## Overview
|
|
|
|
The `db` module is built around one object, [`Database`](../reference/db.md#fastapi_toolsets.db.Database), which owns the engine and sessionmaker and exposes the FastAPI dependency, a commit-before-response middleware, session/transaction context managers, and table locking. Free helpers cover savepoint-aware transactions, advisory locks, many-to-many association tables, and row-change polling.
|
|
|
|
## Setup
|
|
|
|
Create one `Database` for your app. Provide a **URL** (the facade builds and disposes the engine) or pass an existing **`engine=`** you own (e.g. for Alembic or `event.listen`). The session factory is built internally with `expire_on_commit=False`.
|
|
|
|
```python
|
|
from fastapi import Depends, FastAPI
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from fastapi_toolsets.db import Database
|
|
|
|
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
|
|
|
app = FastAPI()
|
|
db.install(app) # commit middleware + engine disposal on shutdown
|
|
|
|
|
|
@app.get("/users")
|
|
async def list_users(session: AsyncSession = Depends(db)): ...
|
|
```
|
|
|
|
The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
|
|
|
|
The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.PostgresDsn). In URL mode you can tune the engine: pass `connect_args` for DBAPI-level options and any other keyword for `create_async_engine` (e.g. `pool_size`, `echo`, `pool_pre_ping`):
|
|
|
|
```python
|
|
from pydantic_settings import BaseSettings
|
|
from pydantic import PostgresDsn
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
database_url: PostgresDsn
|
|
|
|
|
|
settings = Settings()
|
|
|
|
db = Database(
|
|
settings.database_url,
|
|
pool_size=20,
|
|
pool_pre_ping=True,
|
|
connect_args={"server_settings": {"application_name": "myapp"}},
|
|
)
|
|
```
|
|
|
|
## Committing before the response
|
|
|
|
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. With the middleware installed, the dependency does not commit again.
|
|
|
|
The request is committed as a single transaction:
|
|
|
|
- **Read-after-write**: a follow-up request sees the write.
|
|
- **Atomicity**: multi-write endpoints roll back as a unit on failure.
|
|
- **Errors roll back**: on a raised exception the session rolls back and nothing is committed.
|
|
|
|
Without `install`, the session commits in the dependency teardown, which runs after the response has been sent.
|
|
|
|
!!! warning "Streaming / SSE endpoints"
|
|
For a `StreamingResponse` / `EventSourceResponse`, the commit fires at the **start** of the stream. A stream that **writes** must open a short-lived session per write with [`db.session()`](#session-context-manager); the start-time commit will not flush writes made later during the stream.
|
|
|
|
## Lifespan
|
|
|
|
`db.install(app)` disposes the engine on shutdown, composing around your own lifespan:
|
|
|
|
```python
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
await warm_cache() # your startup
|
|
yield
|
|
await flush_metrics() # your shutdown
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
db.install(app) # your shutdown runs first, then the engine is disposed
|
|
```
|
|
|
|
If you have no lifespan of your own, [`db.lifespan`](../reference/db.md#fastapi_toolsets.db.Database) works standalone as `FastAPI(lifespan=db.lifespan)`. Engine disposal is idempotent and is a no-op when you passed your own `engine=`.
|
|
|
|
## Session context manager
|
|
|
|
Use [`db.session()`](../reference/db.md#fastapi_toolsets.db.Database) for sessions outside request handlers (e.g. background tasks, CLI commands). It commits on clean exit and rolls back on exception:
|
|
|
|
```python
|
|
async def seed():
|
|
async with db.session() as session:
|
|
...
|
|
```
|
|
|
|
## Transactions
|
|
|
|
[`transaction`](../reference/db.md#fastapi_toolsets.db.transaction) opens a transaction on a session, using a savepoint when one is already open so it nests safely:
|
|
|
|
```python
|
|
from fastapi_toolsets.db import transaction
|
|
|
|
|
|
async def create_user_with_role(session):
|
|
async with transaction(session):
|
|
...
|
|
async with transaction(session): # uses a savepoint
|
|
...
|
|
```
|
|
|
|
When you have a `Database`, [`db.begin()`](../reference/db.md#fastapi_toolsets.db.Database) opens a session already inside a transaction:
|
|
|
|
```python
|
|
async with db.begin() as session:
|
|
session.add(User(name="ada")) # commits on exit, rolls back on exception
|
|
```
|
|
|
|
## Table locking
|
|
|
|
[`db.lock_tables`](../reference/db.md#fastapi_toolsets.db.Database) acquires PostgreSQL table-level locks for a critical section. It opens a dedicated session internally and releases the lock when the context exits:
|
|
|
|
```python
|
|
from fastapi_toolsets.db import LockMode
|
|
|
|
async with db.lock_tables([User], mode=LockMode.EXCLUSIVE) as session:
|
|
# No other transaction can modify User until this block exits
|
|
...
|
|
```
|
|
|
|
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
|
|
|
Pass `timeout` to limit how long the lock waits. On timeout, a [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) is raised instead of a raw database error:
|
|
|
|
```python
|
|
async with db.lock_tables([Order], timeout="2s") as session:
|
|
...
|
|
```
|
|
|
|
## Advisory locking
|
|
|
|
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock on a session you provide. The lock is released when the context exits:
|
|
|
|
```python
|
|
from fastapi_toolsets.db import advisory_lock
|
|
|
|
# Blocking exclusive lock: waits until the lock is free
|
|
async with advisory_lock(session=session, key=42):
|
|
...
|
|
|
|
# Non-blocking: yields False immediately if already held
|
|
async with advisory_lock(session=session, key=42, nowait=True) as acquired:
|
|
if not acquired:
|
|
raise HTTPException(409, "Resource is locked")
|
|
|
|
# Blocking with a timeout: raises LockTimeoutError if not acquired in time
|
|
async with advisory_lock(session=session, key=42, timeout="5s"):
|
|
...
|
|
|
|
# Shared lock: multiple readers allowed simultaneously, blocks exclusive writers
|
|
async with advisory_lock(session=session, key=42, shared=True):
|
|
...
|
|
|
|
# Two-integer key for namespacing (e.g. lock_type + resource_id)
|
|
async with advisory_lock(session=session, key=(1, user_id)):
|
|
...
|
|
```
|
|
|
|
!!! note
|
|
Advisory locks use PostgreSQL session-level functions (`pg_advisory_lock` / `pg_advisory_unlock`). The lock is tied to the database connection, not the SQLAlchemy transaction, so it is released when the context exits even if the surrounding transaction is still open.
|
|
|
|
## Row-change polling
|
|
|
|
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value:
|
|
|
|
```python
|
|
from fastapi_toolsets.db import wait_for_row_change
|
|
|
|
# Wait up to 30s for order.status to change
|
|
await wait_for_row_change(
|
|
session=session,
|
|
model=Order,
|
|
pk_value=order_id,
|
|
columns=["status"],
|
|
interval=1.0,
|
|
timeout=30.0,
|
|
)
|
|
```
|
|
|
|
## Creating a database
|
|
|
|
[`create_database`](../reference/db.md#fastapi_toolsets.db.testing.create_database) (in `fastapi_toolsets.db.testing`) connects to *server_url* and issues a `CREATE DATABASE` statement:
|
|
|
|
```python
|
|
from fastapi_toolsets.db.testing import create_database
|
|
|
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
|
|
|
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
|
```
|
|
|
|
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module, which handles drop-before, create, and drop-after.
|
|
|
|
## Cleaning up tables
|
|
|
|
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) (in `fastapi_toolsets.db.testing`) truncates all tables:
|
|
|
|
```python
|
|
from fastapi_toolsets.db.testing import cleanup_tables
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def clean(db_session):
|
|
yield
|
|
await cleanup_tables(session=db_session, base=Base)
|
|
```
|
|
|
|
## Many-to-Many helpers
|
|
|
|
The three `m2m_*` helpers modify a many-to-many association table with direct SQL, without loading the ORM collection.
|
|
|
|
### `m2m_add`: insert associations
|
|
|
|
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table:
|
|
|
|
```python
|
|
from fastapi_toolsets.db import m2m_add
|
|
|
|
async with db.lock_tables([Tag]) as session:
|
|
tag = await TagCrud.create(session, TagCreate(name="python"))
|
|
await m2m_add(session, post, Post.tags, tag)
|
|
```
|
|
|
|
Pass `ignore_conflicts=True` to skip associations that already exist:
|
|
|
|
```python
|
|
await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)
|
|
```
|
|
|
|
### `m2m_remove`: delete associations
|
|
|
|
[`m2m_remove`](../reference/db.md#fastapi_toolsets.db.m2m_remove) deletes specific association rows. Removing a non-existent association is a no-op:
|
|
|
|
```python
|
|
from fastapi_toolsets.db import m2m_remove, transaction
|
|
|
|
async with transaction(session):
|
|
await m2m_remove(session, post, Post.tags, tag1, tag2)
|
|
```
|
|
|
|
### `m2m_set`: replace the full set
|
|
|
|
[`m2m_set`](../reference/db.md#fastapi_toolsets.db.m2m_set) replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association:
|
|
|
|
```python
|
|
from fastapi_toolsets.db import m2m_set, transaction
|
|
|
|
# Replace all tags
|
|
async with transaction(session):
|
|
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
|
|
|
# Clear all tags
|
|
async with transaction(session):
|
|
await m2m_set(session, post, Post.tags)
|
|
```
|
|
|
|
All three helpers raise `TypeError` if the relationship attribute is not a Many-to-Many (i.e. has no secondary table).
|
|
|
|
---
|
|
|
|
[:material-api: API Reference](../reference/db.md)
|