Читатели хабра знают меня как автора статей о django. Например, в предыдущем посте я писал о том, как собирался переписывать django на async/await. Кстати, первые эксперименты подтвердили, что подход рабочий, и что django отлично поддаётся переписыванию. А также - что он хорошо расширяем и содержит код высокого качества.
Однако, я пришёл к выводу, что несмотря на эти достоинства, django сильно уступает Pony ORM, которая, в отличие от него и других ORM, ещё и спроектирована правильно - способом, дружественным к оптимизациям. Не случайно в статьях, которые есть о ней на хабре, её хвалят именно за производительность.
В этой статье речь пойдёт о Pony ORM, но не о фичах для пользователя, которых много (о которых можно узнать в этих видео 1, 2), а о её внутренней архитектуре.

Насчёт того, что Pony ORM я открыл для себя сегодня - это не совсем верно: я знал о ней давно и даже когда-то работал в компании у её авторов. Мне даже однажды поручили добавить в pony поддержку миграций. Но этот дисклеймер вряд ли важен: это было давно, и сейчас моя личная заинтересованность - нулевая.
Но недавно я действительно смог посмотреть на Pony ORM под другим углом. И помог мне в этом проект SPy, который расшифровывается как "Static Python". Это проект автора PyPy, который отражает его новый взгляд на производительный питон.
Идея в том, чтобы с помощью анализа дерева AST определять, какие группы узлов можно безопасно скомпилировать. Те, которые можно скомпилировать - это синие узлы, те, которые нужно выполнять интерпретатором - это красные. Задача - красить узлы в синий цвет и минимизировать количество красных узлов.
Между прочим, проект активно развивается. Насколько я понимаю, сейчас они - в процессе написания стандартной библиотеки. Уже реализованы tuple, list, dict, str. Кстати, если вам перед собеседованием нужно повторить структуры данных - можете заглянуть туда. Код там - на питоне, но память нужно выделять вручную.
Вот, например, отрывок из реализации словаря:

В общем - это интересная штука, и я собираюсь её как-нибудь опробовать. Кстати, в ближайшие дни автор будет делать о ней доклад на EuroPython.
Хотя SPy не имеет прямого отношения к теме этой статьи, именно он заставил меня вспомнить про pony. Ещё, конечно - тот факт, что и SPy, и pony работают с помощью анализа AST, но это уже детали. В главном, подход одинаковый - быстродействие не за счёт минималистичности, а за счёт кэширования повторяющихся вычислений. Ну, и достаточный уровень IQ. Но давайте поговорим о pony.
Кэширование
Pony кэширует вообще всё подряд, на всех уровнях. Так что, если бы я использовал pony для своего веб-сервиса, то, возможно, сделал бы отдельный урл /pony-caches, где бы можно было посмотреть размер всех кэшей - на всякий случай.
Важный фактор: в pony запрос - это питоновский генератор, который в скомпилированном виде - хэшируемый глобально доступный объект, который можно использовать в качестве ключа.
gen = ( s for s in Student if s.gpa > 4 ) cache_key = gen.gi_code
Для сравнения, возьмём django:
qs = User.objects.filter(is_staff=True)
Запрос - это локальная переменная qs, которая будет собрана сборщиком мусора после выхода из функции. Как кэшировать какие-то промежуточные результаты для этого запроса - непонятно. Хотя, при желании, можно было найти решение - например, формировать ключ по имени модуля и номеру строчки, где создаётся запрос.
В pony, для запросов вначале вычисляется "абстрактный" SQL - чтобы не зависеть от диалекта конкретной базы данных. Вот как он выглядит:
['SELECT', [ 'ALL', ['COLUMN', None, 'USERNAME']], [ 'FROM', [None, 'TABLE', 'PROFILE']], [ 'WHERE', [ 'EQ', ['COLUMN', None, 'STATUS'], [ 'PARAM', value ] ]]]
Вообще, он похож на какой-нибудь язык Lisp - и некоторое сходство, действительно, есть: это достаточно гибкое представление запроса. Затем, так называемый SQL builder берёт это представление и уже переводит в SQL и список параметров.
Кстати, параметры в pony позиционные: даже если используется так называемый pyformat, в котором параметры передаются по имени, они всё равно называются %(p1)s, %(p2)s и т. д.
А между прочим, тренды сейчас другие. Например, в питоне 3.14 появились так называемые шаблонные строки, или t-строки, которые используют фигурные скобки для передачи параметров:
name = "pwtail" greeting = t'Hello, {name}'
При этом, greeting содержит как шаблон строки, так и переданный параметр name=pwtail. То есть, получается, шаблон может содержать как сам SQL, так и именованные параметры к нему. И, кстати, драйвер psycopg уже это поддерживает:

