Если вы работаете с базами данных и используете ORM, вы, вероятно, сталкивались с той же проблемой, что и я. ORM отлично подходят для отображения таблиц на объекты. Но они начинают мешать, когда запрос становится сложным: агрегации, тщательно продуманные JOIN’ы, формы отчетов, которые не соответствуют одной модели на таблицу. Вы боретесь с ORM, переходите на сырой SQL, а затем вручную пишете связующий код (маппинг).
Не каждый SELECT возвращает то, что подходит под одну ORM-модель. SQL - это лучший язык для доступа к данным. Лучшие ORM, которые я использовал, такие как Drizzle, побеждают, потому что они остаются близки к SQL. Я хотел пойти дальше: хранить SQL в системе контроля версий и генерировать из него типизированный Python.
Именно поэтому я создал nORM (no ORM - не ORM) и выпустил версию v0.1.0 на этой неделе (мой первый опенсорс проект).
nORM - это альтернатива использованию ORM для всего. Пока генератор работает только с Python. Позже я планирую миграции и больше языковых бэкендов; после этого он мог бы полностью заменить ORM, если вы этого захотите. На сегодняшний день это рабочий процесс в стиле sqlc плюс динамические возможности для запросов, которые в противном случае вы бы писали на Python.
Этот рабочий процесс вдохновлен sqlc. Если вы уже используете sqlc, и его вам достаточно - продолжайте.
SQL на входе, Python на выходе
Вы пишете схему и запросы на SQL. norm generate создает модели и классы репозиториев. Откройте сгенерированный метод, и SQL будет прямо там. Никакого скрытого слоя запросов.
Схема (norm_in/schema.sql):
CREATE TABLE users ( id SERIAL PRIMARY KEY, name text NOT NULL, blocked bool DEFAULT false );
Запросы (norm_in/repositories/users_repo.sql):
-- repo_name: UsersRepo -- name: get_user :one SELECT * FROM users WHERE id = :id; -- name: list_users :many SELECT * FROM users ORDER BY name;
Сгенерированный код (сокращенно из гайда по Python):
class UsersRepo: async def get_user(self, id: int) -> User | None: query = """ SELECT users.id AS id, users.name AS name, users.blocked AS blocked FROM users WHERE users.id = %(id)s """ params = {"id": id} async with self.db.cursor() as cur: await cur.execute(query, params) result = await cur.fetchone() ... return User(id=result[0], name=result[1], blocked=result[2])
Ваше приложение:
from norm_out.users_repo import UsersRepo async with get_db() as db: repo = UsersRepo(db) user = await repo.get_user(id=42)
Три шага
Напишите SQL (схема + запросы репозитория).
Запустите
norm generate.Импортируйте сгенерированный пакет и вызывайте методы репозитория из вашего приложения.
norm init создает шаблоны norm.yaml и папки. norm check полезен в CI: если запрос ссылается на отсутствующую колонку или тип параметра не совпадает, генерация завершится ошибкой.
Чем nORM превосходит простую кодогенерацию
sqlc останавливается там, где приложению все еще нужна runtime-композиция: опциональные фильтры для конечных точек списков, выбранные пользователем колонки для сортировки, частичные обновления, nullable объединения. nORM добавляет макросы, чтобы логика оставалась в SQL, а генератор ее разворачивал.
Динамическая фильтрация. Добавьте префикс _ к параметру, чтобы сделать предикат опциональным. Один запрос может покрыть множество комбинаций фильтров вместо построения строк в Python.
-- name: search_authors :many SELECT * FROM authors WHERE name = :_name AND rating > :_min_rating;
Руководство по динамической фильтрации описывает сгенерированный API и то, как nORM обрезает дерево WHERE во время выполнения.
Динамическая сортировка. Используйте n.ord() в ORDER BY, когда клиент выбирает колонку и направление сортировки.
-- name: list_authors_sorted :many SELECT * FROM authors a ORDER BY n.ord(a, :order_by, :desc), a.id ASC;
Сгенерированные методы принимают Literal[...] для разрешенных колонок и проверяют их перед выполнением запроса. Подробности: руководство по динамической сортировке.
Больше макросов (частичные обновления, встраивание JOIN и другие) доступны в обзоре и руководствах.
Как это работает
Разбор и работа с диалектами SQL выполняются через sqlglot. nORM читает DDL, SQL репозиториев и макросы, затем анализирует типы и структуру SQL перед кодогенерацией. Postgres, SQLite, MySQL, ClickHouse и DuckDB используют один и тот же путь. CLI, генераторы, макросы и конфигурация norm.yaml — это сам nORM.
Проект имеет широкое тестовое покрытие. v0.1.0 - это первый публичный релиз, но основная часть генерации закалялась в течение нескольких месяцев.
Что входит в версию 0.1.0
Включено: Генератор Python (асинхронный или синхронный, Pydantic или dataclasses через norm.yaml). CLI: norm init, norm generate, norm check, norm schema pull для интроспекции Postgres.
Пока нет: Генераторы для Rust, Go и TypeScript. Нет команды для миграций в версии 0.1.0; я планирую добавить миграции позже. До этого используйте свой обычный инструмент для изменений схемы.
Попробуйте
pipx install norm-cli norm init norm generate
Репозиторий: https://github.com/devfros/nORM
Комментарии (56)

