Комментарии 61
Если сделаете ещё один шаг то сможете прийти к идее компонентов доступа к БД которые во времена Delphi/ Interbase активно испольщовались и позволяли использовать логику на уровне БД хранимые процедуры.
Как вариант борьбы с рассинхронизацией схемы и запросов в БД давно придумали views.
Которые позволяют адаптировать изменения под «старые» требования
Согласен с вашим ходом мысли. Как хранимые процедуры, так и View предоставляют возможность обеспечивать обратную совместимость запросов при миграции БД. На эту тему был интересный доклад от Макса Грамина на PGConf в этом году (https://pgconf.ru/talk/3033632).
Однако в обоих случаях остаётся открытым вопрос интеграции со стороны языка. Ни View, ни хранимые процедуры не дают статических проверок, интеграционного кода и тестов в языке программирования: это остаётся на плечах программиста.
Непонятно, чем model-first лучше чем code-first: определив типы и их отношения, мы как раз и описываем модель. В том же ef core есть куча точек расширений, в которых можно получить доступ к этой модели и сгенерировать на ее основе всё что угодно.
Мне в этом плане понравился подход, который сейчас внедряется в seaorm 2. Изначально там был подход migration first - миграции пишутся отдельно на специальном dsl, затем применяются к БД, и из живой БД генерируются сущности, используемые для запросов. Расхождения кода с БД устраняется уже на этом этапе, но далее добавляется обратная связь: вручную меняем сгенерированные сущности, по ним автоматически генерируется новая миграция. Ну и для корректности можно применить миграцию к БД, сгенерировать из нее сущности, до этого написанные вручную, и убедиться, что все совпало.
В ef core есть механизм для создания сущностей по бд, но чтобы вписать их в модель нужны дополнительные телодвижения: либо править для них миграции руками, либо вообще убрать их из миграций.
вручную меняем сгенерированные сущности, по ним автоматически генерируется новая миграция
В ef core как раз такой подход основной, только сущности не сгенерированные. В принципе, в нем db-first и code-first тоже можно совмещать, но такой подход нигде не рекламируется)
применить миграцию к БД, сгенерировать из нее сущности, до этого написанные вручную, и убедиться, что все совпало
А как сравнение происходит? На уровне исходников или есть семантический diff?
Сравнение происходит вручную, например, при помощи git diff. Вообще, там все довольно минималистично, должно совпасть с точностью до пробелов, но на практике именно в таком виде это не нужно, суть в самой возможности добавления обратной связи.
Если у вас обновление только через pipline - можно использовать migration first, генерировать модель автоматически из временной БД, к которой применилась миграция - и можно быть уверенным, что модель соответствует текущей схеме, а схема текущей миграции, т.к. прод меняется только миграциями. Или можно как и у вас - править модель вручную, по ней генерировать миграции - результат одинаковый, вы всегда можете сгенерировать одно из другого (по крайней мере если придерживаться некоторых правил организации кода).
Если же на проде несколько слабозависимых служб со своими миграциями, или админы вручную что-то меняют - можно выкачать из прода схему, сгенерировать по ней модель (можно фильтровать по именам таблиц), при помощи git посмотреть, что изменилось, что-то может нужно поправить вручную или может откатить - а затем можно сгенерировать миграцию по новым сущностям, и автоматически привести тестовую среду в к соответвию как модели, так и проду.
Единственное, я пока не уверен, насколько надежно генерируются миграции по модели - seaorm 2 еще не релизнулся, эта возмодность на уровне обзоров. Но вы как раз демонстрируете, что этот подход работает хорошо, а генерация модели по схеме БД в seaorm изначально хорошо работает.
Спасибо за ссылку. Интересная статья. Ироничная подача - тоже класс!
Насколько я понял, вы предлагаете отказаться от дихотомии: DB-first/Code-first, - и ввести третье представление между БД и языками. Данное представление будет нейтральным как относительно языков, так и относительно БД. Ценность этой нейтральности ясна: гибкость и устойчивость этой модели к переходам как между языками, так и между БД. Но помимо ценности есть и цена: переход сложности задачи от 2х до 3х тел со всеми вытекающими.
На мой взгляд, предлагаемый вами подход корректен и более гибок, чем DB-First, но и сложнее. Когда гибкость в отношении выбора БД не является требованием к системе, эта сложность будет избыточной. Иными словами, какой подход выбирать, как всегда в инженерии, "зависит".
Не думаю, что сложнее. Этот подход похож на DB-First тем, что правится база данных и потом генерируется то, что вам там надо по схеме. Отличие только в одном. DB-First ломается на нескольких окружениях, когда есть локальная БД и всякие /dev/stage/prod. У каждой БД будет своя истина, а в model-first база данных подгоняется под модель механизмом синхронизации. Code-first решает эту же проблему путём выкладывания в спец. таблицу истории миграций и жёстким запретом на трогание БД руками. Что очень неудобно.
И кстати. Если вы генерите sql запросы по схеме, можете попробовать скооперировать с моим инструментом. Его задача - выдать вашему скрипту актуальное состояние схемы данных и для абсолютной гибкости подхода я и сделал скрипт на стороне пользователя тулзы.
DB-First ломается на нескольких окружениях, когда есть локальная БД и всякие /dev/stage/prod. У каждой БД будет своя истина, а в model-first база данных подгоняется под модель механизмом синхронизации. Code-first решает эту же проблему путём выкладывания в спец. таблицу истории миграций и жёстким запретом на трогание БД руками.
А как же инструменты миграции Flyway, Liquibase, Sqitch и тп? Там так же накатывание миграций автоматизируется за счёт спец-таблиц. Конкретно это вовсе не прерогатива code-first.
Если вы генерите sql запросы по схеме
Их пишет пользователь.
Когда я только начинал и увидел hibernate, думал, что лучше его не будет, но часто именно такая магия, абстракция, становится узким местом и болью, особенно, если БД это основа приложения.
На одном из проектов я познакомился с JOOQ, DSL для работы с базой данных и насколько же удобно и прозрачно все это, теперь я жесткий фатан JOOQ) Понятно, что если у вас маленький проект ORM упростит работу, но с ростом будет сложнее с ним работать
Спасибо за статью)
Спасибо! Да. jOOQ в том же лагере инструментов. Если интересно, в доках есть сравнение pGenie vs jOOQ.
А смотрели sqlc ? Goorm? Они тоже могут генерить
Про Gorm не скажу, это похоже не ниша SQL-First.
А sqlc - прямой конкурент.
Принципиальное отличие в том, что sqlc целится на разные БД, из-за чего вынужден приводить внутренние представления к пересечению множества их возможностей, отбрасывая особенности. Поэтому, например, он до сих пор не поддерживает композитные типы Postgres. pGenie сфокусирован на Postgres и уже поддерживает продвинутые типы, включая композиты, многомерные массивы, multirange.
Ещё большое отличие в подходе к анализу. pGenie опирается на сам Postgres, разворачивая временный контейнер, проигрывая в нём миграции и прогоняя каждый запрос с извлечением метаданных из информационных схем. Благодаря этому поддерживаются любые синтаксические ухищрения и запросы любой сложности. Версию Postgres пользователь может выбирать, добиваясь соответствия продовой среде. В sqlc самописный эмулятор, который породил бесконечный поток багов несоответствия поведению Postgres. В последнее время, насколько я понял, они от него из-за этого стали плавно уходить.
Подробное сравнение есть в документации.
У нас .net ef + hot chocolate graphql. И много sql вообще под капотом собирается. Например, фильтры для админок склеиваются с фильтрами для контроля доступа, сверху докидываются сортировки, и т.п. Понятно, что в этом случае контроль над sql вообще утерян, но нам вроде и не надо - для всяких табличек в админах особенно.
В твоем подходе какая-то композиция запросов предполагается? Или только средствами БД (вьюшки и т.п.)?
Если подразумевается динамическое построение запросов, то пока не предполагается.
В демо-проекте есть пример со статическим запросом, реализующим динамическую выборку фильтров, условий сортировки и выборки. Для примера, вот генерируемый из него код Java.
Вопрос немного из другого разреза разработки - а зачем вообще пытаться что-то рефлектить "из SQL", или "из языка"?
В чем проблема использовать БД как фронт использует бэк, делая запросы к хранимкам (если уж там есть сложная логика)? В таком случае при миграциях приложение вообще трогать не нужно будет, если старый вид данных, конечно, можно как-то получить. Достаточно изменить хранимку в БД - т.е. при bзменении БД вы остаетесь в парадигме лишь этой БД.
Что касается тестов - естественно, они внешние должны быть, тут ничего не сделать, но эндпоинты на хранимках избавляют от необходимости переделывать те тесты, которые уже были для них написаны, и нужно просто написать тесты только для нового или изменившегося функционала.
Вообще, использовать рефлекты при работы с БД - это очень заманчивая, но очень порочная практика, как и ORM, ИМХО. Это ускоряет старт, но жутко мешает потом в жизненном цикле. Обычные запросы и более понятны (не надо гадать, во что они там трансформируются промежуточным слоем) и легче могут быть оптимизированы или вообще заменены. Единственная проблема - смена БД. Но это крайне редкая проблема, можно сделать миграцию и ручками раз в 10 лет.
Ну, либо я не понял сути вопроса.
Возможно, у нас возникло недопонимание. Попытаюсь распутать.
Я не понял, что вы имели в виду под “рефлектами” в контексте статьи.
Обычные запросы и более понятны (не надо гадать, во что они там трансформируются промежуточным слоем) и легче могут быть оптимизированы или вообще заменены.
В статье и предлагается использовать обычные запросы без промежуточных абстракций. Просто они дополняются автоматическим выведением типов, проверкой на корректность и соответствие схеме и генерацией типизированного интеграционного слоя для приложения.
В чем проблема использовать БД как фронт использует бэк, делая запросы к хранимкам (если уж там есть сложная логика)? В таком случае при миграциях приложение вообще трогать не нужно будет, если старый вид данных, конечно, можно как-то получить. Достаточно изменить хранимку в БД - т.е. при bзменении БД вы остаетесь в парадигме лишь этой БД.
Здесь противоречия нет. С помощью хранимок действительно можно создать API БД, скрыв сами таблицы от клиентов как чёрный ящик. Вы действительно получите возможность обеспечить обратную совместимость и избегать изменения в клиентских приложениях при миграциях. У хранимок есть свои проблемы, но это вполне рабочая схема и она применяется много где. Очень хороший доклад в этом году на эту тему был от @mgramin на PGConf.
Однако с хранимками всё так же остаётся открытым вопрос интеграции со стороны языков приложений. Вам всё ещё необходимо писать и поддерживать в приложении код, который вызывает эти хранимки, передаёт в них параметры и разбирает результаты.
Эту часть pGenie может взять на себя и сделать надёжной. С точки зрения pGenie SELECT call_my_stored_procedure($param1, $param2) - обычный запрос с параметрами и результатом, а потому он всё так же может сгенерировать для него обвязочный код и обеспечить проверки в CI/CD на то, что ваши миграции действительно оставляют ваши процедуры обратно-совместимыми с кодом приложений.
Если интересно, уже была близкая по смыслу ветка обсуждения.
Под рефлектами я имел ввиду отражение кого-либо экземпляра класса (его данных) в/из БД.
Да, сейчас стало понятней, что имелось ввиду, благодарю. Т.е. получается этакий линтер по типам и самим параметрам + возможность отслеживать, не нраушится ли флоу вследствии изменений структуры БД? Вот это инетресно, да.
Скорее, не линтер, а тайпчекер. То есть, он не антипаттерны выявляет, а несоответствия того, как вы БД используете с тем, что у вас в схеме. В этом процессе оно неизбежно выводит типы, и как следствие имеет всё, что нужно для кодогенерации, а потому и генерирует код. Ну а дальше включаются дополнительные фичи вроде файлов сигнатур, которые фиксируют типы ваших запросов в коде и добавляют новые свистелки вроде указания того, является ли запрос идемпотентным, что открывает возможность для авторетраев. Короче говоря, потенциала там много. Гляньте learn-pgenie-in-y-minutes для быстрой экскурсии.
Линтеры SQL есть существующие, поэтому в эту поляну пока лезть не буду.
А можно ли подружить ваш пигини с sqlx? Я уже понял что придется писать свой генератор, вопрос на сколько это сложно?
Допустим у меня один сэлект возвращает всю строку целиком. По моим конвенциям названия таблиц множественное число, а в расте я хочу что бы названия типов были единственного числа, значит мне нужно переименовывать пгшные типы по правилам в общем случае и я бы хотел вручную задать имя типа в расте не по правилам когда надо. Генератор такое умеет? Думаю правило не сложно сделать в самом генераторе, а вот как передать метаинформацию в гегератор чтобы смапать пгшный тип на произвольный растовский тип? По идее это мапа должна быть в ваших файлах сигнатур, но я не заметил по списку того что там может быть возможность задать мапу. Либо сигнатуры должны описывать генератор специфичные секции либо это должен быть еще один слой поверх сигнатур. Короче не похоже что без больших изменений это возможно - а надо.
Дальше вопрос по композитам, продолжение вопроса переименований, но в общем виде. Допустим мой кверик возвращает строку целиком + еще один скалярный флаг/енум. У меня есть такие кверики и я бы хотел что бы часть результирующего композита, которая строка, получила тип в расте тот же что я задал для обычного селекта * для этой таблицы. Короче мне надо не просто задавать производьные имена типам в расте, но и еще чтобы генератор понимал, что он должен использовать эти имена консистентно и в композитах тоже, что бы раст обьекты совпадали.
Миграции - у нас сложные миграции не чистый скул, скул + код. Есть миграции на тайпскрипте и недавно начали писать на расте. Как подружить ваш пигини с такими миграциями? Думаю никак, тут у вас ошибка проектирования генератор не должен требовать каких либо миграций вообще. Это не его дело, ему нужна тупо конекция к аптудэйт базе, а как она мигрируется это не его забота. Малотого анализ работает не совсем одинаково на пустой и не пустой базе, но это слабый аргумент т.к. к прод базе конечно никто ради анализа не полезет, но все равно очень удобно иметь не пустую базу (у нас даунсайз прода в отдельном контейнере и мигрируется также как и прод) и дебажить кверики на ней. Да и генерация быстрее не надо мигрировать каждый раз чтобы сигнатуры посчитать.
Я это все спрашиваю не просто так, у нас есть похожий генератор, только проще. Ограничен пг -> раст. Мы используем sqlx, тоже есть много чего, анализ индексов телеметрия (у вас кстати нет), композиты, нулаблы, форс нот нулл в результатах, компайл тайм генерация. Короче разница с вашим проектом в простоте https://github.com/thepartly/automodel
Ваш пигини конечно более правильный архитектурно (за исключением миграций), но нам ехать, а не шашечки.
Спасибо за развернутый фидбек! Очень ценно.
Классный термин вы ввели: “Reverse ORM”. Ёмко! Не встречал.
А можно ли подружить ваш пигини с sqlx?
Не проблема! Хотел бы только узнать, почему предпочитаете его tokio-postgres?
Спрашиваю потому, что изначально колебался для какой библиотеки писать первый генератор. В итоге выбрал tokio-postgres, потому что он более легковесный и сфокусирован на PostgreSQL. Но с другой стороны для него явно не хватает решения с одной кассой. С пулом, prepared statements, транзакциями и нормальной эргономикой. deadpool-postgres не дотягивает. Я подумывал под это дело расширить генератор, чтобы опционально с батарейками код выдавал или библиотеку расширительную выпустить, но тут надо принимать решение с пользователями. Отсюда и мои вопросы :)
Я уже понял что придется писать свой генератор, вопрос на сколько это сложно?
Несложно. Процесс устаканился. Уже есть 3 генератора как пример. Берёте входные данные как из того же демо-проекта. Руками пишете код, который хотели бы, чтобы генератор на эту модель выдавал. Передаёте это всё в LLM, а дальше напильником доводите до ума. Практика показывает, что нескольких итераций хватает для чего-то, чем можно начинать пользоваться.
Соответственно, если задача - расширить существующий генератор, то это ещё проще. Форкаете, командуете LLM изменить генератор так, чтобы он выдавал то, что вы хотите.
Полагаю, что получилось так просто благодаря тому, что для генераторов использовался Dhall, который строго типизирован и очень ограничен, что даёт LLM внятную структуру и резко сужает пространство для бреда. При этом язык достаточен для написания любого генератора.
Если хотите расширить сам инструмент, приглашаю в Github Discussions. Если понадобится помощь с приватным кодом, обращайтесь по линии консалтинга. Я в РФ.
Допустим у меня один сэлект возвращает всю строку целиком. По моим конвенциям названия таблиц множественное число, а в расте я хочу что бы названия типов были единственного числа, значит мне нужно переименовывать пгшные типы по правилам в общем случае и я бы хотел вручную задать имя типа в расте не по правилам когда надо. Генератор такое умеет?
Названия таблиц pGenie вообще не касаются. Из названий его заботят только три категории: название запроса, его параметров и колонок в его результатах. Это предопределяет структуру генерируемого кода.
Так, в примере генерируемого запроса из демо-проекта, на исходный запрос /queries/select_album_by_format.sql создаётся модуль statements::select_album_by_format, в котором его параметрам соответствует тип данных Input, а его результатам - Output, который является алиасом к вектору над типом OutputRow, который объявляется в том же модуле.
Такая структура кода не типична для кодогенераторов, однако она воплощает архитектурный принцип low-coupling/high-cohesion. Запросы не связаны между собой содержащим модулем (они объявляются в отдельных модулях), а содержимое каждого модуля запроса максимально сплочённо (все детали, относящиеся к одному запросу, находятся в одном месте). Помимо предсказуемости и удобства навигации это ещё ускоряет компиляцию, так как компилятор может компилировать модули параллельно, а изменения в одном запросе не влияют на другие. Когда запросов накопятся сотни, это будет иметь значение.
Тут вытекает вопрос: а что из этого вы хотели бы переименовывать?
Думаю правило не сложно сделать в самом генераторе, а вот как передать метаинформацию в гегератор чтобы смапать пгшный тип на произвольный растовский тип? По идее это мапа должна быть в ваших файлах сигнатур, но я не заметил по списку того что там может быть возможность задать мапу.
Генераторы конфигурируемы. Генератор сам определяет контракт требуемой им конфигурации. Так что, как минимум, вы можете передавать общие правила или те же мапы. Вот пример конфигурации специфичной для генератора java.
Либо сигнатуры должны описывать генератор специфичные секции либо это должен быть еще один слой поверх сигнатур. Короче не похоже что без больших изменений это возможно - а надо.
Сигнатуры пока не содержат возможности передавать генераторо-специфичные настройки, но потенциально в будущем такая возможность может появиться. Нужен только востребованный кейс.
Дальше вопрос по композитам, продолжение вопроса переименований, но в общем виде. Допустим мой кверик возвращает строку целиком + еще один скалярный флаг/енум. У меня есть такие кверики и я бы хотел что бы часть результирующего композита, которая строка, получила тип в расте тот же что я задал для обычного селекта * для этой таблицы. Короче мне надо не просто задавать производьные имена типам в расте, но и еще чтобы генератор понимал, что он должен использовать эти имена консистентно и в композитах тоже, что бы раст обьекты совпадали.
Это уж очень тонкая настройка пошла. Так ли она важна практически? Реализовывать дорого как в плане дизайна, так и усложнения системы, а практическая польза в чём?
Похоже, это ещё и вступает в противоречие с одним из инсайтов из обсуждаемой статьи:
Попытки переиспользовать “типы строк” между разными запросами только создают скрытые зависимости и закладывают будущие поломки из разряда “меняем один запрос, ломается другой”.
Сейчас правило простое: у каждого запроса свои типы. Чего предлагаемое усложнение нам даст с практической точки зрения?
Миграции - у нас сложные миграции не чистый скул, скул + код. Есть миграции на тайпскрипте и недавно начали писать на расте. Как подружить ваш пигини с такими миграциями? Думаю никак, тут у вас ошибка проектирования генератор не должен требовать каких либо миграций вообще. Это не его дело, ему нужна тупо конекция к аптудэйт базе, а как она мигрируется это не его забота. Малотого анализ работает не совсем одинаково на пустой и не пустой базе, но это слабый аргумент т.к. к прод базе конечно никто ради анализа не полезет, но все равно очень удобно иметь не пустую базу (у нас даунсайз прода в отдельном контейнере и мигрируется также как и прод) и дебажить кверики на ней. Да и генерация быстрее не надо мигрировать каждый раз чтобы сигнатуры посчитать.
Можно написать простой скрипт, который проиграет во временной БД ваши миграции, используя ваше текущее решение. Дальше сделать дамп получившейся схемы в миграцию /migrations/1.sql. Дальше запускается pGenie.
Для разработки это может стать билд-скриптом или инструкцией Makefile. В CI/CD это настроить тоже не проблема. Заоптимизировать тоже пространство есть: обновлять /migrations/1.sql только при изменении ваших текущих миграций.
Я это все спрашиваю не просто так, у нас есть похожий генератор, только проще. Ограничен пг -> раст. Мы используем sqlx, тоже есть много чего, анализ индексов телеметрия (у вас кстати нет), композиты, нулаблы, форс нот нулл в результатах, компайл тайм генерация. Короче разница с вашим проектом в простоте https://github.com/thepartly/automodel
Познакомлюсь с вашим проектом плотнее, спасибо за ссылку.
Соглашусь, телеметрию стоит, как минимум, опционально встроить в генератор. Ну это просто вопрос доработки генератора.
Ваш пигини конечно более правильный архитектурно (за исключением миграций), но нам ехать, а не шашечки.
Понимаю и нацелен двигаться в сторону практических кейсов. Если вам интересно, может сделать с вами пилот. Вам интеграция, мне бизнес-кейс. Rust для меня - приоритетное направление.
Это уж очень тонкая настройка пошла. Так ли она важна практически?
Это очень важное практическое свойство. Там выше вы писали что каждый кверик генерирует свой набор типов в расте - это ошибка проектирования. Все кверики врзвращаюшие одинаковые данные должны использовать одинаковые типы иначе как всем этим пользоваться? Писать руками from для каждого кверика в доменный тип? А зачем тогда вообще использовать ваш генератор?
Поймите ценность гегератора не в том что бы с ноля начинать писать проект по феншую (как автор генератора считает правильным) а в том чтобы он облегчал практические задачи (особенно бойлерплэйт) вокруг уже существующего кода. Доменные типы уже есть, никто не хочет тратить время на написание в ручную и поддержку преобразований в/из типов базы. Это кстати ответ почему sqlx он позволяет смапать доменный тип на тип базы и проверить тоже в компил тайме только дороже по времени компиляции чем наш генератор. Еще sqlx умееет в динамические кверики, ну и самый главный критерий - так решила команда.
Реализовывать дорого как в плане дизайна, так и усложнения системы
Неа, это как раз не оч сложно, но вероятно не так хорошо дружит с вашей идеей раздельной компиляции, но все нормальные языки умеют в модули импорт/экспорт - это решение для этой задачи если прям оч хочется раздельной компиляции, но работа с пг / составление сигнатур это большая часть времени, код ген намного быстрее так что имхо это та самая оптимизация которая все только усложняет.
Сейчас правило простое: у каждого запроса свои типы. Чего предлагаемое усложнение нам даст с практической точки зрения?
Ну надеюсь из вышесказанного понятно зачем это надо. У нас много таких квериков уже и это очень важно чтобы интеграция с существующим кодом была максимально проста. Ваше боязнь скрытых зависимостей необоснована, во первых они не скрытые, а задаются самими типами из базы - ну какой смысл генерировать 100500 обсалютно одинаковых (по полям) но совершенно ге вщаимозаменяемых структур? Если один кверик читающий строку целиком поломался то очевидно все остальные которые выдают точно такую же строку обязаны поломаться, а при ваших 100500 разных типов я буду должен руками фиксить это не один раз а 100500.
Я понял у нас с вами несовместимость по философии - вам важно шашечки / как "правильно", а нам важно ехать. На практике это значит, что генератор не имеет право ничего навязывать/требовать изменить в проекте, мне не надо тратить время писать какие то скрипты, что бы после миграций еще и сдампить схему чтобы потом ее импортнуть во временную базу ровно для того что уже и так доступно без всех этих телодвижений. Юникс философия, утилита должна заниматься своим делом и не лезть со своими советами/требованиями как жить всем остальным. Никто не будет танцевать вокруг того, что там вы как автор генератора считаете правильным. Если ваш генератор больно подружить с тем что есть, ну значит не судьба. А ваш генератор больно подружить. Простые/маленькие кастомизации должны требовать простых/маленьких усилий. Поменять драйвер на sqlx это большая кастомизация - писать новый генератор адекватная затрата. Смапать тип из базы на доменный это маленькая задача - писать для этого новый генератор не адекватное усилие. Подключить генератор к существующей базе должно быть оч просто, а не напиши скрипт который сдампит базу. В общем с такой философией нам не попути.
Дальше вопрос по композитам, продолжение вопроса переименований, но в общем виде. Допустим мой кверик возвращает строку целиком + еще один скалярный флаг/енум. У меня есть такие кверики и я бы хотел что бы часть результирующего композита, которая строка, получила тип в расте тот же что я задал для обычного селекта * для этой таблицы.
Я не обратил внимание, что ваш изначальный вопрос был про композиты. Композиты переиспользуются между запросами без дополнительных настроек и ваш кейс вполне реализуем с помощью композитов уже сейчас.
Вот пример объявления композита в миграции. А вот пример запроса, который засовывает часть колонок к этот композит и дополняет ещё одной скалярной колонкой. Похоже на то, что вы описали?
Вот во что этот композит превращается в расте, вот во что превращается запрос в расте. И да, композит может использоваться в нескольких запросах, и в каждом запросе он будет превращаться в один и тот же тип в расте, вот пример соседнего запроса, использующего тот же композит.
Неа, это как раз не оч сложно, но вероятно не так хорошо дружит с вашей идеей раздельной компиляции, но все нормальные языки умеют в модули импорт/экспорт - это решение для этой задачи если прям оч хочется раздельной компиляции, но работа с пг / составление сигнатур это большая часть времени, код ген намного быстрее так что имхо это та самая оптимизация которая все только усложняет.
Я имел в виду усложнение системы pGenie, а не вашего проекта. Чтобы тут нормальное решение задизайнить надо проблему глубоко проработать, а дальше реализовывать это потребует изменений в логике, контракте кодогенератора, и изменения кодогенератора. Поэтому это дорого и ключевым вопросом становится: насколько оно оправдано. Пока что вы первый человек с таким запросом.
Ваше боязнь скрытых зависимостей необоснована, во первых они не скрытые, а задаются самими типами из базы - ну какой смысл генерировать 100500 обсалютно одинаковых (по полям) но совершенно ге вщаимозаменяемых структур? Если один кверик читающий строку целиком поломался то очевидно все остальные которые выдают точно такую же строку обязаны поломаться, а при ваших 100500 разных типов я буду должен руками фиксить это не один раз а 100500.
Вы тут смотрите на систему в моменте времени. Да, в моменте два запроса могут возвращать одинаковый набор колонок. Давайте теперь взглянём в динамике. Разные запросы развиваются независимо и с течением времени возникнет потребность в один из связанных запросов добавить колонку. И тогда вам придётся менять код запросов, которые к этому изменению никакого отношения не имеют, увеличивая размер задачи. Таким образом, с упрощением системы в моменте, закладывается усложнение задач поддержки в будущем. Иначе говоря, происходит накопление технического долга.
На практике это значит, что генератор не имеет право ничего навязывать/требовать изменить в проекте, мне не надо тратить время писать какие то скрипты, что бы после миграций еще и сдампить схему чтобы потом ее импортнуть во временную базу ровно для того что уже и так доступно без всех этих телодвижений.
Хорошо. Только не забывайте, что в подходе с подключением к живым базам, использующимся для других задач, вы за удобство расплачиваетесь воспроизводимостью сборки.
Никто не будет танцевать вокруг того, что там вы как автор генератора считаете правильным.
Это одна из причин, почему я сделал кодогенераторы децентрализованными. У каждой компании свои приоритеты, у каждого техлида свой путь роста и набивания шишек, у каждого разработчика свои предпочтения в стиле кодирования. Всем не угодить.
Я предлагаю своё видение оптимального сгенерированного кода и подкрепляю публикациями вроде обсуждаемой. Тем не менее, согласятся с этим видением не все, и это нормально. Для несогласных есть возможность форкнуть кодогенератор и реализовать своё видение. В эпоху LLM это просто. Для совсем не согласных есть альтернативные инструменты и подходы, и это тоже вполне нормально.
Моё предложение о пилоте всё ещё в силе.
Пример с композитом близок, но не полностью. Представьте есть select * from tracks; и есть with... select (*)::tracks as row, 0 as result_code; Дальше по правилам именований на расте струтура для строки трэка должна быть Track без s на конце + второй кверик должен вернуть что нибудь типа struct ComplexSelectItem { row: Track, result_code: i32} Обратите внимание что я могу задать имя как всему компощиту так и сам генератор должен понять что для row ему не надо генерировать новый тип Tracks а надо использовать имя которое я задал для результата у первом обычном селекте.
Чтобы тут нормальное решение задизайнить надо проблему глубоко проработать, а дальше реализовывать это потребует изменений в логике, контракте кодогенератора, и изменения кодогенератора.
Это не сложно сделать. Я же не зря упомянул обычные языки с их импортами/экспортами - проблема решенная, решение простое, ничего изобретать и усложнять не надо.
Да, в моменте два запроса могут возвращать одинаковый набор колонок.
Большинство реальных запросов очень простые и возвращают строки целиком, такими и останутся на всегда, мне не нужно чтобы результат каждого запроса с тем же самым результатом генерировал уникальные не взаимозаменямые структуры, мне нужно роано наоборот.
Придётся менять код запросов, которые к этому изменению никакого отношения не имеют, увеличивая размер задачи.
Вот когда один из запросов поменяется и получит уникальный набор колонок в результате, вот тогда пусть генератор и сделает уникальную структуру, но не раньше, а все другие запросы что раньше шарили с этим результат продолжат его шарить, но уже без этого кверика - для них ничего не поламается. При добавлении колонки в результат ВСЕ колл сайты такого кверика надо будет руками потрогать, это вам раст, а не абы че, вы не сможете втихую проигнорировать новое поле в структуре. Зависимый от этого кверика код гарантировано поломается - так и должно быть поэтому никакого увеличения задачи нет от слова совсем.
вы за удобство расплачиваетесь воспроизводимостью сборки.
Вы эту проблему придумали или реально наблюдали в живой природе? 5 лет(но старой закрытой версией генератора) так делаем ни разу еще 2 разных артифакта не генерировалось. Смотрите база лежит в контейнере и резетится в первоначальное состояние при каждой сборке простым сбросом контейнера - откуда у вас проблемы не воспроизводимых сборок? Сами данные на типы никак не влияют, в лучшем случае можно надеятся на более качественный анализ по индексам, но это и так опциональная фича что у вас что у нас. Короче проблемы просто не существует.
Смотрите вы сюда пришли за фитбэком, а теперь мягко уклоняетесь от него. Так вот мой фидбэк такой - сигнатуры и сверху отдельные генераторы - отличная идея. Миграции которые ходят строем и кидают зигу при виде фюрера - оч плохая идея. Переписывать генератор с ноля при необходимости мальешей кастомизации - просто плохая идея.
Гегерация разных типов в расте для 100% одинаковых в ПГ вообще за гранью добра и зла только из за этого никто кроме вас этим пользоваться не будет.
Если не знаете куда двигать проект простой совет - интеграция в сложные реальные кодобазы должна быть максимально простая поэтому никаких предположений о том как правильно - как есть так и правильно, а философию оставьте себе. Генератор должен упрощать жизнь, а не усложнять.
За подробный фидбек я вам премного благодарен!
Представьте есть select * from tracks; и есть with… select (*)::tracks as row, 0 as result_code; Дальше по правилам именований на расте структура для строки трэка должна быть Track без s на конце + второй кверик должен вернуть что нибудь типа struct ComplexSelectItem { row: Track, result_code: i32}
Это вы отличный пример дали!
Вас устроит писать select (tracks.*)::tracks from tracks вместо select * from tracks чтобы получать переиспользуемый тип в результате запроса? Потому что в таком случае противоречие, как будто, пропадает, так как результат будет мапиться в один растовский тип, переиспользуемый во множестве запросов.
Переименование композитов можно реализовать в конфиге генератора. На первой итерации это может быть достаточно простой доработкой, изолированной только на генератор для Rust. Скорее всего, сам за реализацию этого я возьмусь не сразу, так как есть много приоритетных задач. Однако PR принимается, если кто-то захочет этим заняться.
Благодаря вашему примеру я вскрыл баг в движке анализа, из-за которого имплицитные композитные типы для строк таблиц ((tracks.*)::tracks) не вытаскивались. Исправлю это в приоритете.
Насколько я понимаю, если расширить pgn режимом работы с живой БД напрямую (без докера), то и вопросы к способу выведения схемы отпадут. Верно?
Вас устроит писать select (tracks.*)::tracks from tracks вместо select * from tracks чтобы получать переиспользуемый тип в результате запроса?
Меня лично устроит, но команду врядли.
Переименование композитов можно реализовать в конфиге генератора
Это не так просто, не все таблицы у нас заканчиваются на s потому что без с иногда лучше, не все таблицы названы в базе удачно поэтому в коде нпзвваются по другому. Не всем заходит автоматически придуманное имя структуры результата запроса. Поэтому простой конфиг для генератора не прокатит, нужны метаданные на уровне сигнатур. Вот товарищ вверху давал вам ссылку на свой модел фест подход, я пошел посмотреть, но там еще более оторванней от реальности (по сути он предлагает выкинуть N лет наработок написать модель с ноля и уже с нее генерить и базу и кверики и миграции и код; ну удачи ему в построении замков из слоновьей кости - это так никогда не работало), хотя аналог сигнатур (эксэмл схема) у него богаче и как раз позволяет эти самые мапы переименований задавать.
Скул сам по себе не позволяет дать имя эд-хок агрегату результат кверика. Если в скуле еще как то можно без этого прожить (это по сути тупл с именованными полями) то в других языках это нужно. Значит надо предоставить способ это сделать - задавать имя структуры результата. Раст не умеет в именованные туплы, по моему только сишарп на такое способен. Но туплы концептуально подошли бы так как они дак тайпятся друг на друга если совпадают по полям, тогда бы этой проблемы не стояло.
Что я хочу сказать - надо решить проблему как смапать тупл с именованными полями на структуру. Как минимум девелопер должен иметь способ задать имя этой структуры (фиг с ним что она будет сгенерированная) + указать набор трэйтов для дерайва, как максимум эта структура уже существует милион лет до начало использования генератора и надо просто смапать на нее. Вот такая задачка. Вы же генератор делаете с прицелом на разные языки, подумайте на счет возвращать туплы вместо структур возможно так будет проще, да и сам скул используют туплы.
Насколько я понимаю, если расширить pgn режимом работы с живой БД напрямую (без докера), то и вопросы к способу выведения схемы отпадут. Верно?
Верно. Только для меня слово расширить звучит странно. Генератору кроме коннекции к базе и папке с квериками вообще ничего из внешнего мира не нужно, а тут у вас терминология с ног на голову поставлена - базовая реализация это система миграций + коннекция, а расширением вы почему то называете редукцию до просто конекции.
Врядли мы будем использовать ваш генератор он для прода не готов, sqlx не подвезли и раст не в приоритете, наш прибит гвоздями к sqlx и использует не чистый скул (это самая сильная притензия) но нам ничего другого кроме sqlx и не надо, тюнится намного проще и фич имеет больше чем ваш. Зачем нам менять шило на мыло?
Спасибо. Всё понятно. Один только вопрос остался. Можете пояснить, почему предпочтение sqlx над tokio-postgres?
Я лично про токио постгрес не знаю ничего, я ж вроде обьяснил кажется - команда выбрала sqlx, это было еще до того как я пришел в компанию, кстати автомодель не моя, я главный заказчик фич реализовывал ее в основном аи под присмотром нашего архитектора - т.е. это буквально вайбкодинг.
Проект обзавёлся группой в Telegram: https://t.me/pgenie_io
>>Editing a signature file
После перегенерации сотрутся все изменения, которые добавляли ручками. Лучше добавить в конце секцию overrides с такой же структурой как параметры, в которой можно будет прописывать замены. При перегенерации эта секция тащится как есть, плюс неплохо было бы если в ней есть замена сгенерированного значения, добавлять вверху напротив коментарий как у вас в примере #overrided with true, тогда и листая сигнатуру видно оригинальное значение и отклонение и внизу краткий список замен есть.
Спасибо за совет! Но, кажется, имеет место недопонимание.
Из доков:
pGenie writes a signature file the first time a query is analysed. On subsequent runs it reads the existing file, re-resolves the signature from the database, and validates the two against each other. It never silently overwrites your signature files. To regenerate a signature file from scratch, delete it and re-run pgn generate.
Иными словами, если файл уже существует, pGenie его не перезаписывает, а использует как ориентир для сравнения. Если данные в нём не совместимы с наблюдениями по схеме, то выдаётся ошибка. Это и есть механизм защиты от дрейфа схемы. Чтобы файл перегенерировался пользователю его надо удалить.
Комментарии при замене, конечно, в нынешней реализации остаются на усмотрение пользователя, а не автоматизированы, как вы предлагаете.
У тебя в таком случае не остаётся информации, что было переделано, особенно с разными разработчиками. В бд поле нулабл, ты поставил в сигнатуре, что не нул.
Дрейфа нет, но сигнатура не совпадает, сиди потом разбирайся - это ручками после генерации сделали или раньше в бд было не нул. В идеале если переопределяешь в сгенерированном файле что-то, это должно быть явно понятно. И перегенерация не должна ни стирать это ни ломаться от этого.
Иными словами, сейчас установки в файле сигнатур смешивают две задачи: определять, меняют ли изменения в схеме что-либо в сигнатуре каждого запроса (дрейф) и сужение сигнатур под практические цели, так как данные из схемы недостаточно детальны. Вы предлагаете эти кейсы разделить.
Если я вас верно понял, то с точки зрения точности решения, вы, конечно же, правы. Но тут есть и обратная сторона: усложнение UX для типичных кейсов. Передо мной стоит задача поиска баланса в таких ситуациях. Поэтому нужно понять, насколько критично то, чего мы лишаемся, объединяя задачи.
Учитывая, что это по существу касается только переключения nullable в non-null, а также того, что в очень многих случаях Postgres всегда подразумевает null и на уровне Postgres это не изменить (параметры почти всегда nullable), я предположил, что объединить будет удобнее. Собственно эта фича переопределения для борьбы с этими недостатками и была введена. Также ещё стоит учесть, что в некоторых языках программирования (Java) по существу все значения nullable.
Резюмируя, так как в дизайне этой фичи я опираюсь на субъективное предположение, я открыт к тому, чтобы пересмотреть механику в предлагаемую вами сторону, но для этого надо накопить пользовательский фидбек.
ок, ваш компонент - ваша архитектура :)
Чисто по подходу я вас абсолютно поддерживаю, ещё в SAP 20 лет назад начал вводить практику генерации кода на базе сигнатур объектов во внешних системах. Сразу потом если что поменялось, у тебя на этапе компиляции выстреливает ошибку. Делали обёртки над таблицами и i18n файлами. Для переводов с параметрами генерили методы типа sayHello(userName), для простых текстов константы. В классах обёртках над БД генерили секцию //override - //override end, в которой можно было своё писать и при перегенерации оно переносилось как есть. Плюс если перенёс в эту секцию метод и переписал его, то при перегенерации этот метод не генерился. Можно было в set/get методы добавлять валидацию, например. Экстендить каждый сгенерённый класс неудобно для переопределения, а так получалось по-красоте.
Спасибо за обратную связь и раскрытие темы!
Кстати, в случае с pGenie подразумевается, что пользователь вообще не будет менять сгенерированный код. Только использовать, как библиотеку от третьей стороны, которую невозможно изменить. На моём опыте работы с генерацией кода, это самый простой и надёжный способ отгородиться от проблем с затиранием изменённого пользователем кода при перегенерации.
Уточните пожалуйста, migrations это таки code-first или я недопонял? А если поправили базу через DDL? Или вообще ее схема/миграции вне нашего доступа?
Если миграции написаны на DDL, то это чистый SQL-first и это именно то, что я пропагандирую в данной статье и то, на что опирается pGenie.
Если не пользоваться миграциями, и ходить менять схему БД руками, то теряется фиксация структуры БД и источником истины о её структуре становится её актуальное состояние, что переменно и устанавливает инфраструктурную зависимость корректности вашей интеграции с данной БД на её состояние. Следствием с высокой вероятностью станет хрупкость интеграции и частые сбои независимо от того, на какой инструментарий вы будете опираться.
Понятно. Однако связь проект-хранилище один-к-одному хоть и распространена, но далеко не единственная. Актуальна периодическая проверка корректности интеграции, по событию или расписанию. Либо, как уже упоминали, отдельный абстрагированный от языка слой в роли истины. Либо, все (?) базы внутри себя уже хранят этот, примененный к ним истинный DDL. Чем не источник?
Это не в критику вашего проекта, а лишь определение областей применения.
Спасибо за поднятую тему!
Вообще, на будущее была задумка реализовать проверки health-check. Startup probe проверял бы состояние на старте приложения и рапортовал бы тому же Kubernetes, что на приложение нельзя пускать трафик, когда его ожидания от схемы БД расходятся с реальностью. Ну и Liveness probe проверял бы то, что вы описали: продолжает ли схема в БД быть совместимой с точки зрения данного приложения.
Такого рода код точно можно генерировать. Проблема лишь в том, что для вытаскивания необходимых для такой генерации данных, потребуются большие доработки в движок анализа. Поэтому это определённо была бы мощная фича, дающая конкурентное преимущество, но пока это отложено на будущее, так как есть ряд более приоритетных банальных задач из разряда покрытия большего количества языков и подобное.
Вы кажется сейчас совместили приложение у пользователя и приложение у разработчика.
Что произойдет у пользователя, в случае расхождения со схемой, причем расхождения такого, которое можно поймать типобезопасностью/программно? Приложение упадет. Так или иначе. При старте или в процессе, выбор неоднозначный. Возможно текущая сессия не затронет измененный участок развесистой схемы. Тогда пусть работает?
А у разработчика... это не столько фича, сколько, кмк, смещение источника истины. Много-к-одному должно решаться иначе, концептуально, либо начнутся костыли натягивания одного на другое.
И еще, считаю один-к частным случаем много-к. Если научиться хорошо и удобно работать с "чужой" схемой, расширить дополнительными плюшками при "владении" своей намного проще.
Что произойдет у пользователя, в случае расхождения со схемой, причем расхождения такого, которое можно поймать типобезопасностью/программно? Приложение упадет. Так или иначе. При старте или в процессе, выбор неоднозначный. Возможно текущая сессия не затронет измененный участок развесистой схемы. Тогда пусть работает?
Часто бывает, что сбоящий запрос исполняется в состояниях приложения, в которые оно заходит редко. Узнать об этом сбое, определённо, лучше превентивно, нежели, дождавшись, когда до этого состояния доберётся пользователь, ведь любой сбой несёт за собой дополнительные издержки.
Какой стратегии придерживаться, конечно, выбор тех, кто за приложение отвечает и в каких-то ситуациях, действительно, может быть важнее, чтобы приложение продолжало работать даже когда часть запросов стала несовместимой. Так или иначе, об этом, как минимум, стоит рапортнуть через алёрт, чтобы возникла возможность начать принимать меры.
А у разработчика… это не столько фича, сколько, кмк, смещение источника истины. Много-к-одному должно решаться иначе, концептуально, либо начнутся костыли натягивания одного на другое.
И еще, считаю один-к частным случаем много-к. Если научиться хорошо и удобно работать с “чужой” схемой, расширить дополнительными плюшками при “владении” своей намного проще.
Тут я не понял, о чём вы.
У вас по сути гибридный подход. Миграции code-first, ведь sql это тоже код; запросы проверяются strorage-first. В случае "чужой" базы, при много приложений к одной базе, её структура вам доступна в режиме read only. Научимся удобно работать при таких ограничениях, будет удобно всегда. Но если концепция хоть частично содержит code-first, удобно не будет.
К сожалению, терминология *-first уже обросла маркетингом и стала контекстно-зависимой. Код действительно везде: и в приложении, и в БД. Подсвечиваемая мной дихотомия по существу сводится к тому, какая из этих сторон является источником истины (БД или приложение). Человек программирует там, где источник, а всё вторичное выводится автоматом. В случае с ORM и Query Builder источник либо не определён (ад в поддержке), либо это приложение (получше). Я же аргументирую за подход, когда источником истины является БД.
Наверное, корректнее было бы назвать дихотомию App-first / DB-first, но, к сожалению, в индустрии устоялось Code-first / SQL-first, а подразумевается одно и то же.
устоялось Code-first / SQL-first, а подразумевается одно и то же.
SQL-first, если честно, вообще не на слуху. Что подразумевается: написание миграций на sql или что все запросы пишутся на sql?
Ну да. Источник истины для схемы код миграций. Источник истины для запросов схема данных.
В целом все хорошо. Но код миграций надо вывести из-под приложения. Явно!!! Отдельная версионируемая сущность. Ничего не знающая про приложения. Но приложения могут знать про миграции. Мейнтейнером при этом может быть как "владелец" приложения, так и любой другой субъект. На суть это не влияет, только на организацию процесса.
В этом что-то есть. Спасибо за приведение моих размышлений в порядок.
Очень рад!
Но код миграций надо вывести из-под приложения.
Это я и предлагаю :)
Взглянуть на БД как на самостоятельный микросервис со своим репозиторием. В этом репозитории миграции и запросы и никакого кода приложений. Свой процесс ревью. Своё решение для тестирования (это возможно в SQL), инструменты мигрирования это поддерживают (как минимум, я это делал в Sqitch). Свои линтеры по SQL (они существуют). Свой CI, который прогоняет анализ с pGenie и упомянутые тесты и линтеры. Свой CD, который накатывает миграции в проде и выкладывает во всякие artifactory сгенерированные библиотеки под клиентские языки.
Помимо прочего это даёт манёвр в структурировании организации. За этот репозиторий могут отвечать люди, разбирающиеся в Postgres, тонкостях работы индексов и антипаттернах SQL. И им не нужно ничего знать про целевые языки. Точно так же это снимает необходимость с ваших джавистов, растовщиков и тд знать чего-либо про SQL.
А ещё это снимает основной аргумент “Shared DB is antipattern” и позволяет вам снова использовать общую БД из нескольких приложений и с разных стеков. Только безопасно.
Если интересно, я 4 года назад, когда шёл по стратегии SaaS, писал про многое из этого подробно: https://habr.com/ru/articles/808279/. Всё написанное актуально для pGenie.
Вы не это предлагаете. Возможно я сам некорректно выразился. Явно вывести, это разделение на "в этом миграции", а вот "в этом запросы". Снижение связности, модульность, гибкость.
Можно резюмировать ваш подход: источник истины — схема описанная миграциями. Именно так, неразрывно. В этом его сила и... слабость. Например:
снимает необходимость с ваших джавистов, растовщиков и тд знать чего-либо про SQL
А если сам с усам? Не хочу каждый запрос отдавать в репозиторий администратора. Останется вне контроля? А если захочется? Клонировать репозиторий миграций, при наличии живой базы?
Буду думать надо разделением контроля. Получить поток данных без DBError и принять поток данных без TypeError. И всё на уровне не запроса, а запросОВ. Извлекаются нужные метаданные, устраняется дублирование, проверяются атомарные токены, при необходимости имеем аналог sourceMap для возврата от токенов к исходному коду запросов.
Вспомнил свой давний эксперимент времен Дельфи. В самой базе хранил repr_rus и description полей. И что-то при новом взгляде, в этом есть.
Мы сейчас говорим фактически о метаданных. Данных (DML) описания данных (schema). Которые могут "расползтись". Но база данных, в том числе инструмент обеспечения ссылочной целостности данных. В том числе ссылочной целостности с схемой, хранящейся в служебных таблицах.
Так какая часть у запросов может разойтись со схемой? И почему бы ее не хранить в "уголке" этой схемы? Источник истины не [только] база, но и данные под ее ссылочным контролем.
Я правильно понимаю, что вы хотите получить проверку того, что запрос не нарушает целостности данных посредством сопровождения DML или запросов дополнительной спецификацией. Как мне кажется, в этом есть потенциал, но для дальнейших шагов нужно спускаться на предметную область: нужны примеры проблемы и предлагаемого решения.
Огромное спасибо автору и комментаторам за столь содержательный пласт информации. Я думал какая-то сложная тема, а оказалось то, для чего я пытался пилить инструменты и создавать собственную систему контрактов. Там дело дошло аж до теории собственного блокчейна. Сейчас потихоньку появляются такие проектики в крипто сфере, которые пытаются решить все проблемы баз данных и функций их реализующих. Тема реально сложная со стороны вопроса объять все и сразу. К сожалению термины абстракций сильно вредят и засоряют смыслы этих тем. Возможно у многих уже проф деформация, и они по-другому не могут выражаться. Есть еще момент с упрощением баз данных и методов их наилучшего построения, и нужны ли нам другие языки кроме самого sql, нужно ли создавать эвристический движок для подбора стратегически верного решения под задачу разработчика, или же нейронок достаточно? Вот же на самом деле большая задача помещения всего этого практического опыта с накоплением и сбором данных, в один идеальный продукт, который будет учить пользователя как надо строить эту самую БД. Либо будут БД под каждую задачу, либо будет сборка из атомарных кусочков по типу частей ДНК. Но все таки БД под каждую задачу более красивое и желаемое решение. Надеюсь, что всплеск моих мыслей никого не задел, и был полезен.

Мой 14-летний путь отказа от ORM: череда инсайтов, приведшая к созданию SQL-First кодогенератора для PostgreSQL