Banco de dados¶
Esta é a camada que toda service Tempest usa para falar com PostgreSQL (produção) ou SQLite (desenvolvimento/testes) sobre SQLAlchemy 2.0 async. Ela existe para que você nunca reescreva a mesma engine, a mesma sessão por request, o mesmo CRUD e a mesma paginação em cada projeto.
Instalação
O núcleo de banco já vem com tempest-fastapi-sdk. Os drivers async
entram por extra — uv add "tempest-fastapi-sdk[postgres]" (PostgreSQL,
traz asyncpg) ou [sqlite] (SQLite em dev, traz aiosqlite).
São quatro peças, e você vai conhecê-las uma de cada vez:
| Peça | Símbolo | Para quê |
|---|---|---|
| Modelo base | BaseModel |
As quatro colunas canônicas (id / is_active / created_at / updated_at) + helpers de serialização. |
| Conexão | AsyncDatabaseManager |
Engine, pool, sessão por request, health_check. |
| Repository | BaseRepository[Model] |
CRUD async, filtros por convenção, operações em lote, paginação. |
| Migrações | AlembicHelper |
Bootstrap do Alembic, autogenerate, gate de drift no CI. |
Mais três opcionais que entram quando o domínio pede: os mixins
(SoftDeleteMixin, AuditMixin, MFAMixin), a paginação por cursor
e o SlowQueryLogger.
Como ler esta página
Ela é progressiva. Comece pelo modelo, conecte o banco, suba um repository, aprenda os filtros, então paginação, migrações e observabilidade. Cada bloco de código é um arquivo completo — copie, cole, rode. Se você só quer a referência da API, pule para Referência ».
1. O modelo base¶
Todo modelo da sua service herda de BaseModel. Você ganha quatro
colunas sem escrever nenhuma:
# src/db/models/user.py
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
class UserModel(BaseModel):
"""Users table."""
name: Mapped[str] = mapped_column()
email: Mapped[str] = mapped_column(unique=True)
password_hash: Mapped[str] = mapped_column()
Isso já cria a tabela user com sete colunas: as três suas
(name, email, password_hash) mais as quatro herdadas:
| Coluna | Tipo | Padrão | Papel |
|---|---|---|---|
id |
UUID (v4) |
uuid4() |
Chave primária, portável entre Postgres/SQLite/MySQL/MSSQL. |
is_active |
bool |
True |
Flag de soft-delete rápido. |
created_at |
datetime (tz-aware) |
utcnow() no flush |
Carimbo de criação. |
updated_at |
datetime (tz-aware) |
utcnow() no onupdate |
Carimbo da última escrita. |
Por que o nome da tabela é user e não UserModel?
BaseModel deriva __tablename__ da classe automaticamente: tira o
sufixo Model e converte para snake_case. UserModel → user,
OrderItemModel → order_item. Você sempre pode fixar
__tablename__ = "users" explicitamente — a declaração explícita
vence o automático.
Fixar o nome não é só gosto
USER é palavra reservada no SQL padrão. O SQLAlchemy sempre escreve
o identificador entre aspas, então a aplicação funciona — mas um
SELECT * FROM user digitado à mão no psql devolve o usuário do
banco, não a sua tabela, e sem erro nenhum. O plural (users)
evita a pegadinha e é a convenção que o próprio SDK assume nas
tabelas de token (user_tokens, user_refresh_tokens).
Centralizando os nomes das tabelas¶
Um nome de tabela quase nunca aparece só uma vez. Ele está no
__tablename__ e volta como string em toda ForeignKey que aponta
para ela:
# src/db/models/user_token.py
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
class UserTokenModel(BaseModel):
__tablename__ = "user_tokens"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
)
Renomear users passa a depender de você lembrar de cada lugar em que a
string aparece. E esquecer uma FK não estoura na hora: o SQLAlchemy só
resolve o alvo quando monta os mapeamentos, então o erro chega na
subida da aplicação — ou, pior, numa migração que aponta para uma
tabela que não existe mais.
A saída é um módulo que só guarda nomes:
# src/db/configs/names.py
"""Nomes das tabelas do projeto. Fonte única de verdade."""
USER_TABLE_NAME = "users"
USER_TOKEN_TABLE_NAME = "user_tokens"
USER_REFRESH_TOKEN_TABLE_NAME = "user_refresh_tokens"
ORDER_TABLE_NAME = "orders"
ORDER_ITEM_TABLE_NAME = "order_items"
O sufixo _TABLE_NAME deixa a constante autoexplicativa no ponto de
uso, onde ela aparece longe deste arquivo: ForeignKey(f"{USER_TABLE_NAME}.id")
diz sozinho o que é aquela string. O prefixo acompanha o modelo, no
singular (UserTokenModel → USER_TOKEN_TABLE_NAME), mesmo quando o
valor é plural.
Cada modelo passa a importar dali, dos dois lados da relação:
# src/db/models/user.py
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
from src.db.configs.names import USER_TABLE_NAME
class UserModel(BaseModel):
__tablename__ = USER_TABLE_NAME
email: Mapped[str] = mapped_column(unique=True)
# src/db/models/user_token.py
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
from src.db.configs.names import USER_TABLE_NAME, USER_TOKEN_TABLE_NAME
class UserTokenModel(BaseModel):
__tablename__ = USER_TOKEN_TABLE_NAME
user_id: Mapped[UUID] = mapped_column(
ForeignKey(f"{USER_TABLE_NAME}.id", ondelete="CASCADE"),
index=True,
)
O ganho aparece na hora de mexer. Renomear uma tabela vira uma linha
em names.py. O "find usages" do editor encontra todo mundo que depende
dela, porque virou símbolo em vez de string solta. E nenhuma
ForeignKey consegue apontar para uma tabela que não existe mais sem que
o import quebre antes.
Por que db/configs/ e não core/constants.py?
Nome de tabela é detalhe do banco, e quem consome é db/models/.
Deixar em db/ mantém a dependência dentro da própria camada, e o
módulo fica sem import nenhum do projeto — ele é só string. É isso
que garante que ele nunca participe de um ciclo: models importa
configs, e configs não importa ninguém.
Vale também para as tabelas do SDK
Os modelos abstratos que o SDK entrega (BaseUserModel,
BaseUserTokenModel, BaseUserRefreshTokenModel,
BaseWebPushSubscriptionModel, BaseOutboxModel) deixam de propósito
o __tablename__ e a FK para o projeto concreto declarar — justamente
para que os dois saiam do seu names.py, sob a sua convenção.
__tablename__ explícito com o Pyright¶
BaseModel declara __tablename__ como um @declared_attr.directive,
que é o mecanismo do SQLAlchemy 2.0 para derivar o nome a partir da
classe. O mypy entende que uma subclasse sobrescreva isso com uma string
e não reclama — é o checker que tempest type roda, então o gate
padrão passa limpo.
O Pyright é mais estrito: lê o atributo herdado como variável mutável de tipo invariante e acusa a atribuição.
reportIncompatibleVariableOverride no Pyright/Pylance
Type "Literal['users']" is not assignable to declared type
"_declared_directive[str]" (reportAssignmentType)
Não é defeito no seu modelo: em tempo de execução a atribuição funciona, e é a forma usada em toda esta documentação. É o Pyright sendo mais rígido que o mypy sobre override de descritor.
Se o seu editor roda Pyright e você quer o arquivo limpo, declare o nome pelo mesmo mecanismo da classe base:
from sqlalchemy.orm import declared_attr
from tempest_fastapi_sdk import BaseModel
from src.db.configs.names import USER_TABLE_NAME
class UserModel(BaseModel):
@declared_attr.directive
def __tablename__(cls) -> str: # noqa: N805
"""Fixa o nome da tabela."""
return USER_TABLE_NAME
Mais verboso, e equivalente em runtime. Escolha pelo checker do projeto:
com mypy (ou sem checagem estática no editor), prefira
__tablename__ = USER_TABLE_NAME, que é mais direto de ler.
Convenção de nomes de constraints¶
BaseModel.metadata já vem configurado com NAMING_CONVENTION. Isso faz
toda PK/FK/índice/unique/check receber um nome determinístico —
ix_user_email, uq_user_email, fk_order_user_id_user — igual em toda
máquina e todo engine.
O ganho real está nas migrações
Sem nomes determinísticos, o alembic revision --autogenerate
inventa identificadores aleatórios e cada desenvolvedor gera um diff
diferente para o mesmo schema. Com a convenção, o autogenerate só
emite diffs de schema reais — sem churn de nomes.
Unique composta — o nome leva todas as colunas¶
Uma regra de unicidade que envolve mais de uma coluna ("o mesmo título não
pode repetir no mesmo ano") é uma UniqueConstraint com várias colunas em
__table_args__. Você não precisa dar nome a ela — a convenção dá:
# src/db/models/book.py
from sqlalchemy import Index, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel
class BookModel(BaseModel):
"""Livros do catálogo."""
__tablename__ = "books"
__table_args__ = (
UniqueConstraint("isbn"),
UniqueConstraint("title", "release_year"),
UniqueConstraint("title", "author"),
Index(None, "author", "title"),
)
isbn: Mapped[str] = mapped_column()
title: Mapped[str] = mapped_column()
release_year: Mapped[int] = mapped_column()
author: Mapped[str] = mapped_column()
names: list[str] = sorted(
str(item.name)
for item in [*BookModel.__table__.constraints, *BookModel.__table__.indexes]
)
print(names)
Saída:
['ix_books_author_books_title', 'pk_books', 'uq_books_isbn', 'uq_books_title_author', 'uq_books_title_release_year']
Cada constraint composta recebe todas as colunas no nome, unidas por _:
uq_books_title_release_year e uq_books_title_author são duas regras
diferentes e têm dois nomes diferentes. Os templates usam os tokens
column_0_N_* do SQLAlchemy, que para uma coluna só renderizam exatamente o
que os column_0_* renderizavam — então uq_books_isbn, ix_user_email e
fk_order_user_id_user continuam iguais.
Nome distinto é o que torna útil o constraint que o Postgres devolve em
parse_integrity_error: um
dicionário nome → código de erro passa a apontar para uma regra só.
from typing import Any
from sqlalchemy.exc import IntegrityError
from tempest_fastapi_sdk import parse_integrity_error
CONFLICT_CODES: dict[str, str] = {
"uq_books_isbn": "ISBN_TAKEN",
"uq_books_title_release_year": "TITLE_TAKEN_THIS_YEAR",
"uq_books_title_author": "AUTHOR_ALREADY_HAS_TITLE",
}
def to_conflict(error: IntegrityError) -> dict[str, Any]:
"""Traduz a violação no código que o cliente ramifica."""
failure = parse_integrity_error(error)
return {
"code": CONFLICT_CODES.get(failure.constraint or "", "CONFLICT"),
"fields": list(failure.columns),
}
Antes desta correção, as duas uniques tinham o mesmo nome
Até a versão anterior, os templates de uq, ix e fk liam só a
primeira coluna. UniqueConstraint("title") e
UniqueConstraint("title", "release_year") viravam ambas
uq_books_title. O PostgreSQL recusa o CREATE TABLE com
relation "uq_books_title" already exists; o SQLite aceita as duas, e é
por isso que o defeito passava no banco de teste. Quando o banco foi
criado com a convenção antiga, as constraints compostas dele ainda têm o
nome antigo — o passo a passo para renomear está em
Migrar constraints compostas da convenção antiga.
Nome acima de 63 caracteres
O PostgreSQL aceita identificador de até 63 caracteres. Quando o nome da
convenção passa disso, o SQLAlchemy encurta na hora de emitir o DDL: os
55 primeiros caracteres, _ e os 4 últimos dígitos hexadecimais do MD5
do nome completo — uq_subscription_billing_events_customer_identifier_bill_f06e
para uma unique de 90 caracteres. É o mesmo em toda execução, e o
autogenerate não acusa diferença depois (medido contra PostgreSQL 16). O
SQLite não tem limite e guarda o nome inteiro.
A única ambiguidade que sobra
Juntar com _ deixa um caso que a convenção não separa: colunas
("a_b", "c") e ("a", "b_c") na mesma tabela geram o mesmo nome. Se o
seu schema tem esse par, dê name= explícito a uma delas — nome
explícito vence a convenção.
Helpers que vêm de graça¶
Toda instância de BaseModel ganha:
# Serializar para dict (útil em logs/testes)
from typing import Any
from src.db.models import UserModel
from src.schemas import UserUpdateSchema
payload = UserUpdateSchema(name="Ana Paula")
user = UserModel(name="Ana", email="ana@example.com")
data: dict[str, Any] = user.to_dict(exclude=["password_hash"])
# Atribuir vários campos de uma vez, com whitelist contra mass-assignment
user.update_from_dict(
payload.model_dump(exclude_unset=True),
allowed_fields={"name", "email"}, # id/role nunca são escritos
)
__eq__ e __hash__ comparam por (tipo, id), então a mesma linha
carregada em sessões diferentes é igual — prático em testes e sets.
Linhas ainda não persistidas (id is None) caem para identidade Python.
Use sempre allowed_fields em payloads externos
update_from_dict sem allowed_fields aceita qualquer coluna
mapeada. Para corpos de PATCH vindos do cliente, passe a whitelist —
é a defesa contra mass-assignment em colunas sensíveis (id, role,
is_active).
O nome da tabela — get_table_name()¶
Chave de dict, lookup no painel, assert de teste: quando você precisa do nome da tabela, peça ao model, não ao dunder:
from tempest_fastapi_sdk import AdminModel, AdminSite, BaseModel
class OrderItemModel(BaseModel):
"""Item de um pedido."""
print(OrderItemModel.get_table_name()) # order_item
site = AdminSite(title="Painel")
site.register(AdminModel(model=OrderItemModel))
admin = site.get(OrderItemModel)
get_table_name() lê a tabela que o mapper construiu, não o
__tablename__: responde o nome derivado da classe, o __tablename__
explícito e também o model declarado com __table__ = Table(...), que
não tem __tablename__ nenhum. Numa herança de tabela única, a subclasse
responde a tabela do pai, onde as linhas dela moram. O retorno é str para
o type-checker, e AdminSite.get / require / unregister aceitam a
própria classe no lugar da string.
Recap: herde BaseModel, declare só as colunas do seu domínio, e o
SDK entrega id/timestamps/soft-delete, nomes de constraint determinísticos
(unique composta com todas as colunas no nome) e helpers de serialização.
2. Conectando ao banco¶
AsyncDatabaseManager é instanciado uma vez por aplicação e cuida da
engine, do pool e da fábrica de sessões. Coloque-o nas dependências de
infraestrutura, não dentro do app.py:
# src/api/dependencies/resources.py
from tempest_fastapi_sdk import AsyncDatabaseManager
from src.core.settings import settings
db = AsyncDatabaseManager(
settings.DATABASE_URL,
echo=settings.DEBUG, # ecoa SQL no stdout em dev
pool_size=10, # ignorado para SQLite
max_overflow=20,
pool_recycle=3600,
)
Ele detecta o backend pela URL (make_url), então SQLite ganha
check_same_thread=False automaticamente e os parâmetros de pool são
ignorados — não há truque de substring.
Uma sessão por request¶
Use session_dependency como dependência do FastAPI. Ela entrega uma
sessão por request e não faz commit no sucesso — o commit é
responsabilidade da camada de repository/service:
# src/api/dependencies/resources.py (continuação)
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.resources import db
SessionDep = Annotated[AsyncSession, Depends(db.session_dependency)]
# src/api/routers/user.py
from uuid import UUID
from fastapi import APIRouter
from src.api.dependencies.resources import SessionDep
from src.db.repositories import UserRepository
from src.schemas import UserResponse
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: UUID, session: SessionDep) -> UserResponse:
"""Fetch a single user by id."""
repository = UserRepository(session)
return repository.map_to_response(await repository.get_by_id(user_id))
Manager criado sob demanda — session_dependency_for¶
Depends(db.session_dependency) exige que db já exista quando o módulo é
importado. Tem serviço que prefere construir o manager só na primeira
chamada: para ler as settings depois do guard de boot, e para os testes
trocarem a DATABASE_URL antes de qualquer engine nascer. Nesse caso,
entregue a fábrica para session_dependency_for:
# src/api/dependencies/resources.py
import os
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import AsyncDatabaseManager, session_dependency_for
@lru_cache
def get_db() -> AsyncDatabaseManager:
"""Build the manager on first use, never at import."""
return AsyncDatabaseManager(os.environ["DATABASE_URL"])
get_session = session_dependency_for(get_db)
SessionDep = Annotated[AsyncSession, Depends(get_session)]
A dependency devolvida tem a mesma semântica de session_dependency: uma
sessão por request, sem commit no sucesso, e a sessão fechada no fim do
request — também quando o endpoint levanta. get_db é chamado a cada
request, nunca no import; com o @lru_cache, todo request recebe o mesmo
manager.
Crie a dependency uma vez, no nível do módulo
O FastAPI faz cache de dependency por request pela identidade do
callable. Duas chamadas a session_dependency_for(get_db) devolvem duas
funções diferentes — e, usadas no mesmo request, duas sessões. Guarde o
resultado num nome (get_session) e reuse.
Ciclo de vida no lifespan¶
Abra e feche a engine junto com a aplicação:
# src/api/app.py
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from src.api.dependencies.resources import db
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Open the database on startup, dispose it on shutdown."""
await db.connect()
yield
await db.disconnect()
Health check¶
health_check() roda um SELECT 1 e engole qualquer exceção, devolvendo
só True/False — perfeito para /health:
from fastapi import APIRouter
from src.api.dependencies.resources import db
router = APIRouter()
@router.get("/health")
async def health() -> dict[str, object]:
"""Liveness + database probe."""
return {
"status": "ok",
"database": await db.health_check(),
"url": db.db_url_safe, # credenciais mascaradas
}
Outras formas de obter sessão
db.get_session_context()/db.transaction()— o mesmo context manager com dois nomes: faz commit na saída e rollback no erro. Use em scripts e tasks de background.db.get_session()— sessão crua; você fecha.db.create_tables()/db.drop_tables()— só para testes e dev local; em produção o schema é do Alembic. Sem argumento, só os models doBaseModeldo SDK — veja Sua própriaDeclarativeBase.
get_session_context() commita — nunca numa dependency de request
O código depois do yield de uma dependency roda depois que a
resposta saiu. Medido no FastAPI 0.141.1, com um middleware ASGI
registrando as mensagens: http.response.start e http.response.body
são enviadas antes do COMMIT. Uma dependency que embrulha
get_session_context() responde 204 para um DELETE cujo commit
ainda não aconteceu — o GET seguinte do cliente pode ver a linha — e,
se esse commit falhar, o status que o cliente já recebeu não muda mais.
Para request use session_dependency (ou session_dependency_for) e
commite na camada de service/repository.
create_tables() é no-op silencioso em tabela que já existe
create_all é CREATE TABLE IF NOT EXISTS: contra uma tabela existente
ele não adiciona coluna nenhuma, não falha e não avisa. Usá-lo no boot
e carimbar head em seguida deixa o schema velho com o Alembic se
declarando em dia — a falha aparece semanas depois, longe da causa. O
bootstrap correto, para os três estados possíveis do banco, está em
Migrations ».
Nunca logue a URL crua, sempre db_url_safe
A URL crua carrega usuário e senha. db_url_safe troca só a senha:
postgresql+asyncpg://user:pass@host:5432/db sai como
postgresql+asyncpg://user:***@host:5432/db — usuário, host, porta,
banco e query string continuam visíveis, então trate o resultado como
diagnóstico interno se o próprio usuário do banco for sensível. A URL
crua fica num atributo privado justamente para não vazar em repr() ou
log acidental.
Sua própria DeclarativeBase¶
create_tables() sem argumento cria só o que herda o BaseModel do SDK. Um
serviço com a própria DeclarativeBase — porque endereça por chave natural,
por exemplo, e o BaseModel impõe UUID e timestamps — não ganha tabela
nenhuma: a primeira query falha com no such table (SQLite). Passe a
metadata da sua base:
import asyncio
from sqlalchemy import Integer, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from tempest_fastapi_sdk import AsyncDatabaseManager
class Base(DeclarativeBase):
"""A base of your own: natural key, no BaseModel UUID or timestamps."""
class ObjectModel(Base):
__tablename__ = "objects"
bucket: Mapped[str] = mapped_column(String(63), primary_key=True)
key: Mapped[str] = mapped_column(String(1024), primary_key=True)
size: Mapped[int] = mapped_column(Integer, nullable=False)
async def main() -> None:
db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.create_tables(Base.metadata)
async with db.transaction() as session:
session.add(ObjectModel(bucket="photos", key="cat.jpg", size=2048))
async with db.transaction() as session:
rows = (await session.scalars(select(ObjectModel))).all()
print([(row.bucket, row.key, row.size) for row in rows])
await db.disconnect()
asyncio.run(main())
Saída: [('photos', 'cat.jpg', 2048)]. drop_tables(Base.metadata) faz o
caminho inverso. Os dois continuam sendo ferramenta de teste e dev local.
Fora de um request¶
Nem todo consumidor tem uma requisição onde pendurar o Depends. Uma
ferramenta de agente, uma task TaskIQ, um consumer FastStream e um script de
manutenção rodam fora do ciclo HTTP — e todos usam get_session_context(),
que abre a sessão, confirma ao sair e faz rollback no erro:
# src/tasks/cleanup.py
from src.api.dependencies.resources import db
from src.db.repositories import UserRepository
async def count_inactive_users() -> int:
"""Count the users that were deactivated."""
async with db.get_session_context() as session:
repository = UserRepository(session)
return len(await repository.list(filters={"is_active": False}))
A regra é abrir o mais tarde possível e fechar o quanto antes: um processo que segura a sessão enquanto espera outra coisa — um modelo gerando tokens, uma API externa respondendo — ocupa uma conexão do pool sem usá-la.
Ferramenta de agente é o caso mais delicado
Uma execução de agente atravessa vários passos e pode levar minutos.
Agentes de IA (banco de dados) » mostra por que a sessão é
aberta dentro de cada ferramenta, o que o commit automático significa
para uma ferramenta que escreve, e por que dois AsyncDatabaseManager no
mesmo processo são dois pools.
SQLite com um worker: WAL e busy timeout¶
No dia em que a aplicação ganha um worker, o SQLite de desenvolvimento
passa a ter dois processos escrevendo no mesmo arquivo. No journal
padrão (delete) um leitor e um escritor se excluem, então o segundo
morre:
sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) database is locked
[SQL: INSERT INTO budget_drafts (...) VALUES (?, ?, ...)]
Medido entre dois processos — um segurando uma transação de leitura aberta enquanto o outro insere:
journal_mode |
O que acontece com o escritor |
|---|---|
delete |
espera o busy_timeout inteiro e falha com database is locked |
wal |
commita na hora |
Por isso o AsyncDatabaseManager abre todo arquivo SQLite em WAL,
com busy_timeout de 30 segundos. Não é preciso pedir:
from tempest_fastapi_sdk import AsyncDatabaseManager
db = AsyncDatabaseManager("sqlite+aiosqlite:///./app.db")
# journal_mode = wal, busy_timeout = 30000 (ms)
Os dois são reguláveis, e ignorados em qualquer outro backend:
from tempest_fastapi_sdk import AsyncDatabaseManager
db = AsyncDatabaseManager(
"sqlite+aiosqlite:///./app.db",
sqlite_wal=False, # sistemas de arquivos sem shared memory
sqlite_busy_timeout=5.0, # segundos
)
Pelo ambiente, via DatabaseSettings: DATABASE_SQLITE_WAL e
DATABASE_SQLITE_BUSY_TIMEOUT.
WAL é propriedade do arquivo
Basta ligar uma vez: o modo sobrevive ao processo e todo conexão
posterior, de qualquer processo, já abre o arquivo em WAL. Em banco
:memory: o pragma é inócuo — o SQLite responde memory e segue.
:memory: recebe conexão de verdade por sessão (v0.252.0)
sqlite+aiosqlite:///:memory: faz o SQLAlchemy escolher StaticPool:
uma conexão DBAPI compartilhada por todas as sessões. Junto com o
BEGIN explícito que o manager emite desde a v0.200.0 — necessário para
o RELEASE SAVEPOINT parar de comitar no SQLite — isso quebrava qualquer
par de sessões sobrepostas com cannot start a transaction within a
transaction. Pega o padrão de teste que este SDK recomenda, e pega um
endpoint que responde e termina o trabalho num BackgroundTasks.
O manager passa a reescrever a URL para um banco in-memory de cache
compartilhado (file:<nome>?mode=memory&cache=shared&uri=true), com
pool normal, e mantém uma conexão viva enquanto o manager existe — um
banco in-memory de cache compartilhado é destruído quando a última
conexão fecha. Cada manager recebe um nome próprio, então dois managers
continuam isolados.
Medido nas duas propriedades: sessão sobreposta funciona e bloco
aninhado que sai limpo continua não durável depois de um rollback
externo. Tirar o BEGIN — a saída mais óbvia — dá a primeira e perde a
segunda.
Precisa da topologia antiga? Passe poolclass=StaticPool
explicitamente: pool informado pelo caller nunca é sobrescrito.
O que esperar não conserta
WAL admite um escritor por vez; os outros aguardam o
busy_timeout. O que timeout nenhum resolve é uma transação que
lê primeiro e escreve depois: promover o lock falha na hora se
outra conexão escreveu no meio, e o busy_timeout não se aplica
porque não há o que aguardar. Em trabalho longo: reivindique a
linha, faça o trabalho sem sessão aberta, e só então persista.
COMMIT recusado não suja mais a conexão do pool
Sem WAL, um leitor aberto em outra conexão faz o COMMIT falhar com
database is locked. A transação continua aberta no driver, e o
SQLAlchemy não a fecha: a conexão voltava ao pool dentro do BEGIN, e o
próximo uso dela falhava com cannot start a transaction within a
transaction. O enable_sqlite_savepoints — que o manager e o
create_test_engine aplicam — dá ROLLBACK na devolução ao pool sempre
que o driver ainda está em transação. O COMMIT recusado continua
levantando OperationalError; o que muda é que a conexão seguinte sai
limpa, e a escrita recusada não fica durável. Engine montado à mão
precisa chamar enable_sqlite_savepoints para ganhar o mesmo reset.
SQLite com chave estrangeira: PRAGMA foreign_keys¶
O SQLite lê a cláusula REFERENCES, mas só confere a chave estrangeira
numa conexão que rodou PRAGMA foreign_keys=ON — desligado é o default dele,
e o pragma vale por conexão, não pelo arquivo. Desligado, três coisas passam
no SQLite e falham (ou fazem outra coisa) no PostgreSQL:
| Operação | FK desligada | FK ligada |
|---|---|---|
| inserir filho apontando para pai inexistente | aceita | IntegrityError |
apagar o pai com ON DELETE CASCADE |
filho fica órfão | filho apagado |
add_all([filho, pai]) sem relationship() ordenando o flush |
aceita | IntegrityError |
Medido no SQLAlchemy 2.0.52 e 2.1.3, aiosqlite 0.22.1, SQLite 3.47.1. Por
isso o AsyncDatabaseManager liga o pragma em todo engine SQLite — arquivo e
:memory: —, igual ao WAL:
import asyncio
from sqlalchemy import ForeignKey, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from tempest_fastapi_sdk import AsyncDatabaseManager
class Base(DeclarativeBase):
"""Base declarativa só deste exemplo."""
class Org(Base):
"""Organização: a linha pai."""
__tablename__ = "orgs"
id: Mapped[int] = mapped_column(primary_key=True)
class Member(Base):
"""Membro: apagado junto com a organização."""
__tablename__ = "members"
id: Mapped[int] = mapped_column(primary_key=True)
org_id: Mapped[int] = mapped_column(ForeignKey("orgs.id", ondelete="CASCADE"))
async def main() -> None:
"""Mostra o órfão recusado e o cascade aplicado."""
db: AsyncDatabaseManager = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.create_tables(Base.metadata)
async with await db.get_session() as session:
session.add(Member(id=1, org_id=999))
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
print("órfão:", exc.orig)
session.add(Org(id=1))
await session.flush()
session.add(Member(id=2, org_id=1))
await session.commit()
org: Org | None = await session.get(Org, 1)
await session.delete(org)
await session.commit()
left: int = (
await session.execute(select(func.count()).select_from(Member))
).scalar_one()
print("membros depois de apagar a organização:", left)
await db.disconnect()
asyncio.run(main())
INSERT fora de ordem
Sem relationship() entre os dois models, o unit of work do SQLAlchemy
não sabe que o membro depende da organização e manda os INSERTs na ordem
do add_all. Declare o relationship(), ou dê flush() depois do pai,
como o exemplo faz.
Para desligar — banco legado com linha órfã que você ainda não pode limpar:
from tempest_fastapi_sdk import AsyncDatabaseManager
db: AsyncDatabaseManager = AsyncDatabaseManager(
"sqlite+aiosqlite:///./legado.db",
sqlite_foreign_keys=False,
)
Pelo ambiente, via DatabaseSettings: DATABASE_SQLITE_FOREIGN_KEYS=false.
Em teste, create_test_engine(foreign_keys=False) — veja
Testes.
Por que o pragma roda no connect
O SQLite ignora PRAGMA foreign_keys dentro de uma transação, sem erro.
Como o manager emite BEGIN explícito (é o que faz o RELEASE SAVEPOINT
parar de comitar), um pragma emitido pela sessão nunca teria efeito:
medido, ele continua respondendo 0. O SDK registra um listener no
evento connect do engine, que roda o pragma pelo cursor do driver antes
de qualquer transação. Engine montado à mão ganha o mesmo comportamento
com enable_sqlite_foreign_keys(engine).
Migration não roda com FK ligada
O batch mode do Alembic recria a tabela no SQLite, e com FK ligada o
DROP TABLE da cópia antiga dispara o ON DELETE CASCADE: medido, os
filhos vão de 3 linhas para 0, sem erro. Os engines de migration do SDK
deixam a FK desligada, e o env.py gerado recusa uma conexão entregue com
ela ligada — veja Migrações.
Recap: um AsyncDatabaseManager por app, em resources.py;
session_dependency injeta a sessão por request (session_dependency_for
quando o manager nasce sob demanda), e get_session_context()/transaction(),
que commitam, ficam fora do request; create_tables(metadata) aceita a sua
DeclarativeBase; connect/disconnect
no lifespan; health_check + db_url_safe no /health; em SQLite, WAL
e busy_timeout já vêm ligados para web e worker conviverem, e a chave
estrangeira é conferida como no PostgreSQL.
3. O repository¶
BaseRepository[Model] é o coração da camada. Ele encapsula o CRUD async,
os filtros, as operações em lote e a paginação. Há dois jeitos de usá-lo.
Modo direto — CRUD puro¶
Quando você não tem query custom, instancie direto:
import asyncio
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from src.db.models import UserModel
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
user_id = UUID("2b1d0c2e-7f3a-4c56-9d18-2f9a4c5b6d70")
repository = BaseRepository(session, model=UserModel)
async def main() -> None:
"""Run this example."""
user = await repository.get_by_id(user_id)
asyncio.run(main())
Modo subclasse — quando há queries próprias¶
Subclassifique para adicionar consultas do domínio e os mappers que
traduzem ORM ↔ DTO. São três: map_to_model (padrão self.model(**data)),
map_to_response e map_to_schema — os dois últimos levantam
NotImplementedError até você sobrescrever, então implemente os que o seu
código chama (o exemplo implementa map_to_response e map_to_model).
O construtor é o contrato — você repassa model
para super().__init__, não há atributos de classe mágicos:
# src/db/repositories/user.py
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseRepository
from src.db.models import UserModel
from src.schemas import UserResponse
class UserRepository(BaseRepository[UserModel]):
"""Data access for the user domain."""
def __init__(self, session: AsyncSession) -> None:
"""Bind the repository to a session and the user model.
Args:
session (AsyncSession): The async database session.
"""
super().__init__(
session,
model=UserModel,
not_found_message="Usuário não encontrado",
create_conflict_message="E-mail já cadastrado",
)
def map_to_response(self, instance: UserModel) -> UserResponse:
"""Map an ORM row to its API response schema.
Args:
instance (UserModel): The persisted user row.
Returns:
UserResponse: The serializable response DTO.
"""
return UserResponse.model_validate(instance)
def map_to_model(self, data: dict[str, Any]) -> UserModel:
"""Build an ORM instance from a plain payload.
Args:
data (dict[str, Any]): Column-value pairs.
Returns:
UserModel: The unpersisted instance.
"""
return UserModel(**data)
Mensagens de erro por repository
Os kwargs not_found_message, create_conflict_message,
update_conflict_message, bulk_create_conflict_message e
bulk_update_conflict_message customizam o texto das exceções. Sem
eles, o SDK gera mensagens a partir de Model.__name__ — o nome da
classe inteiro, sufixo incluído: "UserModel not found",
"Conflict creating UserModel".
Classes de exceção por repository
Cada *_message tem um *_exception correspondente. A mensagem sozinha
não dá para o cliente ramificar: o ConflictException padrão responde
code = "CONFLICT", então uma chave duplicada de coin pack fica
indistinguível de qualquer outro 409 — e error_responses() não consegue
documentá-la. Passando uma subclasse de domínio (que declara seu próprio
code no corpo da classe), o 409 fica identificável sem o repository
saber nada do domínio:
from tempest_fastapi_sdk import BaseRepository, ConflictException
from src.core.exceptions import CoinPackNotFoundException
from src.db.models import CoinPackModel
class CoinPackAlreadyExistsException(ConflictException):
"""Raised when a coin pack name is already taken."""
code: str = "COIN_PACK_ALREADY_EXISTS"
class CoinPackRepository(BaseRepository[CoinPackModel]):
"""Data access for coin packs."""
def __init__(self, session: AsyncSession) -> None:
"""Initialize the repository.
Args:
session (AsyncSession): The async database session.
"""
super().__init__(
session,
model=CoinPackModel,
not_found_exception=CoinPackNotFoundException,
create_conflict_exception=CoinPackAlreadyExistsException,
)
A resolução é do mais específico para o mais genérico —
create_conflict_exception se informado, senão conflict_exception, senão
ConflictException — então um kwarg só (conflict_exception=) cobre todas
as escritas, ou cada escrita usa a sua:
| Kwarg | Cobre |
|---|---|
create_conflict_exception |
add, save_with_outbox, add_audited |
update_conflict_exception |
update, update_audited |
bulk_create_conflict_exception |
add_all, bulk_create_values, bulk_upsert |
bulk_update_conflict_exception |
update_many, bulk_update, update_returning |
conflict_exception |
fallback de todas as quatro |
A classe é instanciada como cls(message=...), o mesmo contrato que
not_found_exception já tem — então ela precisa aceitar o keyword
message. Declarar code no corpo da classe e receber message opcional
atende aos dois. Todos os kwargs são opcionais: omitindo-os, o
comportamento é o de antes (ConflictException genérico). Disponível a
partir de 0.169.0.
Qual constraint recusou — parse_integrity_error¶
Trocar ConflictException por uma classe sua responde houve conflito. Para
responder em qual campo, é preciso ler o que o servidor disse — e o que ele
diz tem a forma do driver, não da sua aplicação.
from typing import Any
from sqlalchemy.exc import IntegrityError
from tempest_fastapi_sdk import parse_integrity_error
def to_response(error: IntegrityError) -> dict[str, Any]:
"""Traduz um erro de integridade no envelope que o cliente parseia."""
failure = parse_integrity_error(error)
return {
"code": failure.kind.value,
"field": failure.column,
"constraint": failure.constraint,
}
IntegrityViolation classifica em unique, foreign_key, not_null,
check e unknown. IntegrityFailure carrega o que o servidor informou:
constraint, table, columns (na ordem listada) e column, que é o atalho
para o caso de uma coluna só.
Campo vazio significa 'este servidor não disse'
Nunca 'não existe'. Os dois dialetos falam de jeitos diferentes, e as diferenças não são cosméticas:
| Postgres | SQLite | |
|---|---|---|
| unique | nomeia constraint, tabela e colunas | nomeia tabela.coluna; sem constraint |
| not null | nomeia coluna e tabela | nomeia tabela.coluna |
| check | nomeia a constraint e a tabela | devolve a expressão quando a constraint não tem nome |
| foreign key | nomeia constraint, tabela e coluna | diz só FOREIGN KEY constraint failed |
Uma ausência medida que vale repetir: FK no SQLite não traz nada além do tipo.
No Postgres, os campos do driver vêm antes do texto
O asyncpg expõe o diagnóstico do servidor como atributos da exceção —
sqlstate, detail, constraint_name, table_name, column_name —, e
o parse_integrity_error lê esses primeiro, caindo para a frase só no que
eles deixam vazio. O texto não é um portador estável. Medido contra
Postgres 16 com asyncpg 0.31.0:
| SQLAlchemy | str(error.orig) |
onde fica a exceção do driver |
|---|---|---|
| 2.0.52 (o piso do SDK) | primeira frase mais a linha DETAIL: |
error.orig.__cause__ |
| 2.1.1 | só a primeira frase | error.orig.orig (e __cause__) |
Lendo só o texto, uma unique no 2.1.1 voltava com columns=(). Os campos
são lidos pelo nome do atributo: o asyncpg não é importado, e uma
instalação sem [postgres] não precisa dele. A table de uma unique vem
do table_name; uma mensagem solta, sem a exceção do driver atrás, ainda
deixa None, porque a frase não diz e dividir o nome da constraint numa
convenção seria chute. O sqlstate (23505, 23503, 23502, 23514)
também classifica uma violação cuja frase o servidor traduziu: com
lc_messages=de_DE.utf8, nas duas versões do SQLAlchemy, uma unique
voltou unique com constraint e tabela, e com columns=(), porque o
DETAIL também vem traduzido.
Lê error.orig, não str(error)
str() numa IntegrityError do SQLAlchemy anexa [SQL: <statement>], e
um statement contém dado do usuário. Um valor que contivesse o texto
UNIQUE constraint failed: x.y seria parseado como nome de coluna. A
função lê a exceção do DBAPI, cuja mensagem é só a frase do servidor.
Nunca levanta
Mensagem desconhecida volta como IntegrityViolation.UNKNOWN com o texto
em failure.message. Um parser de prosa de erro que levanta transforma um
409 tratado num 500 não tratado, que é pior do que o conflito genérico que
ele veio melhorar.
O CRUD que você ganha¶
Lembrando da convenção de coleções do projeto: lookups de registro
único levantam 404; lookups de coleção devolvem [].
import asyncio
from uuid import UUID, uuid4
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from src.db.models import UserModel
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
id1, id2, id3 = uuid4(), uuid4(), uuid4()
repository = BaseRepository(session, model=UserModel)
user_id = UUID("2b1d0c2e-7f3a-4c56-9d18-2f9a4c5b6d70")
user_or_id = user_id
async def main() -> None:
"""Run this example."""
# Leitura — registro único (404 quando não acha)
user = await repository.get_by_id(user_id)
user = await repository.get({"email": "a@b.com"})
# Leitura — pode não existir (None, sem 404)
user = await repository.get_or_none({"email": "a@b.com"})
first = await repository.first({"is_active": True})
# Leitura — coleção (sempre [], nunca 404)
users = await repository.list({"is_active": True})
# Existência / contagem
exists = await repository.exists({"email": "a@b.com"})
total = await repository.count({"is_active": True})
# "Esse valor já é de OUTRO registro?" — validação de unicidade no update
taken = await repository.exists_excluding({"email": "a@b.com"}, exclude_id=user.id)
# id-ou-instância → instância (sem if isinstance espalhado nas services)
user = await repository.resolve(user_or_id)
# Escrita
created = await repository.add(
UserModel(name="Ana", email="ana@x.com", password_hash="...")
)
updated = await repository.update(user) # commita mutações numa instância anexada
# Remoção
await repository.delete(user_id) # hard delete (404 se não existe)
await repository.delete_many({"is_active": False}) # retorna contagem
await repository.delete_batch([id1, id2, id3]) # por PK, retorna contagem
# Soft-delete via flag is_active (não precisa do SoftDeleteMixin)
await repository.soft_delete(user_id) # is_active = False
await repository.restore(user_id) # is_active = True
asyncio.run(main())
update espera uma instância anexada
O fluxo típico é: get_by_id → mutar com update_from_dict →
repository.update(instance). Não construa um modelo solto e mande
para o update — ele persiste mutações de algo já carregado na
sessão.
resolve e exists_excluding — dois ajudantes que você vai usar sempre
resolve(id_ou_instância) resolve o velho dilema: seu método
recebe UUID | UserModel e você não quer escrever
if isinstance(x, UUID): ... else: ... em toda service. O
resolve faz isso por você — passa um UUID, ele busca (404 se não
existir); passa uma instância, ele devolve a mesma. Uma linha:
exists_excluding(filtros, exclude_id=...) responde a pergunta
"esse e-mail/telefone/username já é de outra pessoa?" — exatamente
o que você precisa ao atualizar um campo único. O exists normal
diria True até para o próprio registro; o exists_excluding ignora
o id que você passar:
if await self.repository.exists_excluding(
{"phone": new_phone}, exclude_id=user.id
):
raise UserWithPhoneExistsException(phone=new_phone)
Passe exclude_id=None no cadastro (quando ainda não há registro a
excluir) — aí ele se comporta igual ao exists.
Recap: instancie direto para CRUD puro, subclassifique para queries +
mappers. 404 só em lookup único; coleção devolve []. soft_delete
mexe na flag is_active; o SoftDeleteMixin (seção 6) adiciona um
carimbo deleted_at quando você precisa de auditoria temporal.
Eager-loading de relacionamentos com with_¶
Acessar um relacionamento (user.orders) depois que a sessão async
fechou levanta o temido MissingGreenlet — o SQLAlchemy tentaria uma
query lazy num contexto que não pode mais aguardar I/O. A solução é
carregar o relacionamento junto, na mesma query. Todo método de leitura
(get, get_or_none, get_by_id, first, list) aceita with_=:
import asyncio
from uuid import UUID
from tempest_fastapi_sdk import BaseRepository
from db_setup import db
from src.db.models import UserModel
async def main() -> None:
"""Run this example."""
user_id = UUID("2b1d0c2e-7f3a-4c56-9d18-2f9a4c5b6d70")
async with db.get_session_context() as session:
repository = BaseRepository(session, model=UserModel)
# Carrega o user e seus pedidos numa só ida ao banco
user = await repository.get_by_id(user_id, with_=["orders"])
for order in user.orders: # sem lazy load, sem MissingGreenlet
print(order.total)
# Vários relacionamentos + aninhado (pontilhado)
user = await repository.get_by_id(
user_id,
with_=["profile", "orders.items"], # orders → e os items de cada order
)
# Também funciona em coleções
users = await repository.list({"is_active": True}, with_=["orders"])
asyncio.run(main())
Cada caminho usa selectinload: N relacionados custam uma query
extra por nível (um SELECT ... IN (...)), não N — nada de row
multiplication de JOIN, e funciona tanto para coleções quanto para
escalares.
Nome errado falha alto
Um segmento de with_ que não seja um relacionamento do modelo
naquele ponto levanta ValueError na hora — não um erro silencioso
em runtime. with_=["orders.ghost"] → ValueError: Order has no
relationship 'ghost'.
Signals de ciclo de vida¶
Quando você quer reagir a uma escrita — invalidar cache, enfileirar um evento, sincronizar um índice de busca, disparar um domain event — sem espalhar callbacks por toda service, registre um signal. O repository emite quatro momentos no caminho unit-of-work:
from tempest_fastapi_sdk import RepositorySignal, on_signal
from tempest_fastapi_sdk.cache import AsyncRedisManager
from tempest_fastapi_sdk.db import connect, disconnect
from src.core.settings import settings
from src.db.models import UserModel
from src.services.search import SearchIndex
cache = AsyncRedisManager(settings.REDIS_URL, decode_responses=True)
search_index = SearchIndex()
# Forma decorator
@on_signal(UserModel, RepositorySignal.POST_SAVE)
async def index_user(user: UserModel) -> None:
"""Reindexa o user na busca depois que a linha commitou."""
await search_index.upsert(user.id, user.name)
# Forma imperativa (mesma coisa)
async def bust_cache(user: UserModel) -> None:
"""Derruba a entrada de cache do user depois que a linha commitou."""
await cache.client.delete(f"user:{user.id}")
connect(UserModel, RepositorySignal.POST_SAVE, bust_cache)
disconnect(UserModel, RepositorySignal.POST_SAVE, bust_cache) # remove
search_index é ilustrativo; o cache não
search_index.upsert(...) é um placeholder do seu projeto (um cliente
de busca) — não faz parte do SDK. Troque pelo objeto real do seu domínio.
O cache é um AsyncRedisManager de verdade, e é por isso que a chamada
passa por cache.client: o manager cuida do ciclo de vida, e os comandos
Redis vivem no client. Construindo o handler antes do lifespan, use
cache.client_proxy — cache.client levanta RuntimeError até o
connect() rodar.
Os quatro momentos:
| Signal | Quando dispara | Uso típico |
|---|---|---|
PRE_SAVE |
antes do INSERT/UPDATE commitar |
validação transversal; levantar aqui veta a escrita (rollback + re-raise) |
POST_SAVE |
depois de commitar + refresh | reindex, cache-bust, evento de domínio |
PRE_DELETE |
antes do delete de linha única | limpar dependências externas |
POST_DELETE |
depois de o delete commitar | notificar que o registro sumiu |
Handlers podem ser sync ou async — um retorno awaitable é
aguardado. Registrar num modelo base vale para as subclasses (resolvido
pela MRO da instância).
Signals cobrem só o caminho unit-of-work
add / add_all / update / update_many / soft_delete /
restore / delete disparam signals. Os métodos bulk set-based
(bulk_update, bulk_create_values, bulk_upsert, delete_many,
delete_batch) emitem uma instrução SQL e fazem bypass dos
signals por design — eles nunca materializam as linhas afetadas.
soft_delete/restore disparam PRE_SAVE/POST_SAVE (são um
UPDATE), não os signals de delete.
Isolamento em testes
O registro é global ao processo. Em testes, chame
clear_signals() (de tempest_fastapi_sdk.db.signals) no teardown
de uma fixture para um handler de um teste não vazar para o
seguinte.
Expressões F e Q¶
Para quem vem do Django: F referencia uma coluna dentro da query e Q
compõe condições com OR/NOT. Os dois plugam direto no repository.
F — atualização atômica no banco. Decrementar estoque com
read-modify-write tem race: dois requests leem 10, ambos gravam 9.
F("stock") - 1 calcula no banco, numa instrução — sem lost update:
import asyncio
from uuid import UUID
from tempest_fastapi_sdk import BaseRepository
from db_setup import db
from src.db.models import UserModel
from tempest_fastapi_sdk import F
async def main() -> None:
"""Run this example."""
product_id = UUID("6f1c3d84-2a55-4d0b-9d7e-0c1a2b3c4d5e")
pid = product_id
async with db.get_session_context() as session:
repository = BaseRepository(session, model=UserModel)
# stock = stock - 1, no banco
await repository.bulk_update({"id": product_id}, {"stock": F("stock") - 1})
# aritmética dos dois lados e entre colunas
await repository.bulk_update({"id": pid}, {"stock": 100 - F("stock")})
await repository.bulk_update({"id": pid}, {"total": F("price") * F("qty")})
asyncio.run(main())
update_returning — a decisão e o valor novo na mesma instrução.
bulk_update devolve só a contagem. Quando a escrita é condicional e você
precisa saber o resultado — debitar só se houver saldo, e com o saldo novo
em mãos —, update_returning roda o mesmo UPDATE ... WHERE com
RETURNING. Linha nenhuma devolvida significa que a condição não casou;
não existe leitura antes, então não existe janela para outra requisição:
import asyncio
from uuid import UUID
from tempest_fastapi_sdk import BaseRepository, F
from db_setup import db
from src.db.models import UserModel
async def main() -> None:
"""Run this example."""
user_id = UUID("6f1c3d84-2a55-4d0b-9d7e-0c1a2b3c4d5e")
async with db.get_session_context() as session:
repository = BaseRepository(session, model=UserModel)
rows = await repository.update_returning(
{"id": user_id, "wallet_cents__gte": 500},
{"wallet_cents": F("wallet_cents") - 500},
returning=("wallet_cents",),
)
if rows:
print("novo saldo:", rows[0]["wallet_cents"])
else:
print("recusado: saldo abaixo de 500")
asyncio.run(main())
O where= aceita um Q ou uma cláusula SQLAlchemy pronta, para o que o
dict não expressa — a carteira desconta o saldo retido com
uma subquery correlata ali. RETURNING exige PostgreSQL, ou SQLite 3.35+.
Q — OR / NOT que o dict de filtros não expressa. O dict ANDeia
tudo; Q combina com & / | / ~ e entra via where=:
import asyncio
from tempest_fastapi_sdk import BaseRepository
from db_setup import db
from src.db.models import UserModel
from tempest_fastapi_sdk import Q
async def main() -> None:
"""Run this example."""
async with db.get_session_context() as session:
repository = BaseRepository(session, model=UserModel)
# status open OU pending
abertos = await repository.list(where=Q(status="open") | Q(status="pending"))
# ativos que NÃO são guest
ativos = await repository.list(where=Q(is_active=True) & ~Q(role="guest"))
# combina com o dict (AND): estoque >= 5 E (open OU closed)
rows = await repository.list(
{"stock__gte": 5}, where=Q(status="open") | Q(status="closed")
)
asyncio.run(main())
~Q(...) não devolve a linha com NULL
A negação vira role != 'guest' em SQL, e NULL != 'guest' é
NULL, não verdadeiro: a linha com role nulo fica de fora junto
com a guest. Com três usuários (guest, admin e role=None),
~Q(role="guest") devolve só o admin. Se a linha nula deve entrar,
peça explicitamente: ~Q(role="guest") | Q(role=None) devolve admin e
o nulo (None numa coluna simples é IS NULL). O role__ne="guest"
tem a mesma semântica do ~Q aqui — também exclui o nulo.
Q usa as mesmas convenções do dict de filtros (name ILIKE,
campo__gte, iterável → IN, …), então Q(priority__gte=5, name="ana") é
o AND dessas condições. where= funciona em list / first / get /
get_or_none / count / exists / paginate / delete_many.
Operadores de sufixo campo__op disponíveis (no Q e no dict):
| Sufixo | SQL | Exemplo |
|---|---|---|
gt gte lt lte ne |
comparação | Q(priority__gte=5) |
in notin not_in |
IN / NOT IN (valor = qualquer iterável não-string: list/set/tuple/gerador, ou um escalar, embrulhado numa lista de um item; not_in é alias de notin) |
Q(status__in={"open", "paid"}) |
between |
col BETWEEN lo AND hi (valor = par ordenado (lo, hi) em list/tuple; qualquer outro formato é recusado) |
Q(price__between=(10, 20)) |
iexact |
igualdade case-insensitive (lower(col) == lower(v)) |
Q(email__iexact="Ana@X.com") |
like ilike |
LIKE / ILIKE cru, com os wildcards do chamador (%/_) sem escape |
Q(sku__ilike="ab_-%") |
isnull |
IS NULL (True) / IS NOT NULL (False) |
Q(closed_at__isnull=True) |
contains icontains |
ILIKE %v% (valor escapado) |
Q(name__contains="ana") |
startswith endswith |
ILIKE v% / %v (valor escapado) |
Q(sku__startswith="SKU-") |
Case-sensitivity do like depende do backend
ilike é sempre case-insensitive. Já o like puro segue a semântica de
LIKE do banco: o SQLite ignora maiúsculas/minúsculas para ASCII, o
PostgreSQL não. Para comparação de caixa portável, use ilike ou
iexact.
SQLAlchemy puro continua ali
F/Q são açúcar tipado sobre expressões que o SQLAlchemy já tem.
Precisou de algo que eles não cobrem? Use select(...) direto — o
repository não fica no caminho.
4. Filtros por convenção¶
Todos os métodos que recebem filters: dict[str, Any] passam pelo mesmo
motor. Um valor None significa IS NULL, como em qualquer ORM:
{"deleted_at": None} casa as linhas que nunca foram apagadas. As
convenções:
| Chave / valor | SQL gerado | Exemplo |
|---|---|---|
name (str) |
ILIKE %value% case-insensitive |
{"name": "ana"} |
bool |
col.is_(value) |
{"is_active": True} |
iterável não-string (list/set/tuple/frozenset/range/gerador/dict view) |
col.in_(values) — o iterável é materializado uma vez, então passar um set não precisa de conversão manual pra list |
{"id": {id1, id2}} |
date |
func.date(col) == value (dia inteiro) |
{"created_at": hoje} |
start_in / end_in (date) |
range no date/created_at |
{"start_in": d1, "end_in": d2} |
<col>__<op> |
comparação gt/gte/lt/lte/ne |
{"updated_at__gt": marca} |
| qualquer outra coluna | col == value |
{"email": "a@b.com"} |
import asyncio
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from tempest_fastapi_sdk import BaseRepository
from db_setup import db
from src.db.models import UserModel
async def main() -> None:
"""Run this example."""
fim = datetime(2026, 1, 31, tzinfo=timezone.utc)
inicio = datetime(2026, 1, 1, tzinfo=timezone.utc)
selected_ids = [uuid4(), uuid4()]
watermark = datetime.now(timezone.utc) - timedelta(hours=1)
async with db.get_session_context() as session:
repository = BaseRepository(session, model=UserModel)
# "ativos atualizados depois da marca d'água" — precisão de timestamp
changed = await repository.list({
"is_active": True,
"updated_at__gt": watermark,
})
# "criados entre duas datas" — dia inteiro
report = await repository.list({"start_in": inicio, "end_in": fim})
# busca textual + pertinência a um conjunto
hits = await repository.list({"name": "silva", "id": selected_ids})
asyncio.run(main())
start_in/end_in vs __gt/__lt
start_in/end_in casam por dia inteiro (func.date) contra a
coluna date do modelo (ou created_at se não houver). Os sufixos
__op são precisos no timestamp — é o que queries de delta-sync
usam. Escolha por precisão.
Filtros vêm de um schema, não de strings soltas
Na prática você não monta esse dict à mão. BasePaginationFilterSchema
(e suas subclasses) expõem .get_conditions(), que devolve o dict já
limpo de None. O router recebe o filtro via Depends().
Chave desconhecida é recusada¶
Uma chave que não é coluna do model ({"usr_id": 1}, erro de digitação) ou
um sufixo que não é operador ({"user_id__bogus": 1}) levanta
UnknownFilterKeyException antes de qualquer statement rodar — em todo
método que recebe filters (list, count, exists, first, get,
get_or_none, paginate, cursor_paginate, bulk_update,
update_returning, delete_many), no Q e nas camadas que só repassam
(BaseService, BaseController, TenantScopedRepository):
import asyncio
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel, BaseRepository, UnknownFilterKeyException
class TicketModel(BaseModel):
__tablename__ = "tickets_unknown_key_demo"
user_id: Mapped[int] = mapped_column()
status: Mapped[str] = mapped_column(String(16))
async def main() -> None:
"""Run this example."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(TicketModel.metadata.create_all)
async with AsyncSession(engine) as session:
repo: BaseRepository[TicketModel] = BaseRepository(session, model=TicketModel)
for user_id in (1, 2, 3):
await repo.add(TicketModel(user_id=user_id, status="open"))
try:
await repo.delete_many({"usr_id": 1})
except UnknownFilterKeyException as exc:
print(exc.status_code, exc.code, exc.details)
try:
await repo.list({"user_id__bogus": 1})
except UnknownFilterKeyException as exc:
print(exc.details)
print(await repo.count())
await engine.dispose()
asyncio.run(main())
Saída:
As três linhas continuam lá: o delete_many não apagou nada.
detailstraz só a chave recusada, nunca a lista de colunas do model — quando o dict vem de uma querystring, essa lista é o mapa do que sondar.- Atributo Python que não tem leitura SQL (método,
@property, o própriometadata) conta como desconhecido. Coluna,column_propertye a expressão de classe de umhybrid_propertyvalem. start_in/end_incontinuam sendo açúcar do repository, não coluna.- No admin,
list_filter,search_fieldse osfiltersde cadaLenssão conferidos na construção doAdminModel: um typo viraValueErrorno boot, nomeando a opção, em vez de 422 no primeiro clique.
Mudança de comportamento (#465)
Até a 0.308.0 a chave desconhecida era ignorada em silêncio: com só
{"usr_id": 1} no dict, list/count devolviam todas as linhas e
bulk_update/delete_many alteravam a tabela inteira. Quem dependia
disso — por exemplo, um schema de filtro com um campo que não é coluna
(search, include_archived) repassado inteiro por get_conditions() —
passa a receber 422. Tire o campo do dict antes de repassar
(conditions = f.get_conditions() e depois
search = conditions.pop("search", None)) e trate-o no service.
Valor que não cabe no operador é recusado¶
Um valor com a forma errada para o operador — um between que não é o par
(lo, hi) — levanta InvalidFilterValueException antes de qualquer statement.
Era o restante do defeito do #465: a condição sumia do WHERE e, em
bulk_update, delete_many e update_returning, a escrita atingia justamente
as linhas que o filtro não nomeava.
O in não entra nessa lista: um escalar solto é embrulhado numa lista de um
item ({"status__in": "open"} → IN ('open')), como sempre foi, porque a
condição não sumia do WHERE.
import asyncio
from sqlalchemy import String
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
BaseModel,
BaseRepository,
InvalidFilterValueException,
)
class TicketModel(BaseModel):
__tablename__ = "tickets_bad_value_demo"
status: Mapped[str] = mapped_column(String(16))
async def main() -> None:
"""Run this example."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(TicketModel.metadata.create_all)
async with AsyncSession(engine) as session:
repo: BaseRepository[TicketModel] = BaseRepository(
session, model=TicketModel
)
for status in ("open", "paid"):
await repo.add(TicketModel(status=status))
try:
await repo.delete_many({"status__between": ["open"]})
except InvalidFilterValueException as exc:
print(exc.status_code, exc.code, exc.details)
print(await repo.count())
await engine.dispose()
asyncio.run(main())
Saída:
details["value"] ecoa um repr do valor, truncado em 64 caracteres: o
filtro errado costuma carregar valor grande (um payload, uma lista de ids), e
o erro não é lugar de repetir tudo.
Mudança de comportamento (#484)
Até a 0.312.0 um between malformado era ignorado — a condição
desaparecia do WHERE. Quem mandava um par errado e dependia desse
silêncio passa a receber 422; corrija a forma do par (lo, hi). Um escalar
em in segue embrulhado numa lista de um item, como sempre foi.
Toda paginação herda os operadores¶
Como get_conditions() só remove as chaves de paginação (page,
page_size, order_by, ascending) e repassa todo o resto ao mesmo
motor, qualquer subclasse de BasePaginationFilterSchema ganha os
operadores de graça: basta declarar um campo com o nome <coluna>__<op>.
Sem herança extra, sem mixin — o nome do campo é o operador.
from tempest_fastapi_sdk import BasePaginationFilterSchema
from pydantic import Field
class ProductFilter(BasePaginationFilterSchema):
"""Filtro de listagem de produtos — cada campo vira uma condição."""
name: str | None = Field(default=None) # ILIKE %name%
category_id__in: set[int] | None = Field(default=None) # IN (set!)
price__between: tuple[float, float] | None = Field(default=None) # BETWEEN
sku__ilike: str | None = Field(default=None) # ILIKE cru
created_at__gte: str | None = Field(default=None) # >=
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from src.db.models import UserModel
from src.schemas import ProductFilterSchema
# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))
f = ProductFilterSchema(name="silva", page=1, page_size=20)
repo = BaseRepository(session, model=UserModel)
async def main() -> None:
"""Run this example."""
# No service/repo, o schema inteiro vira filtros + paginação:
data = await repo.paginate(
filters=f.get_conditions(), # name/category_id__in/price__between/…
**f.get_pagination_conditions(), # page/page_size/order_by/ascending
)
asyncio.run(main())
O front chama ?category_id__in=1&category_id__in=2&price__between=10&price__between=20
e o FastAPI monta o schema via Depends(). O get_conditions() do schema
remove os campos não preenchidos antes de montar o dict
(to_dict(exclude_none=True)), então o cliente manda só os campos que quer
e um None do schema nunca chega ao repositório como IS NULL.
None mudou de significado na v0.292.0
Antes, um None no dict descartava a chave — o filtro sumia e a
query devolvia tudo. Era o modo de falha mais caro possível: falha
silenciosa, na direção de mais linhas. Medido num consumidor,
{"left_at": None} (a grafia óbvia de "ainda é membro") casava também
quem tinha saído, e o fan-out de WebSocket construído a partir disso
continuava entregando as mensagens do grupo a essas pessoas.
Quem montava o dict à mão com chaves opcionais e contava com o
descarte precisa passar a omitir a chave — ou usar
get_conditions(), que já faz isso. Onde None não tem leitura
nenhuma (__gt, __between, __in), a condição continua sendo
descartada, agora com um DroppedFilterWarning nomeando a chave.
Recap: um dict, convenções previsíveis, None é IS NULL. Strings em
name viram busca ILIKE; sufixos __op dão comparações precisas.
Toda paginação herda esses operadores só declarando o campo.
O nome do tamanho de página no fio¶
Por default o envelope publica page_size, tanto na resposta quanto no query
param. Para um serviço novo isso não custa nada — mas um serviço que já
publica {"total", "items", "page", "size", "pages"} não pode renomear sem
quebrar todo endpoint paginado de uma vez, e um app já em loja não atualiza em
lockstep com o backend.
Desde a v0.261.0 existe a variante pronta:
from tempest_fastapi_sdk import (
CompactPaginationFilterSchema,
CompactPaginationSchema,
)
# GET /users?size=50
filters = CompactPaginationFilterSchema.model_validate({"size": 50})
filters.page_size # 50 — o nome Python não muda
page = CompactPaginationSchema[int](
items=[1], total=1, page=1, page_size=20, pages=1
)
page.model_dump(by_alias=True)["size"] # 20
O FastAPI serializa response_model com by_alias=True, então declarar
CompactPaginationSchema[Item] como response_model já basta.
O repositório não muda
BaseRepository.paginate continua devolvendo
{"items", "total", "page", "page_size", "pages"} seja qual for o
envelope. A renomeação vive no schema e em nenhum outro lugar —
não há o que renomear na camada de dados, e trocar um serviço de um
envelope para o outro toca uma linha.
Se o seu nome no fio não é nem page_size nem size, as duas classes base
ligam populate_by_name, então dá para sobrescrever só o campo:
from pydantic import Field
from tempest_fastapi_sdk import BasePaginationSchema
class MyPage(BasePaginationSchema[int]):
"""Envelope próprio, renomeando só o tamanho de página."""
page_size: int = Field(
validation_alias="perPage",
serialization_alias="perPage",
)
O nome do fio é escrito duas vezes, validation_alias para ler e
serialization_alias para escrever. Field(alias=...) sozinho parece
funcionar e é aceito pelo mypy, mas pyright e basedpyright acusam erro no
consumidor — o SDK não usa essa forma em lugar nenhum.
5. Operações em lote¶
Para volume, o ORM linha-a-linha é caro. O repository oferece duas famílias: as que mantêm a unit-of-work (instâncias atualizadas de volta) e as que a contornam (uma única instrução, sem refresh).
import asyncio
from tempest_fastapi_sdk import BaseRepository
from db_setup import db
from src.db.models import UserModel
async def main() -> None:
"""Run this example."""
m1, m2, m3 = (UserModel(name=n, email=f"{n}@x.com") for n in "abc")
async with db.get_session_context() as session:
repository = BaseRepository(session, model=UserModel)
# Mantém a UoW — instâncias anexadas e atualizadas
created = await repository.add_all([m1, m2, m3]) # vários INSERTs, 1 tx
u1, u2 = created[0], created[1]
updated = await repository.update_many([u1, u2]) # vários UPDATEs, 1 tx
# Contorna a UoW — uma instrução, escala melhor (>= 50 linhas)
n = await repository.bulk_create_values([
{"name": "A", "email": "a@x.com", "password_hash": "..."},
{"name": "B", "email": "b@x.com", "password_hash": "..."},
]) # INSERT ... VALUES (...), (...) — devolve nº de linhas
n = await repository.bulk_update(
filters={"is_active": False},
values={"is_active": True},
) # UPDATE ... WHERE — devolve nº de linhas afetadas
n = await repository.bulk_upsert(
rows=[{"sku": "ABC", "price": 10}, {"sku": "DEF", "price": 20}],
conflict_columns=["sku"], # precisa de índice UNIQUE
update_columns=["price"], # None = atualiza tudo menos PK + conflito
) # INSERT ... ON CONFLICT DO UPDATE — Postgres e SQLite
asyncio.run(main())
bulk_update recusa filtro vazio
Passar filters={} levanta ValueError — é a trava contra um UPDATE
acidental na tabela inteira. Para realmente atualizar todas as linhas,
passe uma condição explícita sempre verdadeira.
bulk_* não atualiza a sessão
bulk_create_values, bulk_update e bulk_upsert emitem uma
instrução crua e não refrescam nem anexam instâncias à sessão.
Use quando você não precisa dos objetos ORM de volta. Se precisar das
instâncias, use add_all / update_many.
bulk_upsert é específico de dialeto
Postgres e SQLite têm upsert nativo. Outros dialetos levantam
NotImplementedError — caia para um loop SELECT FOR UPDATE +
UPDATE.
Recap: add_all/update_many quando você quer as instâncias de
volta; bulk_* quando quer throughput. Filtro vazio em bulk_update é
erro proposital.
6. Soft-delete e auditoria (mixins)¶
Os mixins são opt-in: você os mistura ao lado de BaseModel só quando
o domínio pede. SoftDeleteMixin adiciona deleted_at (+
mark_deleted() / mark_restored() / is_deleted). AuditMixin
adiciona created_by / updated_by (+ stamp_created_by /
stamp_updated_by).
# src/db/models/user.py
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import AuditMixin, BaseModel, SoftDeleteMixin
class UserModel(BaseModel, SoftDeleteMixin, AuditMixin):
"""Users — soft-deletable and audited."""
name: Mapped[str] = mapped_column()
email: Mapped[str] = mapped_column(unique=True)
password_hash: Mapped[str] = mapped_column()
A filtragem é responsabilidade de quem chama — o mixin não instala um
filtro global. Esconda linhas soft-deleted passando deleted_at=None, ou
filtrando na subclasse. Carimbar auditoria pertence ao service, onde o
usuário atual está em escopo:
# src/services/user.py
from uuid import UUID
from tempest_fastapi_sdk import BaseService
from src.db.repositories import UserRepository
from src.schemas import UserResponse, UserUpdateSchema
class UserService(BaseService[UserRepository, UserResponse]):
"""Business logic for the user domain."""
async def list_alive(self) -> list[UserResponse]:
"""Return only rows where ``deleted_at IS NULL``.
Returns:
list[UserResponse]: The alive users.
"""
instances = await self.repository.list(filters={"deleted_at": None})
return [self.repository.map_to_response(i) for i in instances]
async def update(
self,
user_id: UUID,
data: UserUpdateSchema,
*,
actor_id: UUID,
) -> UserResponse:
"""Apply a partial update and stamp ``updated_by`` with the actor.
Args:
user_id (UUID): Primary key of the row to update.
data (UserUpdateSchema): The partial payload.
actor_id (UUID): The acting user, written to ``updated_by``.
Returns:
UserResponse: The updated user.
"""
instance = await self.repository.get_by_id(user_id)
instance.update_from_dict(data.model_dump(exclude_unset=True))
instance.stamp_updated_by(actor_id)
updated = await self.repository.update(instance)
return self.repository.map_to_response(updated)
Dois carimbos de delete, propósitos diferentes
Use repository.soft_delete(id) (flag is_active) quando o booleano
já basta. Use os helpers do SoftDeleteMixin (mark_deleted →
deleted_at) quando precisa saber quando o delete aconteceu —
auditoria, políticas de retenção.
MFA é outro mixin opt-in
MFAMixin adiciona totp_secret / totp_enabled_at / totp_last_step ao modelo de
usuário quando o projeto liga o fluxo MFA bundled. Detalhes em
MFA (TOTP / 2FA) ».
Locale — idioma preferido do usuário¶
LocaleColumnMixin adiciona uma coluna locale (BCP-47, ex. "pt-BR",
"en-US", nullable) pra o modelo carregar o idioma em que as notificações
e textos localizados dele devem sair — sem cada projeto redeclarar a mesma
coluna. Misture-o como qualquer outro mixin:
# src/db/models/user.py
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseModel, LocaleColumnMixin
class UserModel(BaseModel, LocaleColumnMixin):
"""Users — carregam o locale de notificação."""
name: Mapped[str] = mapped_column()
email: Mapped[str] = mapped_column(unique=True)
Pra escrever o valor, use o enum Locale (lista curada de tags BCP-47) em
vez de digitar a string na mão — cada membro é a própria tag, então
compara e grava como ela:
from tempest_fastapi_sdk import Locale
from src.db.models import UserModel
user = UserModel(name="Ana", email="ana@example.com")
user.locale = Locale.PT_BR # grava "pt-BR"
user.locale = "en-US" # a string crua também vale
assert Locale.PT_BR == "pt-BR" # membro é str
locale NULL significa "sem preferência": resolva pro default da sua app
na hora de renderizar (tipicamente via
MessageCatalog), não trate como erro. Esse é
exatamente o par que o recipe de Web Push » usa pra localizar
o title/body de cada notificação pelo locale do destinatário.
Locale é curado, não exaustivo
O enum cobre os locales mais usados (pt/en/es/fr/de/… + variantes de
região). Precisa de uma tag fora da lista? A coluna é str, então
guarde a string crua e proponha o novo membro upstream quando virar
comum.
Recap: mixins entram só quando o domínio precisa; a filtragem de
soft-delete é sua (deleted_at IS NULL via query crua); o carimbo de
auditoria mora no service; o locale do usuário vem do LocaleColumnMixin
+ enum Locale.
7. Paginação¶
O SDK pagina de duas formas, ambas embutidas no repository. Você quase nunca escreve a query de paginação à mão.
Offset — quando o cliente quer "página 3 de 12"¶
# src/db/repositories/user.py — método de conveniência
from typing import Any
from tempest_fastapi_sdk import BasePaginationSchema, BaseRepository
from src.db.models import UserModel
from src.schemas import UserResponse
UserPage = BasePaginationSchema[UserResponse]
class UserRepository(BaseRepository[UserModel]):
# ... __init__ + mappers ...
async def list_page(
self,
*,
filters: dict[str, Any] | None = None,
page: int = 1,
page_size: int = 20,
) -> UserPage:
"""Return one offset-paginated page of users.
Args:
filters (dict[str, Any] | None): Filter conditions.
page (int): 1-indexed page number.
page_size (int): Items per page.
Returns:
UserPage: Items + total + page metadata.
"""
result = await self.paginate(
filters=filters,
page=page,
page_size=page_size,
)
return UserPage(
items=[self.map_to_response(i) for i in result["items"]],
total=result["total"],
page=result["page"],
page_size=result["page_size"],
pages=result["pages"],
)
BaseRepository.paginate devolve um dict com items / total /
page / page_size / pages. O total é computado da mesma query
filtrada, então joins custom ainda reportam total correto. Quando
order_by é None, ordena por created_at desc.
order_by é validado contra as colunas do model
Ele chega direto de um query param (BasePaginationFilterSchema declara um
str), então é entrada não confiável. paginate e cursor_paginate
resolvem o nome pelo mapper e levantam OrderByNotAllowedException
(422, subclasse de ValidationException) quando não é coluna mapeada —
inclusive pra atributo que existe na classe mas não é coluna, como
metadata. A recusa nunca lista as colunas do model: desde a 0.298.0
details["allowed"] só aparece quando você declarou orderable_columns,
e aí contém só esse conjunto. Ver a seção seguinte.
Encaminhe o schema sem desempacotar à mão
O par get_conditions() / get_pagination_conditions() cobre os dois
lados do filtro: o primeiro devolve só os filtros de domínio, o segundo
só as chaves de paginação (page, page_size, order_by,
ascending). Assim o service repassa o filtro direto, sem **f — que
vazaria filtros de domínio (is_active, etc.) como kwargs que o
repository não aceita:
CursorPaginationFilterSchema tem o mesmo par (com cursor / limit
no lugar de page / page_size).
Quais colunas ordenam, e quantas linhas cabem numa página¶
Validar que order_by é uma coluna não basta numa listagem pública.
Ordenar é um oráculo: ?order_by=wallet ranqueia todo mundo por saldo, e
quem controla a própria linha (grava o próprio email, o próprio CPF)
descobre o valor da linha vizinha por busca binária sobre a posição em que a
sua cai. E sem teto em page_size, um request lê a tabela inteira.
Declare as duas coisas no filtro da listagem:
from typing import Annotated
from fastapi import FastAPI, Query
from tempest_fastapi_sdk import BasePaginationFilterSchema, register_exception_handlers
class ProducerFilterSchema(BasePaginationFilterSchema):
"""Filtro da listagem pública de produtores."""
orderable_columns = frozenset({"created_at", "name"})
max_page_size = 50
app = FastAPI()
register_exception_handlers(app)
@app.get("/producers")
def list_producers(
filters: Annotated[ProducerFilterSchema, Query()],
) -> dict[str, int | str | None]:
"""Echo the validated pagination keys."""
return {"page_size": filters.page_size, "order_by": filters.order_by}
Pedaço por pedaço:
-
orderable_columnsé umClassVar[frozenset[str] | None]. Com o conjunto declarado, qualquer outro valor viraOrderByNotAllowedExceptionjá na validação do schema, antes de chegar ao repository:{ "detail": "Cannot order by this field", "code": "ORDER_BY_NOT_ALLOWED", "details": {"order_by": "hashed_password", "allowed": ["created_at", "name"]}, "field": "order_by" }details["allowed"]é só o conjunto declarado, ordenado. O default éNone, que aceita qualquer valor e deixa a checagem para o repository — o comportamento de antes.order_byvazio (?order_by=) significa ausente e viraNone, nunca 422. -
max_page_sizeé o teto inclusivo depage_size. O default da base éDEFAULT_MAX_PAGE_SIZE(100); declarar na subclasse reescreve ole=do campo, então a recusa é oless_than_equaldo próprio pydantic e o OpenAPI publicamaximum: 50no parâmetro — comDepends()e comAnnotated[..., Query()].Noneremove o teto.
O teto de 100 é novo na 0.298.0
Até a 0.297.x page_size só tinha ge=1. Cliente que pedia
page_size=500 passa a levar 422 — declare max_page_size com o valor
que a listagem realmente precisa (ou None) em vez de redeclarar o
campo.
A segunda linha é o repository, para quem chega lá sem passar pelo schema
(script, painel, outra rota). Os mesmos nomes existem em BaseRepository,
como atributo de classe ou argumento do construtor (o construtor vence):
import asyncio
from typing import ClassVar
from sqlalchemy import Integer, String
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import (
BaseModel,
BaseRepository,
OrderByNotAllowedException,
PageSizeTooLargeException,
)
class ProducerModel(BaseModel):
__tablename__ = "producers_order_demo"
name: Mapped[str] = mapped_column(String(80))
wallet: Mapped[int] = mapped_column(Integer, default=0)
class ProducerRepository(BaseRepository[ProducerModel]):
orderable_columns: ClassVar[frozenset[str] | None] = frozenset(
{"created_at", "name"},
)
max_page_size: ClassVar[int | None] = 50
def __init__(self, session: AsyncSession) -> None:
super().__init__(session, model=ProducerModel)
async def main() -> None:
"""Run this example."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(ProducerModel.metadata.create_all)
async with AsyncSession(engine) as session:
repo = ProducerRepository(session)
await repo.paginate(order_by="name", page_size=50)
try:
await repo.paginate(order_by="wallet")
except OrderByNotAllowedException as exc:
print(exc.details)
try:
await repo.paginate(page_size=51)
except PageSizeTooLargeException as exc:
print(exc.code, exc.details)
await engine.dispose()
asyncio.run(main())
Saída:
{'order_by': 'wallet', 'allowed': ['created_at', 'name']}
PAGE_SIZE_TOO_LARGE {'page_size': 51, 'max_page_size': 50}
- Sem
orderable_columnsno repository, qualquer coluna mapeada ainda ordena — mas a recusa de um nome que não é coluna sai comdetailssó com{"order_by": ...}, sem a chaveallowed. - Nome declarado que não é coluna do model levanta
ValueErrorna construção do repository: um typo na declaração recusaria, em todo request, exatamente a coluna que você queria liberar. max_page_sizedo repository tem defaultNone(sem teto), porque chamadores internos como o export do painel/adminpaginam aos milhares. Declarado, vale parapaginate,cursor_paginate(sobrelimit) eGeoRepositoryMixin.paginate_nearby.
O conjunto do repository vale para todo caminho
orderable_columns no repository governa paginate, cursor_paginate e
changes_since — inclua a coluna de watermark (updated_at) se usar
sync. E o painel /admin ordena pelo mesmo repository: se ele usa esta
classe via repository_class, a ordenação por coluna fora do conjunto
passa a ser recusada lá também.
CursorPaginationFilterSchema tem o mesmo orderable_columns, e
max_limit (default DEFAULT_MAX_CURSOR_LIMIT, 500 — o le=500 que o
campo sempre teve) no papel de max_page_size.
Recap: declare orderable_columns e max_page_size no filtro de toda
listagem pública; o 422 lista só o que você declarou. No repository, os
mesmos nomes são a segunda linha.
Cursor — quando a tabela é grande¶
A paginação por cursor escala melhor que offset em tabelas grandes (sem
COUNT(*), estável sob inserts concorrentes) ao custo de perder acesso
aleatório. Já está pronta em cursor_paginate — ordena por
(order_by, id) e codifica o cursor opaco automaticamente:
# src/db/repositories/user.py
from typing import Any
from tempest_fastapi_sdk import BaseRepository, CursorPaginationSchema
from src.db.models import UserModel
from src.schemas import UserResponse
UserCursorPage = CursorPaginationSchema[UserResponse]
class UserRepository(BaseRepository[UserModel]):
# ... __init__ + mappers ...
async def cursor_page(
self,
*,
cursor: str | None = None,
limit: int = 20,
ascending: bool = False,
filters: dict[str, Any] | None = None,
) -> UserCursorPage:
"""Return one cursor-paginated page of users.
Args:
cursor (str | None): Opaque cursor from the previous page.
limit (int): Max items in the page.
ascending (bool): Sort direction.
filters (dict[str, Any] | None): Filter conditions.
Returns:
UserCursorPage: Items + next_cursor + has_more.
"""
result = await self.cursor_paginate(
filters=filters,
cursor=cursor,
limit=limit,
order_by="created_at",
ascending=ascending,
)
return UserCursorPage(
items=[self.map_to_response(i) for i in result["items"]],
next_cursor=result["next_cursor"],
has_more=result["has_more"],
limit=result["limit"],
)
Router, com o filtro vindo de um schema via Depends():
# src/api/routers/user.py
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import CursorPaginationFilterSchema
from src.api.dependencies.resources import SessionDep
from src.db.repositories import UserCursorPage, UserRepository
router = APIRouter(prefix="/api/users", tags=["users"])
class UserCursorFilter(CursorPaginationFilterSchema):
"""Cursor filter for the user listing."""
name: str | None = None # ILIKE %value% pela convenção do repository
@router.get("/", response_model=UserCursorPage)
async def list_users(
session: SessionDep,
f: UserCursorFilter = Depends(),
) -> UserCursorPage:
"""List users, cursor-paginated."""
repository = UserRepository(session)
return await repository.cursor_page(
cursor=f.cursor,
limit=f.limit,
ascending=f.ascending,
filters=f.get_conditions(),
)
O cursor é opaco
next_cursor é JSON em base64 url-safe. O cliente nunca o inspeciona;
ele devolve o valor literalmente até next_cursor virar null. Por
baixo, cursor_paginate usa encode_cursor/decode_cursor e uma
comparação de tupla (order_by, id) estável no Postgres.
O valor do cursor volta com o tipo da coluna
O cursor é JSON, então um datetime sai da página 1 como string ISO.
Antes de montar o WHERE, cursor_paginate reidrata o valor no tipo
Python da coluna (datetime, date, time, UUID, Decimal, enum).
Sem isso, o PostgreSQL recusava a comparação inteira na segunda
página:
E o SQLite não reclamava — comparava as duas strings e devolvia uma
página errada: medido, o passeio de 7 linhas em páginas de 3
repetia r02 e perdia r05. Coluna de texto continua intocada; um
TypeDecorator que não declara python_type também.
Para sincronização offline-first, há um terceiro modo
changes_since + SyncPaginationSchema fazem paginação de delta
(rows alteradas desde uma marca d'água). Veja
Offline sync ».
Recap: paginate (offset) para navegação por página; cursor_paginate
para feeds/tabelas grandes. Ambos prontos — você só mapeia o resultado
para o schema de resposta.
8. Migrações Alembic¶
AlembicHelper embrulha o Alembic com uma config curada (timezone UTC,
arquivos com prefixo de data, target_metadata já ligado, modo batch).
Fluxo completo: bootstrap → revisão → aplicar → gate de CI.
Bootstrap, uma vez por projeto¶
# scripts/alembic_init.py
from tempest_fastapi_sdk import AlembicHelper
from src.core.settings import settings
helper = AlembicHelper(config_path="alembic.ini", db_url=settings.DATABASE_URL)
helper.init(
directory="alembic",
metadata_module="src.db.models", # expõe BaseModel
metadata_attr="BaseModel",
db_url=settings.DATABASE_URL,
)
Cria:
alembic.ini # config curada pelo SDK (UTC, prefixo de data, post-write hooks, prepend_sys_path)
alembic/
├── env.py # run_alembic_env(target_metadata): hooks, compare_type, batch
├── script.py.mako
└── versions/
Gerar revisões¶
# scripts/make_migration.py
import sys
from tempest_fastapi_sdk import AlembicHelper
from src.core.settings import settings
helper = AlembicHelper("alembic.ini", db_url=settings.DATABASE_URL)
helper.revision(message=sys.argv[1], autogenerate=True)
O arquivo cai em
alembic/versions/2026_05_16_1432-ae12cd34_add_users_table.py — o prefixo
de data ordena cronologicamente e torna conflitos de merge óbvios.
Migrações já saem lint-clean
O alembic.ini que o init() escreve inclui [post_write_hooks] que
roda ruff format e depois ruff check --fix em cada revisão.
Sem isso, os arquivos do Alembic falham no tempest lint (W291 no
Revises: vazio, E501 em sa.Column(...) longas) — e é por isso
que o formatter vem primeiro: ele é quem quebra a linha longa e tira
o espaço à direita, então rodar o linter antes reportaria erros que
o hook seguinte ia consertar. Os hooks usam a
config de ruff do seu projeto. Requer ruff no PATH — já é
dependência de dev em todo scaffold tempest new.
Aplicar no startup¶
# src/api/app.py — dentro do lifespan
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from tempest_fastapi_sdk import AlembicHelper
from src.api.dependencies.resources import db
from src.core.settings import settings
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Run pending migrations, then serve."""
helper = AlembicHelper("alembic.ini", db_url=settings.DATABASE_URL)
await helper.upgrade_async()
await db.connect()
yield
await db.disconnect()
upgrade_async() roda a migration numa thread de trabalho: o env.py do SDK
chama asyncio.run, que não aninha dentro do loop do lifespan, então um
helper.upgrade() simples ali levanta RuntimeError nomeando o
upgrade_async. Todo comando que roda o env.py tem o mesmo par _async —
veja Migrations ».
Migrações destrutivas: use safe_upgrade
helper.pending_destructive_ops() lista DROPs de coluna/tabela
pendentes; helper.safe_upgrade() levanta DestructiveMigrationError
em vez de apagar dados silenciosamente. O guia completo de deploy
(migração + shutdown gracioso) está em
Deploy seguro ».
Gate de CI — o schema deve casar com os modelos¶
# scripts/check_migrations.py
import sys
from tempest_fastapi_sdk import AlembicHelper
from src.core.settings import settings
helper = AlembicHelper("alembic.ini", db_url=settings.DATABASE_URL)
if not helper.check():
print("Schema drift detected — run make_migration.py and commit.")
sys.exit(1)
print("Schema is in sync.")
# .github/workflows/ci.yml
- name: Check migrations are in sync
run: uv run python scripts/check_migrations.py
check() não acusa drift falso de enum no SQLite (v0.313.0, #483)
No SQLite uma coluna enum é um VARCHAR mais um CHECK
(ck_<tabela>_<enum>). O comparador de CHECK do Alembic lia a
constraint do banco mas a descartava do lado da metadata — elas são
_type_bound, ligadas ao tipo da coluna —, então check() propunha
um remove_constraint fantasma logo depois de um upgrade head
limpo e o gate de drift vivia desligado. O run_alembic_env passa um
include_object que ignora esse CHECK, chaveado por (tabela, nome)
para que duas tabelas com um CHECK homônimo não escondam o drift uma
da outra. Drift real continua acusado — coluna nova, e também a
mudança de membro do enum: o sync_enum_types anexa um
replace_enum, e o ReplaceEnumOp ganhou to_diff_tuple, sem o qual
o check() levantava NotImplementedError ao montar o diff em vez de
reportá-lo.
Colunas base sempre primeiro
O env.py do SDK instala o hook reorder_base_columns_first, então
toda migração gerada lista id / is_active / created_at /
updated_at antes das suas colunas — diffs consistentes entre
pessoas.
Coluna NOT NULL nova não explode mais (v0.67.0)
Adicionar uma coluna NOT NULL numa tabela que já tem linhas
estoura no Postgres com NotNullViolationError: column "x" contains
null values — porque um default= Python só roda no insert do ORM,
nunca como DDL. O SDK agora instala um segundo hook,
backfill_non_nullable_defaults: toda coluna adicionada que seja
nullable=False, sem server_default, mas com um default
escalar no model, recebe um server_default derivado desse default —
então a migração gerada backfilla as linhas existentes na mesma
instrução.
# A migração gerada agora sai assim (note o server_default):
op.add_column(
"users",
sa.Column(
"is_professional",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
Cobre bool / int / float / str / Enum (usa .value). Não
age quando o default é callable (uuid4, func.now()) ou inexistente
— esses precisam de uma migração de dados escrita à mão, porque o SDK
não tem como inferir um valor de backfill seguro.
Já tem um env.py antigo? Atualize o import + wiring para os dois
hooks compostos:
# alembic/env.py
from tempest_fastapi_sdk.db.alembic_hooks import (
backfill_non_nullable_defaults,
compose_hooks,
reorder_base_columns_first,
)
_process_revision_directives = compose_hooks(
reorder_base_columns_first,
backfill_non_nullable_defaults,
)
# ...e passe-o em context.configure(process_revision_directives=...)
Para uma migração já gerada que estourou, adicione o
server_default=sa.text("...") na mão no op.add_column (ou
backfille + alter_column para remover o default depois).
Migrar constraints compostas da convenção antiga¶
Se o seu banco foi criado antes da correção de nomes compostos, cada unique, índice e foreign key com mais de uma coluna ainda tem o nome da primeira coluna só. Os de uma coluna não mudaram. Ao atualizar o SDK, o gate de drift acusa diferença — e o que o autogenerate propõe não é o que você quer aplicar.
Num banco com UniqueConstraint("title", "release_year"),
Index(None, "author", "title") e uma FK composta, o
alembic revision --autogenerate gera (medido contra PostgreSQL 16 e Alembic
1.19.1):
from alembic import op
def upgrade() -> None:
"""O que o autogenerate gera sobre o banco antigo."""
with op.batch_alter_table("books", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_books_author"))
batch_op.drop_constraint(batch_op.f("uq_books_title"), type_="unique")
batch_op.create_index(
batch_op.f("ix_books_author_books_title"),
["author", "title"],
unique=False,
)
batch_op.create_unique_constraint(
batch_op.f("uq_books_title_release_year"),
["title", "release_year"],
)
Não aplique esse drop + create no PostgreSQL
Dois problemas medidos, e um custo:
- Quebra quando uma FK depende da unique. Com uma FK composta
apontando para a unique que ele derruba, o PostgreSQL recusa:
cannot drop constraint uq_authors_tenant_id on table authors because other objects depend on it. - Não vê as foreign keys. Uma FK composta cujo nome mudou não aparece
no diff; ela fica com o nome antigo no banco, e é esse nome que o
parse_integrity_errordevolve. - Reconstrói o índice. Criar a unique de novo constrói o índice
dela lendo a tabela inteira. O
RENAMEsó troca o nome — e no PostgreSQL renomear a constraint renomeia junto o índice por trás dela (medido empg_indexes).
O legacy_constraint_renames compara a metadata com a convenção antiga e
lista toda constraint composta cujo nome mudou — foreign keys incluídas —,
com o RENAME pronto. Rode uma vez, com todos os models importados:
# scripts/constraint_renames.py
from sqlalchemy.dialects import postgresql
import src.db.models
from tempest_fastapi_sdk import BaseModel, legacy_constraint_renames
dialect = postgresql.dialect()
renames = legacy_constraint_renames(BaseModel.metadata)
print("def upgrade() -> None:")
for rename in renames:
print(f" op.execute({rename.statement(dialect)!r})")
print("def downgrade() -> None:")
for rename in renames:
print(f" op.execute({rename.inverse().statement(dialect)!r})")
Crie uma revisão vazia (helper.revision(message="rename composite
constraints", autogenerate=False)) e cole a saída nela:
"""rename composite constraints to the per-column convention."""
from alembic import op
revision: str = "b7e1c2d3a4f5"
down_revision: str | None = "ae12cd34"
def upgrade() -> None:
"""Renomeia as constraints compostas para a convenção por coluna."""
op.execute("ALTER INDEX ix_books_author RENAME TO ix_books_author_books_title")
op.execute(
"ALTER TABLE books RENAME CONSTRAINT uq_books_title "
"TO uq_books_title_release_year"
)
def downgrade() -> None:
"""Volta aos nomes da convenção antiga."""
op.execute("ALTER INDEX ix_books_author_books_title RENAME TO ix_books_author")
op.execute(
"ALTER TABLE books RENAME CONSTRAINT uq_books_title_release_year "
"TO uq_books_title"
)
Depois do upgrade, o autogenerate não vê mais diferença nenhuma, e as
constraints de uma coluna ficaram intocadas (medido: compare_metadata
devolve [] sobre o banco renomeado, em tests/db/test_naming_live.py).
Por que colar, e não chamar a função dentro da migração
A migração tem que fazer amanhã o mesmo que faz hoje. Chamada no
upgrade(), a função calcularia os renames a partir dos models do dia
em que a migração roda — uma constraint composta criada depois entraria
num arquivo antigo. As revisões anteriores não precisam de nada: o
autogenerate gravou o nome em cada uma com op.f(...).
Detalhes que valem saber
- O SDK também tem uma. O
make_user_oauth_account_modeldeclaraUniqueConstraint("provider", "subject")eUniqueConstraint("user_id", "provider"):uq_<tabela>_providervirauq_<tabela>_provider_subject, euq_<tabela>_user_idvirauq_<tabela>_user_id_provider. Com o model importado, a função os lista junto dos seus. - Duas entradas com o mesmo
old_namesão a colisão antiga: o PostgreSQL recusou o segundoCREATE, então o banco tem só uma delas. Fique com a entrada cujas colunas são as da constraint que existe. - SQLite não renomeia constraint — o nome mora no texto do
CREATE TABLE—, estatement()levantaValueErrorfora do PostgreSQL. No SQLite de desenvolvimento, aplique a migração em batch que o autogenerate gera (ela reconstrói a tabela e funciona, medido) ou recrie o banco. - Quer adiar? Dê à constraint o nome que ela já tem no banco:
UniqueConstraint("title", "release_year", name="uq_books_title"). Nome explícito vence a convenção, e a função passa a ignorá-la.
Recap: init uma vez, revision --autogenerate por mudança, upgrade
no startup, check no CI, safe_upgrade para proteger dados. Banco criado
antes da correção de nomes compostos: legacy_constraint_renames escreve os
RENAME, e você cola numa revisão vazia.
9. Detectando queries lentas¶
SlowQueryLogger registra um listener na engine e emite uma linha de log
para toda instrução acima de um limiar. Anexe uma vez no boot:
# src/api/app.py — depois de db.connect()
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from tempest_fastapi_sdk.db import SlowQueryLogger
from src.api.dependencies.resources import db
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Connect, instrument slow queries, then serve."""
await db.connect()
slow = SlowQueryLogger(db.engine, threshold_ms=200.0)
slow.attach()
yield
await db.disconnect()
| Parâmetro | Padrão | Para quê |
|---|---|---|
threshold_ms |
500.0 |
Instruções neste tempo ou acima são logadas. |
level |
logging.WARNING |
Nível das linhas de slow-query. |
log_parameters |
False |
Inclui os bind params na linha. Só em dev — podem carregar PII. |
explain |
False |
Roda EXPLAIN e anexa o plano. Custa um round-trip por query lenta. |
log_parameters=True só em desenvolvimento
Os bind parameters podem conter segredos e PII. Mantenha False em
produção — o padrão já é seguro.
Recap: SlowQueryLogger(db.engine, threshold_ms=...).attach() no
lifespan transforma queries lentas em linhas de log acionáveis, com
EXPLAIN opcional para investigar planos.
Recap¶
BaseModeltrazid,is_active,created_ateupdated_at; você declara só as colunas do seu domínio, e o hook do Alembic mantém essa ordem nas migrações geradas.- Constraint composta ganha nome com todas as colunas
(
uq_books_title_release_year); banco criado antes dessa correção se migra com osRENAMEquelegacy_constraint_renamesescreve. - Um
AsyncDatabaseManagerpor aplicação, emresources.py— não um por request. BaseRepositoryserve instanciado para CRUD puro e subclassificado quando aparece query própria; filtro é um dict com convenção previsível, eNonenuma coluna simples éIS NULL.- Operação em lote tem duas famílias: a que devolve instância (
add_all,update_many) e a que não devolve, mas é uma ida ao banco. - Mixin entra quando o domínio pede: soft-delete e auditoria custam coluna,
e o filtro é seu — o
SoftDeleteMixinnão instala filtro global, então esconder linha apagada é passar{"deleted_at": None}. - Paginação tem duas formas com propósitos diferentes:
paginatepara navegar por página,cursor_paginatepara lista que cresce enquanto o usuário lê. - Migração é
inituma vez,revision --autogeneratepor mudança,upgradeno deploy — eSlowQueryLoggernoenginemostra a query lenta comEXPLAINantes do usuário reclamar.
Próximos passos¶
Esta página cobriu o núcleo. Os recursos avançados de banco têm receitas dedicadas:
- Multi-tenant » —
TenantScopedRepositorypara isolamento por tenant. - Audit trail » —
BaseAuditLogModel,add_audited/update_audited/delete_audited(quem mudou o quê, na mesma tx). - Outbox transacional » —
BaseOutboxModel+OutboxRelay,save_with_outboxpara publicar eventos atomicamente com a escrita. - Offline sync » —
changes_since+ paginação de delta para clientes offline-first. - Deploy seguro » — migrações destrutivas + shutdown gracioso.
- Testes » — SQLite em memória, fixtures,
create_tables.