Это тоже не имеет к pony особого отношения, просто хотел поделиться.
Поддержка сессий
Теперь поговорим о том, что в pony называется db_session. Обратите внимание на название - оно намекает, что имеется в виду сессия именно в терминологии баз данных, а это значит - использование одного соединения. Казалось бы, это естественно для ORM - использовать ту же терминологию, что и базы данных, но вы увидите, что это не всегда так.
Моя рекомендация - использовать, по возможности, короткие сессии, которые явно указаны контекстным менеджером with:
with db_session: me = User.get(name='pwtail') me.is_superuser = True
Pony использует IdentityMap для кэширования объектов внутри сессии (по первичному ключу). Если объект есть в кэше, запрос в базу не делается.
Использование сессий, кстати, может облегчить возможную поддержку асихронности в pony в будущем. По сути, достаточно реализовать для db_session методы асинхронного контекста:
async with db_session: ...
Поскольку результат select-запроса - объект Query - ленивый, его тоже достаточно сделать Awaitable, чтобы использовать с await. Операции записи выполняются в конце сессии - их тоже менять не нужно. Остаются только запросы одиночных объектов, вроде User.get(id=id) и User[id]. Но даже если с ними ничего не придумать не удастся, всегда можно проверять, что мы внутри асинхронной сессии, и возвращать Awaitable.
db_session - пример адекватной работы с сессиями. Как мы сейчас увидим, не все мейнстримные фреймворки могут этим похвастаться.
Возьмём, например, django - там используется автокоммит. Если только вся функция не обёрнута в atomic, каждое действие будет приводить к транзакции. Это ещё ничего - вполне возможный подход. Естественное решение в этом случае - использовать внешний пул коннекшнов и брать из него коннекшн на одну транзакцию.
Самое интересное начинается дальше: django почему-то предполагает, что берёт коннекшн надолго. Получив коннекшн, он стремится его всячески настроить под себя и шлёт кучу запросов к базе. Что совершенно не нужно, если используется внешний пул соединений в режиме "коннекшн на транзакцию". С другой стороны, "вечные" соединения в среднем неэффективны, часто простаивают без дела и самое главное - не нужны самому django, поскольку он работает в режиме автокоммита.
Возьмём sqlalchemy. Как и в pony, там есть сессии, внутри которых поддерживается IdentityMap. Однако, это сессии без приставки "db_": внутри сессии совсем не обязательно используется одно и то же соединение.
Насколько я пониманию, примерный аналог db_session в sqlalchemy можно получить так:
with Session(engine) as session, session.begin(): session.add(some_object) session.add(some_other_object)
Но, я думаю, в коде такое можно встретить достаточно редко.
Миграции
Не могу не сказать о поддержке миграций, потому я лично участвовал в реализации этой фичи. К сожалению, миграции так и остались в ветке dev, хотя ветка была вполне рабочая.