Tishka17
05.06.2026 10:15Почему бы не использовать sqlachemy с (императивным) маппингом на запрос? Она умеет маппить модели/датаклассы на резульатат селекта любой сложности.

AfrosRajabov Автор
05.06.2026 10:15Вся концепция заключается в том, чтобы избавиться от ORM как от зависимости вовсе.

Tishka17
05.06.2026 10:15Я не понимаю эту ценность. Мы поменяли одну (очень мощную) библиотеку на другую (надо изучать, хватит ли возможностей).

AfrosRajabov Автор
05.06.2026 10:15nORM — это просто SQL с макросами и генератором кода. Он может всё, что может SQL, а это гораздо больше, чем любой ORM. Возможно, это непонятно из статьи, рекомендую пройтись по документации - я постарался там всё по полочкам разложить. Здесь же просто обзор.
И это не библиотека, которая идет в ваш проект, сгенерированный код идет.
Tishka17
05.06.2026 10:15Он может всё, что может SQL, а это гораздо больше, чем любой ORM.
Охотно верю, но можно пример что не может та же sqlachemy зато легко решается с вашим проектом?
И я не очень понял как состоит дело с динамическим sql? Условно когда содержимое where зависит от входных параметров и может содержать сабквери и доп. джойны и union. Как ваш проект это решает?

