ORM-бэкенды
SQLAlchemy 2.0 и Tortoise за единым контрактом адаптера — и четыре строки, которые между ними различаются.
Доступно с версии v0.2.0
Ничто выше fastfort/orm/ не может импортировать SQLAlchemy или Tortoise — при
нарушении тест ломает сборку. Интроспекция превращает модель в ModelSpec —
простые, неизменяемые данные, сериализуемые в JSON, — и каждый вышестоящий слой
работает именно с ними.
Результат оказался конкретным: второй адаптер был добавлен без единого изменения в админке.
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 никогда не открывает собственное соединение. Он использует фабрику, которую собрали вы.
Tortoise
uv add "fastfort[tortoise,postgres]"
Различаются четыре строки. Всё остальное — настройки, @admin.register,
list_display, действия, экспорт, импорт — идентично, потому что всё остальное
находится выше слоя ORM и модель вообще не видит.
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)
«Второй ведёт себя так же» — это тест
tests/orm/test_conformance.py задаёт обоим бэкендам одинаковые вопросы на
идентичных формах моделей — одни и те же столбцы, одни и те же связи, одни и
те же удаления — и сравнивает ответы. Именно поэтому утверждение выше — тест,
а не голословное заявление.
fastfort/orm/coerce.py — единственное, что общее у двух адаптеров, потому что
преобразование строки запроса в тип столбца касается самого столбца, а не той
или иной ORM.
Базы данных
| SQLite | PostgreSQL | MySQL | |
|---|---|---|---|
| Базовые CRUD, фильтры, поиск | ✅ | ✅ | ✅ |
| JSON | ✅ | ✅ JSONB | ✅ |
Массивы, hstore, диапазоны, inet, citext | — | ✅ | — |
| Геометрия PostGIS | — | ✅ | — |
| pgvector | — | ✅ | — |
uv add "fastfort[sqlalchemy,sqlite]"
uv add "fastfort[sqlalchemy,postgres]"
uv add "fastfort[sqlalchemy,mysql]"
Всё, что касается SQL и отличается между движками, находится в модуле диалекта бэкенда — он существует именно для того, чтобы утверждение «работает на всех трёх» можно было проверить тестом, а не просто надеяться на это.
Два правила, которым следует адаптер
Адаптеры никогда не коммитят
Они делают flush. Контекстный менеджер UnitOfWork коммитит при штатном
выходе и откатывает при исключении, поэтому наполовину неудавшийся запрос не
оставляет после себя ничего — включая массовое действие, упавшее на
сороковой строке.
Откат делает недействительным каждый атрибут
После await uow.rollback() чтение instance.id — это синхронное обновление
для асинхронной сессии вне greenlet-моста — оно приводит к падению.
Поэтому: считывайте все ещё нужные значения — метку, первичный ключ — до отката, либо сначала отрендерите ответ, а откат сделайте после. Это самый частый способ сломать код, работающий поверх адаптеров.
Поток запроса
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
Адаптер создаётся на каждый запрос и никогда не кэшируется; спецификация
кэшируется — именно поэтому register_type должен выполняться до того, как
что-либо будет интроспектировано.
Добавление бэкенда
Протоколы находятся в fastfort/orm/base.py: Backend, ModelAdapter,
UnitOfWork. Реализуйте эти три — и набор тестов на соответствие покажет,
всё ли сделано правильно, — а выше fastfort/orm/ менять ничего не придётся,
потому что эта граница обеспечивается тестом, а не соглашением.