ORM backends
SQLAlchemy 2.0 and Tortoise behind one adapter contract — and the four lines that differ between them.
Available since v0.2.0
Nothing above fastfort/orm/ may import SQLAlchemy or Tortoise, and a test fails
the build on a violation. Introspection turns a model into a ModelSpec — plain,
immutable, JSON-serialisable data — and every layer above works from that.
The payoff was concrete: the second adapter was added without changing a line of the admin.
SQLAlchemy
uv add "fastfort[sqlalchemy,postgres]"
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from fastfort.orm.sqlalchemy import SQLAlchemyBackend
class Base(DeclarativeBase):
pass
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/shop")
# Not a preference. The admin's views read attributes off an object *after* the
# unit of work has committed — the label in a success message, the primary key
# to redirect to. With the default, each of those is a synchronous refresh
# against an async session, which raises MissingGreenlet.
session_factory = async_sessionmaker(engine, expire_on_commit=False)
fort = FastFort(
settings=settings,
backend=SQLAlchemyBackend(session_factory=session_factory, base=Base),
)
FastFort never opens a connection of its own. It takes the factory you built.
Tortoise
uv add "fastfort[tortoise,postgres]"
Four lines differ. Everything else — the settings, @admin.register,
list_display, actions, export, import — is identical, because everything else
is above the ORM layer and never sees a model.
from tortoise import Tortoise
from fastfort.orm.tortoise import TortoiseBackend
await Tortoise.init(
db_url="postgres://user:pass@localhost/shop",
modules={"models": ["app.models"]},
# Tortoise keeps its connections in a contextvar, and an ASGI server runs
# the lifespan in a different task from the requests. Without this flag the
# init above is invisible to every view: start-up looks healthy and the
# first page that touches the database is a 500. `RegisterTortoise` from
# `tortoise.contrib.fastapi` passes it for you.
_enable_global_fallback=True,
)
fort = FastFort(settings=settings, backend=TortoiseBackend())
fort.set_user_model(User)
fort.mount(app)
“The second one behaves the same” is a test
tests/orm/test_conformance.py asks both backends the same questions over
identical model shapes — the same columns, the same relations, the same
deletions — and compares the answers. It is why the claim above is a test rather
than an assertion.
fastfort/orm/coerce.py is the one thing the two adapters share, because turning
a query string into a column’s type is about the column rather than about either
ORM.
Databases
| SQLite | PostgreSQL | MySQL | |
|---|---|---|---|
| Core CRUD, filters, search | ✅ | ✅ | ✅ |
| JSON | ✅ | ✅ JSONB | ✅ |
Arrays, hstore, ranges, inet, citext | — | ✅ | — |
| PostGIS geometry | — | ✅ | — |
| pgvector | — | ✅ | — |
uv add "fastfort[sqlalchemy,sqlite]"
uv add "fastfort[sqlalchemy,postgres]"
uv add "fastfort[sqlalchemy,mysql]"
Anything touching SQL that differs between engines lives in the backend’s dialect module, which exists precisely so that “works on all three” is testable rather than hoped for.
The two rules an adapter follows
Adapters never commit
They flush. The UnitOfWork context manager commits on a clean exit and rolls
back on an exception, so a half-failed request leaves nothing behind — including
a bulk action that fails on its fortieth row.
Rollback expires every attribute
After await uow.rollback(), reading instance.id is a synchronous refresh
against an async session outside the greenlet bridge — it crashes.
So: read every value you still need — the label, the primary key — before rolling back, or render the response first and roll back afterwards. This is the single most common way to break code that sits on top of the adapters.
Request flow
introspect(model) → ModelSpec once, cached on the backend
ModelAdmin(spec) once, validated when built
request → ListQuery.from_params(...) the validation boundary
→ backend.unit_of_work() one transaction per request
→ backend.adapter(model, uow) request-scoped, never cached
→ adapter.list/get/create/... flushes, never commits
→ Renderer.render(template) server-side HTML
The adapter is request-scoped and never cached; the spec is cached, which is
why register_type has to run before anything is introspected.
Adding a backend
The protocols are in fastfort/orm/base.py: Backend, ModelAdapter,
UnitOfWork. Implement those three and the conformance suite tells you whether
you got it right — and nothing above fastfort/orm/ needs to change, because
that boundary is enforced by a test rather than by convention.