Прошло уже очень много времени с момента как я писал про свои эксперименты с Erlang ORM.
И что я могу сказать?
Первое и самое, вероятно, печальное, подход оказался не жизнеспособным, Второе – несмотря на недостатки ORM ее использование помогло набраться опыта работы с postgresql из Erlang и получить представление о том как все действительно должно работать.
Где я допустил ошибки:
– Нельзя мешать в одной модели описание того как данные хранятся в базе и как будут отдаваться клиенту
– Не стоило мешать генерацию sql и непосредственную работу с БД в одном проекте
– Подход ActiveRecord – не самая лучшая идея для Erlang
– Слишком много parse_transform-a
За эти 2 года я очень часто сталкивался с генерацией sql в чужих проектах на Erlang и, к сожалению, единого, общепризнанного, да что там, хотя бы просто удобного решения нигде не встречал. Везде лишь ад из case блоков, сверток и iolist-ов. Что еще печальнее, коллеги все чаще стали поглядывать в сторону Elixir с его ecto. А уж совсем не хочется изучать, а тем более использовать в продакшене еще один язык.
Так на свет появился стек для работы с postgresql и моделями данных:
– epgpool – простой пулл подключений к postgres. Очередной велосипед, если есть предложения чего-то получше – могу рассмотреть
– dbschema – автоматические миграции наше все. Библиотек позволяет исполнять sql и erl up/down инструкции. Что убирает кучу работы по ручной раскладке и позволяет автоматизировать тестирование
– emodel – библиотека для валидации входных данных. Та самая прослойка, которая должна отделять чистые, проверенные данные от мусора, который к нам прилетает. Отличается тем, что возвращает сразу все ошибки до которых может дотянуться.
– equery – генерация sql, вдохновленная подходом Ecto
– repo – одна из возможных реализаций CRUD библиотеки поверх equery и epgpool.
Сегодня я расскажу про repo и equery.
Equery
Какие проблемы с ручным написанием sql?
1. Как выразился один мой бывший коллега, основная проблема – смапить результат исполнения sql на внутреннюю структуру данных
2. Сложность композиции. (К примеру, как добавить еще один фильтр к уже имеющимся)
3. Необходимость модифицировать разрозненные запросы в случае добавления, удаления полей в таблице.
Как будем решать?
Итак, понадобится сущность, которая будет описывать структуру таблицы SQL (Благо в Erlang r17 появились мапы и с ними жизнь стала намного легче). Решение в лоб
1 2 3 4 5 6 7 |
|
FieldOpts – Набор дополнительной информации о колонках. Например:
– type – тип в бд ({varchar, 255}, decimal, int, text, bigint, …)
– required – аналог NOT NULL (boolean)
– readOnly – удобная опция, в случае если поле нельзя обновлять, например id, который генерируется через serial или timestamp через now()
– index – поле является индексом, или частью составного индекса (boolean)
– … – что угодно. Т.к. Opts – это map(), то можно добавлять свои опции по желанию.
К этому моменту у меня примерно прояснилась картина, как я хочу видеть код.
1 2 3 4 |
|
Скорее всего делать внутреннее представление SQL придется через какой-то промежуточный AST, но, у меня есть одно важное требование – легкое добавление SQL конструкций, которых еще нет в моей библиотеке. Делать полноценный AST который потом бы компилировался в SQL или во что-то еще я не хочу. У меня есть postgresql и это все что мне нужно на данный момент. При такой постановке вопроса проектировать решение становится заметно проще :) Я взял на вооружение подход из моей предыдущей ORM – добавить дополнительную разметку прямо в SQL
Возьмем пример:
1
|
|
Что понадобится?
Ключевые слова, операции, пробелы, запятые и прочее что должно быть встроено “как есть” назовем raw (в equery – {'$raw', iolist()}
).
Названия таблиц повторяются часто и не хотелось бы чтобы при объединении запросов были проблемы с одинаковыми псевдонимами поэтому есть 2 варианта:
1. генерировать уникальные псевдонимы по ходу построения запроса
2. генерировать их в самом конце – при генерации sql, а в ast использовать уникальную “ссылку”
Т.к. posgresql кэширует план запросов – то нужно чтобы один и тот же код каждый раз давал один и тот же результат.
Следовательно решение 1 отпадает и вводим дополнительную конструкцию {table, UniqueRef} там где нужно ссылаться на поля таблиц. (в equery – {'$table', ref()}
)
Чтобы как-то группировать несколько синтаксических конструкций в одну добавим выражение {exp, [Nodes]} (в equery – {'$exp', ref()}
)
Все остальное считаем данными и при генерации SQL будем их собирать в отдельном списке, а в SQL на этом месте проставлять ссылки на аргументы $1, $2, ...
В результате sql из примера превращается в AST:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
Как преобразовать такое представление в SQL + Args, думаю вполне очевидно. А вот как строить его с помощью erlang?
Для начала нам понадобятся схемы для таблиц:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Начнем с того, что должна делать функция from(Schema)
1
|
|
В AST видны повторяющиеся конструкции для ссылок на поля {exp, [{table, Ref1},{raw, "."},{raw, "name"}]}
. Следовательно, нужно сгенерировать Ref1 и для каждого поля из схемы построить такое AST выражение. Получится map, который я называю TableData:
1 2 3 4 5 6 |
|
теперь про функции. Для реализации текущего запроса нам нужны функции sum
и =
. Выглядят они очень просто:
1 2 3 4 5 |
|
Как строить where? Я сделал функцию where(Fun, Query)
принимающую Fun, в которую передается несколько TableData уже участвующих в запросе, а на выходе AST для where выражения. Результирующий AST добавляется через and к тому, который уже хранится в Query.
1
|
|
С join поступим похожим образом,
1
|
|
для OrderSchema создается OrderTableData и вместе с UserTableData, которая уже есть в запросе передается в callback.
Что касается group by то функция тоже очень похожа на предыдущие только возвращает не AST, а массив в котором описано по каким полям группировать
1
|
|
Осталось сделать select. Функция не будет исключением и работает по сходным правила
1
|
|
select callback возвращает описание результата – это может быть или map или одно поле (удобно для count).
Query готов. Осталось только на основе него сгенерировать итоговый Select AST и преобразовать его в SQL. Этим займемся чуть позже. А пока приведу код запроса полностью и попробуем сделать его чуть-чуть красивее.
1 2 3 4 5 |
|
Во-первых, давайте перенесем все функции, модифицирующие запрос в отдельный модуль. Для лаконичности q.erl Функции, которые генерирую postgresql AST для операций в другой модуль – pg_sql.erl
1 2 3 4 5 |
|
Во-вторых, передавать схему как map не совсем практично. В дальнейшем нам понадобится больше функционала для моделей. Поэтому перенесем каждую схему в свой модуль. Я использую префикс m_ и функцию schema/0
.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
попутно обучим функции, которые принимали схему принимать модуль и дергать Module:schema() при этом.
Теперь возьмемся за то, что ломает глаза и бесит многих, кто приходит в erlang из других языков: повторы QueryN. Ну как решать эту проблему мы-то знаем ;) В elixir для этого существует pipe оператор |>
, в erlang его нет, но можно сделать по-другому:
1 2 |
|
а для каждой функции из q заведем каррированый аналог.
f(Fun, Query).
=> f(Fun) -> fun(Query) -> f(Fun, Query) end.
получилось:
1 2 3 4 5 6 |
|
теперь можно легко переставлять выражения (но порядок все-таки важен)
1 2 3 4 5 6 |
|
Получился вполне удобный язык запросов, который удовлетворяет всем требованиям которые я описал выше. Все это и немного больше находится в библиотеке equery и пока ее возможностей хватает чтобы покрыть 95% того что мне сейчас нужно (5% – это union, которых пока нет и выборка из нескольких таблиц select * from table1, table2, ...
, но поддержка появится в скором будущем)
Маленький бонус. Для тех, кому как и мне, не совсем приятно и удобно читать выражения вида
1 2 3 4 |
|
я реализовал parse_transform, который в q callback-ах позволяет писать код в erlang стиле
1
|
|
работает даже в repl !!! :)
с ним наш запрос будет выглядеть так:
1 2 3 4 5 6 7 8 |
|
Чтобы получить AST для select запроса нужно вызвать qsql:select/1. А чтобы получить SQL qast:to_sql/1.
1 2 3 |
|
REPO
Генерация запросов это хорошо, но хочется еще добавлять, изменять, удалять сущности из БД. Хочется исполнять запросы через pool, иметь хуки на сохранение и т.п. Для этого всего я реализовал библиотеку repo. Она построена поверх equery and epgpool
Select
С select запросами все совсем просто: пишешь запрос и он исполняется.
1 2 3 4 5 6 |
|
Довольно часто нужно выбрать сущности по какому-то полю или набору полей, например по id. Изначально для этого нужно писать where запрос
1 2 3 |
|
но, благодаря тому, что запросы легко строить динамически можно добавить немного сахара:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
я пошел еще дальше и repo api принимает map на вход.
1
|
|
для поиска единственного элемента есть функция get_one
1
|
|
Если результат запроса слишком большой, но при этом его можно обрабатывать потоково (например вывод отчета с большим количеством полей) то желательно использовать потоковое api через zlist, что сильно уменьшает latency и потребление памяти, т.к. данные из БД подгружаются пачками по мере необходимости
1 2 3 |
|
Чуть не забыл. С помощью repo_utils:preload/1 можно подгружать данные из зависимых таблиц (has_many, belongs_to). Например:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
запрос
1 2 3 |
|
вернет подобную структуру
1 2 3 4 5 6 7 8 9 10 11 |
|
и все это одним SQL запросом.
Insert/Update/Upsert
С insert/update все очень просто. API принимает один или несколько объектов и по-умолчанию возвращает их же (через sql returnging)
1
|
|
1
|
|
1
|
|
Хочется добавить, что для каждой модели можно, при необходимости, объявить before_save/2
и after_save/2
hook-и и, если хочется хранить сущности не в мапах, а, скажем, в record-ах, для этого есть from_db/1
и to_db/1
(для примера смотри common тесты)
Batch update
Можно обновлять сущности пачкой, а не по одному:
1 2 3 |
|
Batch delete
Удаляются данные только пачкой.
1 2 3 |
|
Резюме
На данный момент я не считаю что реализация repo идеальна и эталонна, но она решает большую часть повседневных задач стоящих в моих проектах и уже успешно используется в production. Нет ничего плохого в том, чтобы форкуть его и заменить, докрутить его части так как это необходимо именно вам, т.к. проект очень маленький и при этом ~100% покрыт тестами.
При этом проект позволил полностью избавиться от генерации SQL вручную (кроме миграций) и заметно сократить общий объем кодовой базы. Стоит, однако, понимать, что библиотека equery не контролирует ошибки генерации sql, зато дает больший контроль во взаимодействии с Postgresql т.к. вы можете самостоятельно реализовать почти любую синтаксическую конструкцию. Необходимо понимать что и как работает и где копать в случае проблем. Именно поэтому я привел достаточно подробное описание выше. Хорошо это или нет – решать вам.
Дальнейшие планы
Предстоит еще не мало работы:
– добавить unioun, multiple from в equery
– покрыть все спеками и dialyzer тестами
– написать документацию
– добавить больше common операций в equery pg_sql.erl
– реализовать dbschema-repo addon чтобы автоматически генерировать миграции для изменения схем
Основным недостатком (вероятно для некоторых) что проект ориентирован только на Posgresql и при том 9.5 версии (из-за upsert)
Буду рад комментариям и PR :)