Это слайд из презентации авторов pony на московской конференции. То, что вы на нём видите, относится к первой версии миграций. Позднее, они от неё отказались в пользу более сложной реализации, скопированной из django - на мой взгляд, напрасно. Первая версия мне нравилась больше.
Рассмотрим, как это могло бы работать. Миграции схемы могут быть написаны хоть на голом SQL (точнее, DDL), сложный случай представляют так называемые миграции данных (терминология из django). Это - когда схема базы не меняется, а выполняется скрипт, который изменяет данные. При этом, использовать обычный скрипт - хрупкое решение: к моменту, когда вы решите применить этот скрипт на продакшене, модели могут уже сильно измениться.
Чтобы представить себе, какие проблемы могут возникнуть, сделаем мысленный эксперимент. Пусть, мы пишем интернет-магазин, и у нас сейчас в базе данных около 5 таблиц, которые содержат, в общей сложности, 20 колонок. Мы поручаем Васе сделать одну крупную фичу, и он её делает за месяц, добавив, в общей сложности, 10 колонок. Параллельно с этим, мы поручаем Пете сделать другую крупную фичу, и ровно через месяц он сообщает, что и она готова. Петя добавил, в общей сложности, тоже 10 колонок. Кроме Васи и Пети, у нас ещё есть остальная команда - она занималась фиксами и мелкими фичами и тоже добавила за этот период, в сумме, 10 колонок.
Вопрос: сколько в нашей базе в продакшене сейчас колонок? Правильно, 30: наша команда, конечно же, выкатывала свои фиксы и фичи в прод.
Теперь, мы хотим выкатить в прод фичи Пети и Васи. Петя первым успел смержить в main свою ветку, поэтому его миграции, в основном, идут перед миграциями Васи.
Петя - опытный разработчик, он, конечно же, вовремя делал мердж из main в свою ветку - мерджил как код, так и миграции. Конфликтов в миграциях он не находил: да, были разрушительные изменения вроде удаления и переименования колонок, но его фичи они не касались. Равно как, он не нашёл конфликтов и после мерджа с фичей Васи: там, в основном, все изменения касались новых таблиц и колонок.
Большинство миграций у Пети - это изменения схемы, но в середине есть одна миграция данных. В моделях Пети на этот момент было около 30 колонок (5 штук он добавил сам и ещё 5 штук пришло из ветки main). Для создание этой миграции, он скопировал свои модели в define_entities и написал функцию migrate:
def define_entities(db): ... @db_session def migrate(db): clients = select(p for p in Person if ...) # update clients
Наконец, мы начали выкатывать фичу Пети и уже применили первые его несколько миграций - это были миграции схемы. Они нам добавили 5 колонок, то есть, теперь у нас в базе 35 колонок. Дальше идёт его миграция данных.
Теперь представьте, если бы Петя использовал бы для миграции данных обычный скрипт. В таком случае, он бы использовал новые модели из ветки main. Там сейчас целых 50 колонок - целые джунгли! Когда он писал свою миграцию, у него их было 30! В базе данных, на данный момент, их 35 - но это, всё равно, гораздо ближе к моделям в его миграции (если бы Вася первым смерджил свою ветку, их было бы больше).
Теперь давайте подумаем, как должны быть устроены миграции, чтобы функция migrate в миграции Пети выполнилась без ошибок. В базе, в среднем, есть 5 лишних колонок по сравнению с моделями Пети, причём, есть и разрушительные изменения - например, удалили 1 колонку. Поэтому, перед запуском migrate нужно обязательно проверить, все ли колонки, перечисленные в define_entities, присутствуют в базе, и лишние - удалить.
Но это ещё не всё: в базе ещё добавилось 5 новых колонок. Причём, наши разработчики - не дураки: они добавляют новые колонки, по возможности, совместимым образом. Если для колонки указан дефолт на уровне базы данных - всё хорошо, наша миграция не сломается. Но что, если дефолтное значение указано питоновской функцией? У нас в define_entities об этих 5 колонках не сказано ничего, зато ещё есть модели в коде (да, это те, в которых сейчас 50 колонок). И нам, в принципе, ничего не мешает посмотреть, как там определены эти 5 новых колонок.
В целом - я думаю, у вас теперь есть представление о том, какие проблемы нужно решать при реализации миграций. На мой взгляд, в рамках подхода с define_entities, можно получить годную реализацию. И, в этом виде, она гораздо проще, чем та, которая есть в django. Кстати, во многих других фреймворках (например, alembic) - реализация вообще неполноценная. Скорее всего, вы там вообще термин "миграции данных" не встретите.
* * *
Что касается настоящего момента - насколько я знаю, pony не поддерживает последние версии питона, из-за того, что там сломана декомпиляция (из байткода в AST). Вообще говоря, декомпиляция не является обязательной, учитывая, что у нас почти всегда есть исходники - но в pony сделано именно так. Кстати, может быть, ИИ-агент может починить декомпиляцию? Надо попробовать.
В общем и целом, pony - это наглядный пример того, что даже если вы пишете ORM - это всё равно это не повод, чтобы отключать мозг. Что касается меня - я планировал добавить в django поддержку асинхронности, чтобы научиться работать с ИИ-агентами. Так вот, я передумал: я не буду добавлять фичи в django, пока есть такая чудесная библиотека как pony. Потестирую ИИ-агентов на ней.
