Skip to content

# 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`, 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`.

    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`. 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`):

    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)` 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.

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()`; 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:

    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` 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()` for sessions outside request handlers (e.g. background tasks, CLI commands). It commits on clean exit and rolls back on exception:

    async def seed():
        async with db.session() as session:
            ...

## Transactions

`transaction` opens a transaction on a session, using a savepoint when one is already open so it nests safely:

    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()` opens a session already inside a transaction:

    async with db.begin() as session:
        session.add(User(name="ada"))  # commits on exit, rolls back on exception

## Table locking

`db.lock_tables` acquires PostgreSQL table-level locks for a critical section. It opens a dedicated session internally and releases the lock when the context exits:

    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`: `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` is raised instead of a raw database error:

    async with db.lock_tables([Order], timeout="2s") as session:
        ...

## Advisory locking

`advisory_lock` acquires a PostgreSQL session-level advisory lock on a session you provide. The lock is released when the context exits:

    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` polls a row until a specific column changes value:

    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` (in `fastapi_toolsets.db.testing`) connects to server_url and issues a `CREATE DATABASE` statement:

    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` from the `pytest` module, which handles drop-before, create, and drop-after.

## Cleaning up tables

`cleanup_tables` (in `fastapi_toolsets.db.testing`) truncates all tables:

    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` inserts one or more rows into a secondary table:

    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:

    await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)

### `m2m_remove`: delete associations

`m2m_remove` deletes specific association rows. Removing a non-existent association is a no-op:

    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` replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association:

    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).

API Reference