Egobrain

Erlang ORM. часть 3

Прошло уже очень много времени с момента как я писал про свои эксперименты с 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
Schema = #{
    fields => #{
       field_1 => Fields1Opts,
       field_2 => Fields2Opts,
    },
    table => <<"SomeTableName">>
}

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
Query1 = from(Schema),
Query2 = filter(Query1),
...
Result = select(QueryN)

Скорее всего делать внутреннее представление SQL придется через какой-то промежуточный AST, но, у меня есть одно важное требование – легкое добавление SQL конструкций, которых еще нет в моей библиотеке. Делать полноценный AST который потом бы компилировался в SQL или во что-то еще я не хочу. У меня есть postgresql и это все что мне нужно на данный момент. При такой постановке вопроса проектировать решение становится заметно проще :) Я взял на вооружение подход из моей предыдущей ORM – добавить дополнительную разметку прямо в SQL

Возьмем пример:

1
select sum(o.sum), u.name from users as u join orders o on o.user_id = u.id where u.age > 18 group by u.name

Что понадобится?
Ключевые слова, операции, пробелы, запятые и прочее что должно быть встроено “как есть” назовем 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
{exp, [
    {raw, "select "},
    {exp, [
        {raw, "sum("},
            {exp, [{table, Ref2},{raw, "."},{raw, "sum"}]},
        {raw, ")"}
    ]}
    {raw, ","},
    {exp, [{table, Ref1},{raw, "."},{raw, "name"}]},
    {raw, " from users as "}, {table, Ref1},
    {raw, " join orders as"}, {table, Ref2},
    {raw, " on "},
    {exp, [
        {exp, [{table, Ref2},{raw, "."},{raw, "user_id"}]},
        {raw, " = "},
        {exp, [{table, Ref1},{raw, "."},{raw, "id"}]},
    ]},
    {raw, " where "},
    {exp, [
        {exp, [{table, Ref1},{raw, "."},{raw, "age"}]},
        {raw, " = "},
        18
    ]},
    {raw, " group by"},
    {exp, [{table, Ref1},{raw, "."},{raw, "name"}]}
]}

Как преобразовать такое представление в SQL + Args, думаю вполне очевидно. А вот как строить его с помощью erlang?

Для начала нам понадобятся схемы для таблиц:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
UserSchema = #{
    fields => #{
        id => #{type => int, index => true, required => true, readOnly => true},
        name => #{type => {varchar, 255}, required => true},
        age => #{type => int}
    },
    table => <<"users">>
}.

OrderSchema = #{
    fields => #{
        id => #{type => int, index => true, required => true, readOnly => true},
        user_id => #{type => int, required => true},
        sum => #{type => number, required => true}
    },
    table => <<"orders">>
}

Начнем с того, что должна делать функция from(Schema)

1
Query1 = from(UserSchema).

В AST видны повторяющиеся конструкции для ссылок на поля {exp, [{table, Ref1},{raw, "."},{raw, "name"}]}. Следовательно, нужно сгенерировать Ref1 и для каждого поля из схемы построить такое AST выражение. Получится map, который я называю TableData:

1
2
3
4
5
6
Ref1 = make_ref(),
#{
    id   => {exp, [{table, Ref1},{raw, "."},{raw, "id"}]},
    name => {exp, [{table, Ref1},{raw, "."},{raw, "name"}]},
    age  => {exp, [{table, Ref1},{raw, "."},{raw, "age"}]},
}

теперь про функции. Для реализации текущего запроса нам нужны функции sum и =. Выглядят они очень просто:

1
2
3
4
5
sum(Field) ->
    {exp, [{raw, "sum("}, F, {raw, ")"}]}.

'=:='(A, B) ->
    {exp, [A, {raw, " = "}, B]}.

Как строить where? Я сделал функцию where(Fun, Query) принимающую Fun, в которую передается несколько TableData уже участвующих в запросе, а на выходе AST для where выражения. Результирующий AST добавляется через and к тому, который уже хранится в Query.

1
Query2 = where(fun([#{age := Age}]) -> '=:='(Age, 18) end, Query1).

С join поступим похожим образом,

1
Query3 = join(OrderSchema, fun([#{id := Id}, #{user_id := UserId}]) -> '=:='(Id, UserId) end, Query2).

для OrderSchema создается OrderTableData и вместе с UserTableData, которая уже есть в запросе передается в callback.

Что касается group by то функция тоже очень похожа на предыдущие только возвращает не AST, а массив в котором описано по каким полям группировать

1
Query4 = group_by(fun([#{name := Name}, _OrdersTableData]) -> [Name] end, Query3).

Осталось сделать select. Функция не будет исключением и работает по сходным правила

1
Query5 = select(fun([#{name := Name}, #{sum := OrderSum}]) -> #{name => Name, sum => sum(OrderSum)} end, Query4)

select callback возвращает описание результата – это может быть или map или одно поле (удобно для count).

Query готов. Осталось только на основе него сгенерировать итоговый Select AST и преобразовать его в SQL. Этим займемся чуть позже. А пока приведу код запроса полностью и попробуем сделать его чуть-чуть красивее.

1
2
3
4
5
Query1 = from(UserSchema),
Query2 = where(fun([#{age := Age}]) -> '=:='(Age, 18) end, Query1),
Query3 = join(OrderSchema, fun([#{id := Id}, #{user_id := UserId}]) -> '=:='(Id, UserId) end, Query2),
Query4 = group_by(fun([#{name := Name}, _OrdersTableData]) -> [Name] end, Query3),
Query5 = select(fun([#{name := Name}, #{sum := OrderSum}]) -> #{name => Name, sum => sum(OrderSum)} end, Query4)

Во-первых, давайте перенесем все функции, модифицирующие запрос в отдельный модуль. Для лаконичности q.erl Функции, которые генерирую postgresql AST для операций в другой модуль – pg_sql.erl

1
2
3
4
5
Query1 = q:from(UserSchema),
Query2 = q:where(fun([#{age := Age}]) -> pg_sql:'=:='(Age, 18) end, Query1),
Query3 = q:join(OrderSchema, fun([#{id := Id}, #{user_id := UserId}]) -> pg_sql:'=:='(Id, UserId) end, Query2),
Query4 = q:group_by(fun([#{name := Name}, _OrdersTableData]) -> [Name] end, Query3),
Query5 = q:select(fun([#{name := Name}, #{sum := OrderSum}]) -> #{name => Name, sum => pg_sql:sum(OrderSum)} end, Query4)

Во-вторых, передавать схему как map не совсем практично. В дальнейшем нам понадобится больше функционала для моделей. Поэтому перенесем каждую схему в свой модуль. Я использую префикс m_ и функцию schema/0.

m_user.erl
1
2
3
4
5
6
7
8
9
10
11
12
13
-module(m_user).
-export([schema/0]).

schema() ->
    #{
        fields => #{
            id => #{type => int, index => true, required => true, readOnly => true},
            name => #{type => {varchar, 255}, required => true},
            age => #{type => int}
        },
        table => <<"users">>
    }.

m_order.erl
1
2
3
4
5
6
7
8
9
10
11
12
-module(m_order).
-export([schema/0]).

schema() ->
    #{
        fields => #{
            id => #{type => int, index => true, required => true, readOnly => true},
            user_id => #{type => int, required => true},
            sum => #{type => number, required => true}
        },
        table => <<"orders">>
    }.

попутно обучим функции, которые принимали схему принимать модуль и дергать Module:schema() при этом.

Теперь возьмемся за то, что ломает глаза и бесит многих, кто приходит в erlang из других языков: повторы QueryN. Ну как решать эту проблему мы-то знаем ;) В elixir для этого существует pipe оператор |>, в erlang его нет, но можно сделать по-другому:

1
2
pipe(State, Funs) ->
    lists:foldl(fun(F, St) -> F(St) end, State, Funs).

а для каждой функции из q заведем каррированый аналог.

f(Fun, Query). => f(Fun) -> fun(Query) -> f(Fun, Query) end.

получилось:

1
2
3
4
5
6
q:pipe(q:from(m_user), [
    q:where(fun([#{age := Age}]) -> pg_sql:'=:='(Age, 18) end),
    q:join(m_order, fun([#{id := Id}, #{user_id := UserId}]) -> pg_sql:'=:='(Id, UserId) end),
    q:group_by(fun([#{name := Name}, _OrdersTableData]) -> [Name] end),
    q:select(fun([#{name := Name}, #{sum := OrderSum}]) -> #{name => Name, sum => pg_sql:sum(OrderSum)} end)
]).

теперь можно легко переставлять выражения (но порядок все-таки важен)

1
2
3
4
5
6
q:pipe(q:from(m_user), [
    q:join(m_order, fun([#{id := Id}, #{user_id := UserId}]) -> pg_sql:'=:='(Id, UserId) end),
    q:select(fun([#{name := Name}, #{sum := OrderSum}]) -> #{name => Name, sum => pg_sql:sum(OrderSum)} end),
    q:group_by(fun([#{name := Name}, _OrdersTableData]) -> [Name] end),
    q:where(fun([#{age := Age}]) -> pg_sql:'=:='(Age, 18) end)
]).

Получился вполне удобный язык запросов, который удовлетворяет всем требованиям которые я описал выше. Все это и немного больше находится в библиотеке equery и пока ее возможностей хватает чтобы покрыть 95% того что мне сейчас нужно (5% – это union, которых пока нет и выборка из нескольких таблиц select * from table1, table2, ... , но поддержка появится в скором будущем)

Маленький бонус. Для тех, кому как и мне, не совсем приятно и удобно читать выражения вида

1
2
3
4
pg_sql:'andalso'(
    pg_sql:'>=(Age, 18),
    pg_sql:'=<'(Age, 25)
)

я реализовал parse_transform, который в q callback-ах позволяет писать код в erlang стиле

1
Age >= 18 andalso Age =< 25

работает даже в repl !!! :)

с ним наш запрос будет выглядеть так:

1
2
3
4
5
6
7
8
-include_lib("equery/include/equery.hrl").

Query = q:pipe(q:from(m_user), [
    q:where(fun([#{age := Age}]) -> Age =:= 18 end),                                                %% Изменения тут
    q:join(m_order, fun([#{id := Id}, #{user_id := UserId}]) -> Id =:= UserId end),      %%  и тут
    q:group_by(fun([#{name := Name}, _OrdersTableData]) -> [Name] end),
    q:select(fun([#{name := Name}, #{sum := OrderSum}]) -> #{name => Name, sum => pg_sql:sum(OrderSum)} end)
]).

Чтобы получить AST для select запроса нужно вызвать qsql:select/1. А чтобы получить SQL qast:to_sql/1.

1
2
3
qast:to_sql(qsql:select(Query)).

{<<"select \"__table-0\".\"name\",sum(\"__table-1\".\"sum\") from \"users\" as \"__table-0\" inner join \"orders\" as \"__table-1\" on (\"__table-0\".\"id\" = \"__table-1\".\"user_id\") where (\"__table-0\".\"age\" = $1) group by \"__table-0\".\"name\"">>,  [18]}

REPO

Генерация запросов это хорошо, но хочется еще добавлять, изменять, удалять сущности из БД. Хочется исполнять запросы через pool, иметь хуки на сохранение и т.п. Для этого всего я реализовал библиотеку repo. Она построена поверх equery and epgpool

Select

С select запросами все совсем просто: пишешь запрос и он исполняется.

1
2
3
4
5
6
repo:all(m_user, [
    q:where(fun([#{age := Age}]) -> Age =:= 18 end),
    q:join(m_order, fun([#{id := Id}, #{user_id := UserId}]) -> Id =:= UserId end),
    q:group_by(fun([#{name := Name}, _OrdersTableData]) -> [Name] end),
    q:select(fun([#{name := Name}, #{sum := OrderSum}]) -> #{name => Name, sum => pg_sql:sum(OrderSum)} end)
]).

Довольно часто нужно выбрать сущности по какому-то полю или набору полей, например по id. Изначально для этого нужно писать where запрос

1
2
3
repo:all(m_user, [
    q:where(fun([#{id := Id}]) -> Id =:= 123 end)
]).

но, благодаря тому, что запросы легко строить динамически можно добавить немного сахара:

1
2
3
4
5
6
7
8
9
10
11
12
like(Map) ->
    q:where(fun([M|_]) ->
        maps:fold(
            fun(K, V, S) ->
                case maps:find(K, M) of
                    {ok, V2} -> S andalso V =:= V2;
                    error -> S
                end
            end, true, Map)
    end).

repo:all(m_user, [ like(#{id => 123} ]).

я пошел еще дальше и repo api принимает map на вход.

1
repo:all(m_user, #{id => 123}). %% просто, понятно, лаконично

для поиска единственного элемента есть функция get_one

1
{ok, User} = repo:get_one(m_user, #{id => 123}).

Если результат запроса слишком большой, но при этом его можно обрабатывать потоково (например вывод отчета с большим количеством полей) то желательно использовать потоковое api через zlist, что сильно уменьшает latency и потребление памяти, т.к. данные из БД подгружаются пачками по мере необходимости

1
2
3
repo:all(m_user, [q:where(fun([#{age := Age}]) -> Age > 18 end)], fun(ZList) ->
    zlist:foreach(fun(#{name := Name, age := Age}) -> io:format("User: ~p, ~p\n", [Name, Age]) end, Zlist)
end).

Чуть не забыл. С помощью repo_utils:preload/1 можно подгружать данные из зависимых таблиц (has_many, belongs_to). Например:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-module(m_user).
-export([schema/0]).

schema() ->
    #{
        fields => #{
            id => #{type => int, index => true, required => true, readOnly => true},
            name => #{type => {varchar, 255}, required => true},
            age => #{type => int}
        },
        links => #{
            orders => {has_many, m_order, #{id => user_id}}
        },
        table => <<"users">>
    }.

запрос

1
2
3
repo:all(m_user, [
    repo_utils:preload(orders)
]).

вернет подобную структуру

1
2
3
4
5
6
7
8
9
10
11
[#{
    id => UserId,
    name => UserName,
    age => UserAge,
    orders => [
         #{id => OrderId1,  user_id => UserId, sum => Sum1},
         #{id => OrderId2,  user_id => UserId, sum => Sum2},
         ...
 },
 ... Other users ...
].

и все это одним SQL запросом.

Insert/Update/Upsert

С insert/update все очень просто. API принимает один или несколько объектов и по-умолчанию возвращает их же (через sql returnging)

1
repo:insert(m_user, [#{name => <<"Alladin">>, age => 25}, ...]).
1
repo:update(m_user, [#{id = 238, name => <<"Masha">>, age => 18}, ...]).
1
repo:upsert(m_user, [#{id = 239, name => <<"Igor">>, age => 18}, ...]).

Хочется добавить, что для каждой модели можно, при необходимости, объявить before_save/2 и after_save/2 hook-и и, если хочется хранить сущности не в мапах, а, скажем, в record-ах, для этого есть from_db/1 и to_db/1 (для примера смотри common тесты)

Batch update

Можно обновлять сущности пачкой, а не по одному:

1
2
3
repo:set(m_user, [
   q:set(fun([#{age := Age}]) -> #{age => Age * 2 } end)
]).

Batch delete

Удаляются данные только пачкой.

1
2
3
repo:delete(m_user, [
   q:where(fun([#{age := Age}]) -> Age > 99 end)
]).

Резюме

На данный момент я не считаю что реализация 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 :)

Комментарии