Комментарии 58
Прежде чем браться за миграции: а как оно будет работать с аггрегацией данных из трёх таблиц? Какая-нибудь нормализация, например, если применена к данным
Я думаю сделать schema.sql источником правды с автогенерацией миграций там, где это возможно, плюс возможность дописывать миграции там, где изменения сложнее, чем обычные добавления/удаления колонок. Так работает Alembic, например. У меня уже есть наработки и успехи с PostgreSQL, но пока далеко от идеала.
Ок, ещё раз: а как nORM будет работать с аггрегацией данных из трёх таблиц?
Уже сейчас отлично работает с запросами любой сложности, и не важно, сколько таблиц в них участвует. Успешно справляется с определением того, какой набор колонок в итоге возвращается, и генерирует соответствующий код. Если запрос возвращает не строго какую-то одну таблицу, то генерируется новая модель, и результат маппится на неё.
Вся задача этого инструмента - это определение того, что принимает запрос, что возвращает и генерирование типизированного кода на основе этого. Плюс дополнительные макросы, чтобы по ORM не скучать.
Почему бы не использовать sqlachemy с (императивным) маппингом на запрос? Она умеет маппить модели/датаклассы на резульатат селекта любой сложности.
Вся концепция заключается в том, чтобы избавиться от ORM как от зависимости вовсе.
Я не понимаю эту ценность. Мы поменяли одну (очень мощную) библиотеку на другую (надо изучать, хватит ли возможностей).
nORM — это просто SQL с макросами и генератором кода. Он может всё, что может SQL, а это гораздо больше, чем любой ORM. Возможно, это непонятно из статьи, рекомендую пройтись по документации - я постарался там всё по полочкам разложить. Здесь же просто обзор.
И это не библиотека, которая идет в ваш проект, сгенерированный код идет.
Он может всё, что может SQL, а это гораздо больше, чем любой ORM.
Охотно верю, но можно пример что не может та же sqlachemy зато легко решается с вашим проектом?
И я не очень понял как состоит дело с динамическим sql? Условно когда содержимое where зависит от входных параметров и может содержать сабквери и доп. джойны и union. Как ваш проект это решает?
Примером может послужить любой достаточно сложный 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;
Не буду говорить, что мне не понравился ваше решение (напротив, оно очень интересное). Просто накину тезис.
Может я что-то неправильно понимаю, но в sqlalchemy (как и практически во все орм) есть возможность напрямую исполнить такой код. Предвижу справедливое замечание: при исполнении обычного sql запроса в орм, типизация отсутствует. И, фактически, это именно то, в чем выигрывает ваше решение: оно автоматически мапит типы. Но тут возникает вопрос резонности использования вашей nORM заместо sqlalchemy, теряя при этом функционал, который дает сама sqlalchemy
Да, text() / session.execute() выполнят любой SQL. Но это не тот же workflow: запрос в строке внутри Python, типов на входе/выходе нет, Row мапится вручную, схема и SQL живут отдельно, ошибки всплывают в рантайме. nORM - SQL в .sql-файлах, типизированные методы из генерации, плюс макросы для динамики без сборки строк. Разница не в том, можно ли выполнить запрос, а в том, сколько ручной работы остаётся вокруг него.
Про “теряем функционал SA” - частично согласен, но только если SA реально используется как ORM. На сложных запросах многие и так уходят в raw SQL и платят за ORM-слой, который для этих путей не работает. nORM это альтернативный слой доступа к данным, который работает для запросов любой сложности, а не замена (пока что).
ошибки всплывают в рантайме
В большинстве случаев чтобы этого избежать достаточно для всех запросов при старте проделать процедуру PREPARE
Полностью избежать не получится потому что схема БД может меняться прямо на лету (когда к БД подключены "захардкоженные" клиенты)
prepare сделать для всего не получится, если есть динамические запросы. А они почти всегда есть в каком-то количестве
А для динамических и типы результатов не определены - следовательно, это не решаемая анализом синтаксиса проблема.
Такие запросы зависят от переданных аргументов. Именно поэтому для них не сделали возможность PREPARE.
(В таком случае мы можем запускать запрос в транзакции, получать данные о типах и откатывать её. Нужно только где-то раздобыть валидные аргументы для запуска транзакции.)
Почему не определены? Мы можем сохранять состав колонок, но менять джойны, сабквери, юнионы, условия.
Мы не можем сохранить состав (и численность!) колонок потому что он может зависеть от аргументов этого запроса - всё зависит только от того как написан сам этот EXECUTE-запрос.
Сколько колонок вернёт вызов такой функции, например?
CREATE FUNCTION func1(query_text text)
RETURNS SETOF record AS $$
BEGIN
RETURN QUERY EXECUTE query_text;
END;
$$ LANGUAGE plpgsql;
Код, который выдает разный состав колонок в зависимости от параметров более редкий, так как с такими данными работать, так скажем, сложнее. А вот сохраняя состав колонок менять условия фильтрации, в том числе меняя джойны - это совершенно обычная ситуация.
Признаю, мне было лень писать вручную, запрос действительно большой, но выглядеть будет примерно так. Что приятно - можно вынести переиспользуемые константы или сформировать запрос в виде константы и потом переиспользовать его как часть другого или добавлять к нему фильтры.
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)
)
Если это удобно, читабельно и легко поддерживать для вас, nORM вам точно не нужен. Как я уже сказал, это вопрос количества ручной работы вокруг всего этого.
Я просто попросил пример, что можно сделать на nORM и нельзя на sqlalchemy. Оба кода выше выглядят очень похоже. Да, sql можно сразу скопировать и выполнить, зато у алхимии можно выносить переиспользуемые части и добавлять динамику в конструирование. Получается всё таки вопрос вкуса, а не то что у ORM есть какие-то принципиальные ограничения, которые вы решили своим проектом?
Мне кажется все, что нужно в повседневной разработке, можно сделать и тем и другим. Вопрос не в том, что nORM умеет такого, что нельзя сделать ОРМ-ом, а в том КАК это делается. В nORM все сводится к написанию SQL запроса в SQL файле (это все что нужно сделать), в то время как с ОРМ этого недостаточно и при сложных запросах ответ нужно маппить руками. Ещё раз: nORM - это альтернативная имплементация слоя доступа к данным.
Я же правильно понял, что nORM не решает задачу сохранения данных, только чтение?
Кстати, а у вас стратегии загрузки связанных данных? Например, иногда дешевле сделать второй запрос по списку айди чем пытаться заджойнить.
Нет, не только чтение, работает с любым SQL запросом (SELECT, UPDATE, INSERT, DELETE). Даже есть частичные обновления таблиц, где вы пишите запрос на обновление один раз и обновляете только те колонки, которые подаете в функцию.
Давайте представим что у нас есть что-то такое
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?
Разработчик сам решает какие запросы ему писать и в какой композиции использовать. Задача nORM сгенерировать модели БД и репозитории с методами под каждый запрос.
Тогда я вообще не понимаю чем это лучше чем взять какой-нибудь pydantic/adaptix и сделать что-то максимально тупое вроде load(cursor.fetchall(), list[Data])
UoW нет, стратегий загрузки нет, квери билдера нет
Что касается динамических запросов, я их описал подробно в документации тут: https://devfros.github.io/nORM/guides/dynamic_filtering
Вв про такое? Не думаю, что это удобно хоть кому-то
"/*FILTER1*/": {
'clause': 'WHERE',
'tree': {
't': 'OR',
'l': {'p': ['id'],
'v': 'authors.id = %(id)s'},
'r': {'v': 'authors.rating > %(rating)s'}
}
},Это кусок генерируемого кода внутри функции, которую вы просто вызываете. Какое неудобство это может вызвать? Вы же не инспектируете код каждой библиотеки, которую устанавливаете.
Это дерево предикатов фильтра. В зависимости от поданных вами параметров, в запрос включаются только определенные. Легче было так сделать, чем генерировать тонну if-ов.
В алхимии я могу написать что-то типа
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Как в вашем проекте делать похожие вещи?
В текущей (0.2.0) версии динамические запросы затрагивают только фильтры и сортировку, JOIN-ов пока нет. В данном случае вы бы имели 2 разных запроса, сгенерировали бы 2 функции и вызывали бы эти функции также, как собираете ваш запрос:
if arg1:
result = table_repo.get_minimal()
elif arg2 is not None:
result = table_repo.get_maxified(arg2)
ну это я написал два, в реальности этот код генерирует 4 варианта. Если фильтров допустим 10 штук (маленькая такая форма, бывает и 20), то это уже 1024 варианта
Естественно я не хочу дублировать общую часть, я хочу её переиспользовать
-- name: optional_id :many
SELECT * FROM authors
WHERE id = :_id or rating > :_rating;
Функцию, которая генерируется на основе этого запроса nORM-ом, можно использовать как 4 разных вариаций в зависимости поданных параметров. 2^n ( где n - количество опциональных параметров). При n = 10 получаем 1024
я что-то не понял. Если у меня фильтрация по рейтингу не требуется, она это как-то вырежет из запроса?
Да, в итоге в БД отравится:
SELECT * FROM authors
WHERE id = :id;
Не включит предикат (rating > :rating) в запрос, а не вырежет, если быть точнее.
я не очень понял по каком принципу оно вырезает кусок условия (например там может быть сложный подзапрос, где :_ratingзапрятан глубоко в дереве), но допустим. Подожду что вы придумаете с джойнами
WHERE или HAVING - это всегда набор предикатов объединенных c помощью логических операторов (OR, AND). Опциональный параметер делает опциональным весь предикат. не важно на сколько сложен и вложен.
-- 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 внутри?
Здесь будет контороллироваться только включение всего exist на основе :_rating. Это специальное ограничение пока что, я работаю над вложенными фильтрами.
Хорошо, работайте, интересно что получится. Пока мне кажется что этот подход тупиковый и вы родите какое-то подобие query builder в конце концов
Я вам рекомендую установить и попробовать просто, это супер быстро. И генерируется все моментально. Не могу на каждый вопрос ответить так же хорошо, как изложено в документации либо в генерируемом коде. Спасибо за обсуждение!
Это никогда не будет квери билдером. Я паршу чистый SQL, анализирую и генерирую код. В sql есть вся нужная информация, чтобы генерировать нужный код. В эту сторону я и двигаюсь.
Я паршу чистый SQL
Это очень плохая идея: корректно распарсить SQL-запрос чертовски сложно. SQLAlchemy делает это неправильно, например.
А главное - не нужно (по крайней мере для PostgreSQL): информация о типах аргументов в запросе и типах результата ответа всегда доступна (в моём софте я всё это беру через libpq)
Я использую хороший парсер, который отлично с этим справляется: https://github.com/tobymao/sqlglot
Когда он не справлялся, я пару раз открывал issue и 2 раза даже PR отправлял. Вопрос 1-2 дней буквально.
Весь проект покрыт e2e и runtime тестами. Все работает.
Сложно - никогда не аргумент.
"Сложно" здесь это обозначает что в таком разобре всегда будут проблемы
По какой-то (непонятной мне) причине проект PostgreSQL не предоставляет библиотеку синтаксического анализатора для своего диалекта SQL. (Хотя, естественно, внутри сервера этот код имеется - нужно только начать собирать его в отдельную библиотеку, на манер libpq)
Поэтому, каждый изобретатель велосипеда делает свой парсер в меру своих знаний. И, естественно, получается глюкавая чепуха
Ну, к счастью, в случае с парсером, который я использую, “глюковая чепуха” не получилась. Отлично парсит 30+ диалектов и дает возможность расширять возможности, если нужно где-то закрыть пробелы.
Как по мне - идея неплохая в своей сути. Можно конечно подескутировать относительно того не создание ли это очередного велосипеда, но от себя вижу вполне полезную нишу: обмен запросами с аналитиками BI - там может приятно лечь на общий pipeline проекта или по крайней мере упростить этот обмен.
Но есть три "no":)
Я бы порекомендовал где возможность сделать runtime компиляцию без необходимости вызывать команду каждый раз. Что-то вроде простенького nOrm.actualize(). Где в рантайм не получится - набрать готовых встроек на этап компиляции.
Учитывая сколько проектов УЖЕ на ORM, неплохо бы подсластить пелюлю и добавить коннекторы на основные ORMки языков, чтобы итоговый результат запроса аккуратно упаковывался в уже сущесвующие типы объектов строк или коллекций. Тогда выше шанс, что на nORM обратят внимание.
Скажу немного крамольную фразу для Хабра, но: "В эпоху ИИ ценность утилиты уже не столь очевидна". Я бы порекомендовал развивать ее во что-то более комплексное, но направление дать не решусь. Оставлю на ваших плечах.
Вот 3 моих маленьких совета. Надеюсь они вам помогут, даже если будут отброшены)
Для тех, кто хочет попробовать без установки и настройки, я только что поднял Playground: https://norm-play.vercel.app/

nORM — ORM, но есть одно «no»