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.

Базы данных

SQLitePostgreSQLMySQL
Базовые 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/ менять ничего не придётся, потому что эта граница обеспечивается тестом, а не соглашением.