AfrosRajabov Автор
05.06.2026 10:15Примером может послужить любой достаточно сложный sql запрос. Запрос ниже в nORM будет выглядеть также в чистом виде. В SQlAlchemy даже представить не могу
WITH customer_metrics AS ( SELECT c.id, c.name, c.plan_type, COUNT(DISTINCT p.id) FILTER (WHERE p.status = 'failed') AS failed_payments, COUNT(DISTINCT p.id) FILTER (WHERE p.status = 'refunded') AS refunded_payments, COUNT(DISTINCT d.id) AS open_disputes, SUM(p.amount) FILTER (WHERE p.status = 'failed') AS total_failed_amount, MAX(p.created_at) FILTER (WHERE p.status = 'success') AS last_successful_payment, MIN(p.created_at) FILTER (WHERE p.status = 'failed') AS first_failed_since_success FROM customers c LEFT JOIN payments p ON p.customer_id = c.id AND p.created_at > NOW() - INTERVAL '90 days' LEFT JOIN disputes d ON d.payment_id = p.id AND d.status IN ('pending', 'investigation') WHERE c.status = 'active' AND c.deleted_at IS NULL GROUP BY c.id, c.name, c.plan_type HAVING COUNT(p.id) > 5 -- Has payment history ) SELECT cm.id, cm.name, cm.plan_type, cm.failed_payments, cm.refunded_payments, cm.open_disputes, cm.total_failed_amount, CASE WHEN cm.open_disputes >= 2 THEN 'immediate_suspend' WHEN cm.failed_payments >= 3 AND cm.refunded_payments * 1.0 / NULLIF(cm.failed_payments, 0) > 0.5 THEN 'suspend' WHEN cm.total_failed_amount > 1000 THEN 'review_required' ELSE 'safe' END AS action, DENSE_RANK() OVER (ORDER BY cm.open_disputes DESC, cm.failed_payments DESC) AS risk_rank FROM customer_metrics cm WHERE cm.last_successful_payment > NOW() - INTERVAL '30 days' AND cm.plan_type NOT IN ('enterprise', 'annual_enterprise') AND ( cm.failed_payments >= 2 OR cm.open_disputes >= 1 ) ORDER BY risk_rank LIMIT 100;
TheDigitalMadness
05.06.2026 10:15Не буду говорить, что мне не понравился ваше решение (напротив, оно очень интересное). Просто накину тезис.
Может я что-то неправильно понимаю, но в sqlalchemy (как и практически во все орм) есть возможность напрямую исполнить такой код. Предвижу справедливое замечание: при исполнении обычного sql запроса в орм, типизация отсутствует. И, фактически, это именно то, в чем выигрывает ваше решение: оно автоматически мапит типы. Но тут возникает вопрос резонности использования вашей nORM заместо sqlalchemy, теряя при этом функционал, который дает сама sqlalchemy

AfrosRajabov Автор
05.06.2026 10:15Да, text() / session.execute() выполнят любой SQL. Но это не тот же workflow: запрос в строке внутри Python, типов на входе/выходе нет, Row мапится вручную, схема и SQL живут отдельно, ошибки всплывают в рантайме. nORM - SQL в .sql-файлах, типизированные методы из генерации, плюс макросы для динамики без сборки строк. Разница не в том, можно ли выполнить запрос, а в том, сколько ручной работы остаётся вокруг него.
Про “теряем функционал SA” - частично согласен, но только если SA реально используется как ORM. На сложных запросах многие и так уходят в raw SQL и платят за ORM-слой, который для этих путей не работает. nORM это альтернативный слой доступа к данным, который работает для запросов любой сложности, а не замена (пока что).

wango_pama
05.06.2026 10:15ошибки всплывают в рантайме
В большинстве случаев чтобы этого избежать достаточно для всех запросов при старте проделать процедуру PREPARE
Полностью избежать не получится потому что схема БД может меняться прямо на лету (когда к БД подключены "захардкоженные" клиенты)

Tishka17
05.06.2026 10:15prepare сделать для всего не получится, если есть динамические запросы. А они почти всегда есть в каком-то количестве

wango_pama
05.06.2026 10:15А для динамических и типы результатов не определены - следовательно, это не решаемая анализом синтаксиса проблема.
Такие запросы зависят от переданных аргументов. Именно поэтому для них не сделали возможность PREPARE.
(В таком случае мы можем запускать запрос в транзакции, получать данные о типах и откатывать её. Нужно только где-то раздобыть валидные аргументы для запуска транзакции.)

Tishka17
05.06.2026 10:15Почему не определены? Мы можем сохранять состав колонок, но менять джойны, сабквери, юнионы, условия.

wango_pama
05.06.2026 10:15Мы не можем сохранить состав (и численность!) колонок потому что он может зависеть от аргументов этого запроса - всё зависит только от того как написан сам этот EXECUTE-запрос.
Сколько колонок вернёт вызов такой функции, например?
CREATE FUNCTION func1(query_text text) RETURNS SETOF record AS $$ BEGIN RETURN QUERY EXECUTE query_text; END; $$ LANGUAGE plpgsql;

Tishka17
05.06.2026 10:15Код, который выдает разный состав колонок в зависимости от параметров более редкий, так как с такими данными работать, так скажем, сложнее. А вот сохраняя состав колонок менять условия фильтрации, в том числе меняя джойны - это совершенно обычная ситуация.

wango_pama
05.06.2026 10:15Так ведь теория нам говорит о том что мы не знаем что вернёт код пока не выполним его. И в этом деле нам не поможет даже анализ его AST.
То есть, мы не знаем когда на эту редкость напоремся (а напоремся мы обязательно)

Tishka17
05.06.2026 10:15Признаю, мне было лень писать вручную, запрос действительно большой, но выглядеть будет примерно так. Что приятно - можно вынести переиспользуемые константы или сформировать запрос в виде константы и потом переиспользовать его как часть другого или добавлять к нему фильтры.
PAYMENT_HISTORY_SINCE = now - timedelta(days=90) RECENT_SUCCESS_SINCE = now - timedelta(days=30) DISPUTE_OPEN_STATUSES = [ DisputeStatus.PENDING, DisputeStatus.INVESTIGATION, ] EXCLUDED_PLAN_TYPES = [ "enterprise", "annual_enterprise", ] cm = ( select( c.c.id, c.c.name, c.c.plan_type, func.count(distinct(p.c.id)).filter(p.c.status == PaymentStatus.FAILED).label("failed_payments"), func.count(distinct(p.c.id)).filter(p.c.status == PaymentStatus.REFUNDED).label("refunded_payments"), func.count(distinct(d.c.id)).label("open_disputes"), func.sum(p.c.amount).filter(p.c.status == PaymentStatus.FAILED).label("total_failed_amount"), func.max(p.c.created_at).filter(p.c.status == PaymentStatus.SUCCESS).label("last_successful_payment"), ) .select_from(c) .outerjoin( p, and_( p.c.customer_id == c.c.id, p.c.created_at > PAYMENT_HISTORY_SINCE, ), ) .outerjoin( d, and_( d.c.payment_id == p.c.id, d.c.status.in_(DISPUTE_OPEN_STATUSES), ), ) .where( c.c.status == CustomerStatus.ACTIVE, c.c.deleted_at.is_(None), ) .group_by(c.c.id, c.c.name, c.c.plan_type) .having(func.count(p.c.id) > 5) .cte("customer_metrics") ) query = ( select( cm.c.id, cm.c.name, cm.c.plan_type, cm.c.failed_payments, cm.c.refunded_payments, cm.c.open_disputes, cm.c.total_failed_amount, case( (cm.c.open_disputes >= 2, "immediate_suspend"), ( and_( cm.c.failed_payments >= 3, cm.c.refunded_payments / func.nullif(cm.c.failed_payments, 0) > 0.5, ), "suspend", ), (cm.c.total_failed_amount > 1000, "review_required"), else_="safe", ).label("action"), func.dense_rank() .over(order_by=[cm.c.open_disputes.desc(), cm.c.failed_payments.desc()]) .label("risk_rank"), ) .select_from(cm) .where( cm.c.last_successful_payment > RECENT_SUCCESS_SINCE, cm.c.plan_type.not_in(EXCLUDED_PLAN_TYPES), or_( cm.c.failed_payments >= 2, cm.c.open_disputes >= 1, ), ) .order_by("risk_rank") .limit(100) )
AfrosRajabov Автор
05.06.2026 10:15Если это удобно, читабельно и легко поддерживать для вас, nORM вам точно не нужен. Как я уже сказал, это вопрос количества ручной работы вокруг всего этого.

Tishka17
05.06.2026 10:15Я просто попросил пример, что можно сделать на nORM и нельзя на sqlalchemy. Оба кода выше выглядят очень похоже. Да, sql можно сразу скопировать и выполнить, зато у алхимии можно выносить переиспользуемые части и добавлять динамику в конструирование. Получается всё таки вопрос вкуса, а не то что у ORM есть какие-то принципиальные ограничения, которые вы решили своим проектом?

AfrosRajabov Автор
05.06.2026 10:15Мне кажется все, что нужно в повседневной разработке, можно сделать и тем и другим. Вопрос не в том, что nORM умеет такого, что нельзя сделать ОРМ-ом, а в том КАК это делается. В nORM все сводится к написанию SQL запроса в SQL файле (это все что нужно сделать), в то время как с ОРМ этого недостаточно и при сложных запросах ответ нужно маппить руками. Ещё раз: nORM - это альтернативная имплементация слоя доступа к данным.

Tishka17
05.06.2026 10:15Я же правильно понял, что nORM не решает задачу сохранения данных, только чтение?
Кстати, а у вас стратегии загрузки связанных данных? Например, иногда дешевле сделать второй запрос по списку айди чем пытаться заджойнить.

AfrosRajabov Автор
05.06.2026 10:15Нет, не только чтение, работает с любым SQL запросом (SELECT, UPDATE, INSERT, DELETE). Даже есть частичные обновления таблиц, где вы пишите запрос на обновление один раз и обновляете только те колонки, которые подаете в функцию.

Tishka17
05.06.2026 10:15Давайте представим что у нас есть что-то такое
class Child: id: int name: str class Parent: id: int children: list[Child]Даьше, я с помощью вашего инструмента заселектил Parent с id=1 и всеми связанными children. После этого я поменял добавил туда ещё один Child, а один из существующий отредактировал (поменял name). Мне теперь что надо сделать чтобы это сохранить? Писать руками запрос на insert, ещё один на update и следить какой вызывать? Или тут есть аналоги подхода data mapper и unit of work?

AfrosRajabov Автор
05.06.2026 10:15Нет, такого функицонала как у ОРМ нету. Явно добавляете или обновляете.

AfrosRajabov Автор
05.06.2026 10:15Разработчик сам решает какие запросы ему писать и в какой композиции использовать. Задача nORM сгенерировать модели БД и репозитории с методами под каждый запрос.

Tishka17
05.06.2026 10:15Тогда я вообще не понимаю чем это лучше чем взять какой-нибудь pydantic/adaptix и сделать что-то максимально тупое вроде load(cursor.fetchall(), list[Data])
UoW нет, стратегий загрузки нет, квери билдера нет

AfrosRajabov Автор
05.06.2026 10:15Потому что это не ОРМ. Тут вы пишите запросы напрямую, поэтому и нет квери билдера. И текущая ранняя версия ни на что не претендует. Хотя nORM заменил мне ОРМ на несольких микросервисах вполне себе успешно.

AfrosRajabov Автор
05.06.2026 10:15Что касается динамических запросов, я их описал подробно в документации тут: https://devfros.github.io/nORM/guides/dynamic_filtering

Tishka17
05.06.2026 10:15Вв про такое? Не думаю, что это удобно хоть кому-то
"/*FILTER1*/": { 'clause': 'WHERE', 'tree': { 't': 'OR', 'l': {'p': ['id'], 'v': 'authors.id = %(id)s'}, 'r': {'v': 'authors.rating > %(rating)s'} } },
AfrosRajabov Автор
05.06.2026 10:15Это кусок генерируемого кода внутри функции, которую вы просто вызываете. Какое неудобство это может вызвать? Вы же не инспектируете код каждой библиотеки, которую устанавливаете.

AfrosRajabov Автор
05.06.2026 10:15Это дерево предикатов фильтра. В зависимости от поданных вами параметров, в запрос включаются только определенные. Легче было так сделать, чем генерировать тонну if-ов.

Tishka17
05.06.2026 10:15В алхимии я могу написать что-то типа
query = select(table1) if arg1: query = query.join(table2, cond) if arg2 is not NULL: query = query.where(table1.c.field2 == arg2) session.execute(query).all()То есть в завимсимости от параметров будет сгенерировано либо (минимальная версия)
select * from table1либо (максимальная версия, есть промежуточные)
select * from table1 join table2 on cond where table1.fiedl2 = %sКак в вашем проекте делать похожие вещи?

AfrosRajabov Автор
05.06.2026 10:15В текущей (0.2.0) версии динамические запросы затрагивают только фильтры и сортировку, JOIN-ов пока нет. В данном случае вы бы имели 2 разных запроса, сгенерировали бы 2 функции и вызывали бы эти функции также, как собираете ваш запрос:
if arg1: result = table_repo.get_minimal() elif arg2 is not None: result = table_repo.get_maxified(arg2)
Tishka17
05.06.2026 10:15ну это я написал два, в реальности этот код генерирует 4 варианта. Если фильтров допустим 10 штук (маленькая такая форма, бывает и 20), то это уже 1024 варианта
Естественно я не хочу дублировать общую часть, я хочу её переиспользовать

AfrosRajabov Автор
05.06.2026 10:15-- name: optional_id :many SELECT * FROM authors WHERE id = :_id or rating > :_rating;Функцию, которая генерируется на основе этого запроса nORM-ом, можно использовать как 4 разных вариаций в зависимости поданных параметров. 2^n ( где n - количество опциональных параметров). При n = 10 получаем 1024

Tishka17
05.06.2026 10:15я что-то не понял. Если у меня фильтрация по рейтингу не требуется, она это как-то вырежет из запроса?

AfrosRajabov Автор
05.06.2026 10:15Да, в итоге в БД отравится:
SELECT * FROM authors WHERE id = :id;

AfrosRajabov Автор
05.06.2026 10:15Не включит предикат (rating > :rating) в запрос, а не вырежет, если быть точнее.

Tishka17
05.06.2026 10:15я не очень понял по каком принципу оно вырезает кусок условия (например там может быть сложный подзапрос, где
:_ratingзапрятан глубоко в дереве), но допустим. Подожду что вы придумаете с джойнами

AfrosRajabov Автор
05.06.2026 10:15WHERE или HAVING - это всегда набор предикатов объединенных c помощью логических операторов (OR, AND). Опциональный параметер делает опциональным весь предикат. не важно на сколько сложен и вложен.

Tishka17
05.06.2026 10:15-- name: optional_id :many SELECT * FROM authors WHERE id = :_id or exists( select 1 from books where books.author_id=authors.id and books.rating > :_rating; )что будет вырезано? Как мне сказать что в одном случае надо вырезать весь exists, а во втором случае - только проверку на rating внутри?

AfrosRajabov Автор
05.06.2026 10:15Здесь будет контороллироваться только включение всего exist на основе :_rating. Это специальное ограничение пока что, я работаю над вложенными фильтрами.

Tishka17
05.06.2026 10:15Хорошо, работайте, интересно что получится. Пока мне кажется что этот подход тупиковый и вы родите какое-то подобие query builder в конце концов

AfrosRajabov Автор
05.06.2026 10:15Я вам рекомендую установить и попробовать просто, это супер быстро. И генерируется все моментально. Не могу на каждый вопрос ответить так же хорошо, как изложено в документации либо в генерируемом коде. Спасибо за обсуждение!

AfrosRajabov Автор
05.06.2026 10:15Это никогда не будет квери билдером. Я паршу чистый SQL, анализирую и генерирую код. В sql есть вся нужная информация, чтобы генерировать нужный код. В эту сторону я и двигаюсь.

wango_pama
05.06.2026 10:15Я паршу чистый SQL
Это очень плохая идея: корректно распарсить SQL-запрос чертовски сложно. SQLAlchemy делает это неправильно, например.
А главное - не нужно (по крайней мере для PostgreSQL): информация о типах аргументов в запросе и типах результата ответа всегда доступна (в моём софте я всё это беру через libpq)

AfrosRajabov Автор
05.06.2026 10:15Я использую хороший парсер, который отлично с этим справляется: https://github.com/tobymao/sqlglot
Когда он не справлялся, я пару раз открывал issue и 2 раза даже PR отправлял. Вопрос 1-2 дней буквально.
Весь проект покрыт e2e и runtime тестами. Все работает.
Сложно - никогда не аргумент.

wango_pama
05.06.2026 10:15"Сложно" здесь это обозначает что в таком разобре всегда будут проблемы
По какой-то (непонятной мне) причине проект PostgreSQL не предоставляет библиотеку синтаксического анализатора для своего диалекта SQL. (Хотя, естественно, внутри сервера этот код имеется - нужно только начать собирать его в отдельную библиотеку, на манер libpq)
Поэтому, каждый изобретатель велосипеда делает свой парсер в меру своих знаний. И, естественно, получается глюкавая чепуха

AfrosRajabov Автор
05.06.2026 10:15Ну, к счастью, в случае с парсером, который я использую, “глюковая чепуха” не получилась. Отлично парсит 30+ диалектов и дает возможность расширять возможности, если нужно где-то закрыть пробелы.

wango_pama
05.06.2026 10:15Ну, к счастью, в случае с парсером, который я использую, “глюковая чепуха” не получилась.
Таким образом, к концу года в программе будут исправлены все ошибки (c)

LernardDranrel
05.06.2026 10:15Как по мне - идея неплохая в своей сути. Можно конечно подескутировать относительно того не создание ли это очередного велосипеда, но от себя вижу вполне полезную нишу: обмен запросами с аналитиками BI - там может приятно лечь на общий pipeline проекта или по крайней мере упростить этот обмен.
Но есть три "no":)
Я бы порекомендовал где возможность сделать runtime компиляцию без необходимости вызывать команду каждый раз. Что-то вроде простенького nOrm.actualize(). Где в рантайм не получится - набрать готовых встроек на этап компиляции.
Учитывая сколько проектов УЖЕ на ORM, неплохо бы подсластить пелюлю и добавить коннекторы на основные ORMки языков, чтобы итоговый результат запроса аккуратно упаковывался в уже сущесвующие типы объектов строк или коллекций. Тогда выше шанс, что на nORM обратят внимание.
Скажу немного крамольную фразу для Хабра, но: "В эпоху ИИ ценность утилиты уже не столь очевидна". Я бы порекомендовал развивать ее во что-то более комплексное, но направление дать не решусь. Оставлю на ваших плечах.
Вот 3 моих маленьких совета. Надеюсь они вам помогут, даже если будут отброшены)
wango_pama
Прежде чем браться за миграции: а как оно будет работать с аггрегацией данных из трёх таблиц? Какая-нибудь нормализация, например, если применена к данным
AfrosRajabov Автор
Я думаю сделать schema.sql источником правды с автогенерацией миграций там, где это возможно, плюс возможность дописывать миграции там, где изменения сложнее, чем обычные добавления/удаления колонок. Так работает Alembic, например. У меня уже есть наработки и успехи с PostgreSQL, но пока далеко от идеала.
wango_pama
Ок, ещё раз: а как nORM будет работать с аггрегацией данных из трёх таблиц?
AfrosRajabov Автор
Уже сейчас отлично работает с запросами любой сложности, и не важно, сколько таблиц в них участвует. Успешно справляется с определением того, какой набор колонок в итоге возвращается, и генерирует соответствующий код. Если запрос возвращает не строго какую-то одну таблицу, то генерируется новая модель, и результат маппится на неё.
AfrosRajabov Автор
Вся задача этого инструмента - это определение того, что принимает запрос, что возвращает и генерирование типизированного кода на основе этого. Плюс дополнительные макросы, чтобы по ORM не скучать.