Examples¶
End-to-end recipes. Each one is self-contained; the models below are reused throughout.
# myapp/models.py
from uuid import UUID
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from sqlargon import Base
from sqlargon.mixins import CreatedUpdatedMixin, SoftDeleteMixin, UUIDV7ModelMixin
from sqlargon.types import JSON
class User(UUIDV7ModelMixin, CreatedUpdatedMixin, SoftDeleteMixin, Base):
email: Mapped[str] = mapped_column(sa.Unicode(255), unique=True)
name: Mapped[str] = mapped_column(sa.Unicode(255))
preferences: Mapped[dict] = mapped_column(JSON(), default=dict)
class Order(UUIDV7ModelMixin, CreatedUpdatedMixin, Base):
user_id: Mapped[UUID] = mapped_column(sa.ForeignKey("user.id"))
total: Mapped[int] = mapped_column(sa.Integer())
status: Mapped[str] = mapped_column(sa.Unicode(32), default="new")
A FastAPI service¶
Repositories and units of work take no constructor arguments, so they are dependencies as they are. Nothing in the endpoints knows which database the work lands on.
# myapp/repositories.py
from collections.abc import Sequence
from sqlargon import SQLAlchemyRepository, SQLAlchemyUnitOfWork
from sqlargon.pagination import TotalPageNumberPagination
from .models import Order, User
class UserRepository(SQLAlchemyRepository[User]):
default_order_by = User.created_at.desc()
paginate = TotalPageNumberPagination(default_page_size=25)
async def active(self) -> Sequence[User]:
return await self.filter(User.not_deleted).all()
async def by_email(self, email: str) -> User | None:
return await self.get(email=email)
class OrderRepository(SQLAlchemyRepository[Order]):
default_order_by = Order.created_at.desc()
class OrdersUow(SQLAlchemyUnitOfWork):
users: UserRepository
orders: OrderRepository
# myapp/main.py
from contextlib import asynccontextmanager
from typing import Annotated
from uuid import UUID
from annotated_types import Interval
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from sqlargon import Database, set_default_database
from .repositories import OrdersUow, UserRepository
db = Database.from_env()
@asynccontextmanager
async def lifespan(app: FastAPI):
set_default_database(db)
await db.verify_connection()
yield
await db.dispose()
app = FastAPI(lifespan=lifespan)
PageNumber = Annotated[int, Interval(ge=1)]
PageSize = Annotated[int, Interval(ge=1, le=100)]
class UserOut(BaseModel):
id: UUID
email: str
name: str
model_config = {"from_attributes": True}
class UsersPage(BaseModel):
items: list[UserOut]
current_page: int
total_items: int
total_pages: int
model_config = {"from_attributes": True}
class OrderIn(BaseModel):
email: str
total: int
@app.get("/users", response_model=UsersPage)
async def list_users(
users: Annotated[UserRepository, Depends()],
page: PageNumber = 1,
page_size: PageSize = 25,
):
return await users.paginate(page, page_size)
@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: UUID, users: Annotated[UserRepository, Depends()]):
user = await users.get(id=user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@app.post("/orders", response_model=UserOut, status_code=201)
async def create_order(data: OrderIn, uow: Annotated[OrdersUow, Depends()]):
async with uow:
user = await uow.users.get_or_create(email=data.email, name=data.email)
await uow.orders.create(user_id=user.id, total=data.total)
return user
Both statements in create_order share the unit of work's transaction: if the order insert
fails, the user is not created either.
Idempotent ingestion¶
Upserts keyed on a natural key make a job safe to re-run, and exclude_set protects columns
that must not be overwritten:
from sqlargon.typing import OnConflictOptions
class UserRepository(SQLAlchemyRepository[User]):
@property
def on_conflict(self) -> OnConflictOptions:
return {
"index_elements": {"email"},
"set_": {"name", "preferences", "updated_at"},
"exclude_set": {"created_at"},
}
async def ingest(rows: list[dict]) -> None:
users = UserRepository()
for start in range(0, len(rows), 1_000):
await users.bulk_create_or_update(rows[start : start + 1_000])
To insert only what is missing and leave existing rows untouched, use bulk_create
(ON CONFLICT DO NOTHING) instead, and ask for the rows that were actually written:
Draining a work queue¶
get_chunk_for_update selects a locked chunk (FOR UPDATE SKIP LOCKED where the dialect
supports it), yields it, and then applies a final update — or deletes the rows — in the same
transaction. Several workers can run this loop concurrently without processing the same row
twice:
class OrderRepository(SQLAlchemyRepository[Order]):
async def process_pending(self) -> int:
async with self.get_chunk_for_update(
{"status": "done"}, # applied to the chunk after the block
limit=100,
on_="id",
order_by=Order.created_at,
status="new", # filter
) as orders:
for order in orders:
await handle(order)
return len(orders)
Pass None as the first argument to delete the chunk instead of updating it — the shape of
a classic outbox or dead-letter drain.
For read-only batch work, iterate pages instead and keep the reads off the primary:
from sqlargon.pagination import LimitOffsetPagination
class OrderRepository(SQLAlchemyRepository[Order]):
default_order_by = Order.id
paginate = LimitOffsetPagination(default_page_size=1_000)
async def export() -> None:
orders = OrderRepository().using(read_only=True)
async for page in orders.filter(status="done").paginate.pages():
await write_csv(page.items)
Streaming large result sets¶
stream() yields rows without loading the whole result into memory:
async def export_emails() -> None:
users = UserRepository()
async for row in users.select(User.email).stream():
await sink.write(row.email)
Multi-tenant sharding¶
A cluster maps tenants to databases; the unit of work resolves the shard once and pins it:
from fastapi import Header
from sqlargon import Database, DatabaseCluster, ShardRouter, set_default_database
SHARDS = {"eu": "shard_eu", "us": "shard_us"}
cluster = DatabaseCluster(
{
"shard_eu": Database("postgresql+asyncpg://shard-eu/app"),
"shard_us": Database("postgresql+asyncpg://shard-us/app"),
},
router=ShardRouter(SHARDS, default="shard_eu"),
default="shard_eu",
)
set_default_database(cluster)
def tenant_uow(region: str = Header(alias="X-Region")) -> OrdersUow:
return OrdersUow().using(shard_key=region)
@app.post("/orders")
async def create_order(data: OrderIn, uow: Annotated[OrdersUow, Depends(tenant_uow)]):
async with uow:
return await uow.orders.create(total=data.total)
A repository can be pointed at a shard the same way: repo.using(shard_key=region). See
Database Routing for replicas, ModelRouter and the full resolution order.
Reporting against a replica¶
import sqlalchemy as sa
from sqlargon import read_only, using
class OrderRepository(SQLAlchemyRepository[Order]):
@read_only
async def revenue(self) -> int:
return await self.select(sa.func.sum(Order.total)).scalar() or 0
# or per call site
with using("replica_0"):
totals = await OrderRepository().count()
A background job with a lock¶
Named locks keep a scheduled job single-instance across workers (natively on PostgreSQL and MySQL — see the dialects reference):
@db.with_lock(key="nightly-rollup")
@db.atomic
async def nightly_rollup() -> None:
orders = OrderRepository()
await orders.update_many({"status": "archived"}, Order.status == "done")
Testing¶
Point the default database at SQLite (or a disposable PostgreSQL) per test session and let each test start from a clean schema:
# tests/conftest.py
import pytest
from sqlargon import Database, set_default_database
from myapp.repositories import UserRepository
@pytest.fixture(scope="session")
def db():
return Database("sqlite+aiosqlite:///:memory:")
@pytest.fixture(autouse=True)
def default_database(db):
set_default_database(db)
yield db
set_default_database(None)
@pytest.fixture
async def schema(db):
await db.create_all()
yield
await db.drop_all()
@pytest.fixture
def users(schema) -> UserRepository:
return UserRepository()
async def test_create_user(users: UserRepository):
user = await users.create(email="a@example.com", name="A")
assert user is not None
assert await users.count() == 1
set_default_database(None) clears the default so no state leaks between tests. To exercise
a repository against a different database without touching the default, bind it explicitly:
Enable the connection tracker to assert that no connection is left checked out: