Egobrain

Erlang ORM. Часть 1.

Всем известно, что в Erlang нет привычных объектов из “классического” ООП, но они, по сути, и не нужны. Под словом объект, в данном случае, будет пониматься связка структура (record) и модуль, в котором описаны все функции для работы с этой структурой.

Приведу пример, который буду рассматривать на протяжении всех своих рассуждений:

Классический объект – User с полями login, email, password и salt (куда без нее). Его можно описать так (приставка db_ – необходима, т.к. в erlang уже есть модуль с таким именем).

db_user.erl
1
2
3
4
5
6
7
8
9
10
11
-module(db_user).

-record(db_user, {
    login,
    email,
    password,
    salt
}).

new() ->
    #db_user{}.

Я хочу описать два своих проекта tq_transform и tq_db. Которые суммарно генерируют объект позволяющий:

  1. Прятать реализацию, т.е. объекты за пределами модуля db_user не должны работать с record-ом напрямую. Это позволит в случае необходимости с легкостью поменять record, к примеру, на map.
  2. Задавать значения по умолчанию.
  3. Работать с “внешним миром”. Существует возможность указать какие поля и доступны для отправки внешнему пользователю ( к примеру через REST api ) и в каком виде, какие нет, какие только для чтения, какие только для записи.
  4. Конвертировать содержимое полей в/из proplist.
  5. Выводить список измененных полей.
  6. Задавать валидаторы как отдельных полей, так и всей модели в целом.
  7. Проводить загрузку модели в/из БД, притом представление полей в БД и Erlang может отличаться.
    1. При обновлении модели, подставлять в SQL запрос только обновленные поля.
    2. Задавать хуки на события: before_save, after_save, before_delete, after_delete

tq_record_transform

Начну с того, что tq_record_transform – это базовый плагин tq_transform для генерации функций, назначение которых будет описано далее, по описанию заданному через атрибуты -field и -model. Сначала я постараюсь описать, как решается проблема в общем виде в Erlang, следом – как это реализовано или работает через tq_transform и почему именно так.

Прячем реализацию

Для того чтобы нормально работать с полями структуры не обращаясь к ним напрямую нужно задать getter-ы и setter-ы

db_user.erl
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
-module(db_user).

-record(db_user,
    {
     login,
     email,
     password,
     salt
    }).

-export([
    new/0,
    login/1, set_login/2,
    email/1, set_email/2,
    password/1, set_password/2,
    salt/1, set_salt/2
])

new() ->
    #db_user{}.

login(User) ->
    User#db_user.login.

set_login(Login, User) ->
    User#db_user{login=Login}.

email(User) ->
    User#db_user.email.

set_email(Email, User) ->
    User#db_user{email=Email}.

password(User) ->
    User#db_user.password.

set_password(Password, User) ->
    User#db_user{password=Password}.

salt(User) ->
    User#db_user.salt.

set_salt(Salt, User) ->
    User#db_user{salt=Salt}.

Как можно заметить имя record-а совпадает с именем модуля. Я уже много раз писал об этом в том числе на habre. Это позволяет использовать объект как классическим способом

1
2
3
4
5
1> User = db_user:new().
2> User2 = db_user:set_login(<<"my_login">>, User).
3> User3 = db_user:set_password(<<"my_password">>, User2).
4> db_user:login(User3).
<<"my_login">>

так и более емким

1
2
3
4
5
1> User = db_user:new().
2> User2 = User:set_login(<<"my_login">>).
3> User3 = User2:set_password(<<"my_password">>).
4> User3:login().
<<"my_login">>

Теперь как то же делается с помощью tq_transform.

db_user.erl
1
2
3
4
5
6
7
8
-module(db_user).

-compile({parse_transform, tq_record_transform}).

-field({login, []}).
-field({email, []}).
-field({password, []}).
-field({salt, []}).

Т.е. надо в модуль добавить указание компилятору использовать parse_transform. Далее определяются поля, имеющие отношение к данной модели через атрибут -field() и кортеж {FieldName, Opts}. FieldName – имя поля, а про Opts поговорим чуть далее.

tq_transform сгенерирует record, геттеры, сеттер и еще много вспомогательных функций.

Значения по умолчанию.

Для того чтобы задать default-ные значения для модели надо модифицировать метод new/2. В нашем примере это не требуется, но, к примеру, если бы у нас было поле group, которое указывало бы имя группы в какой находится пользователь и по умолчанию оно было бы равно “main”, то это бы выглядело так:

1
2
3
4
5
6
7
8
...

new() ->
    #db_user{
        group = <<"main">>
    }.

...

В tq_record_transform этот же функционал достигается через опции. Их две: {default, Value} – задает значение по умолчанию. {default_fun, Fun} – значение, если его необходимо вычислить.

Примеры:

1
2
3
4
-field({group,
    [
     {default, <<"main">>}
    ]}).

или

1
2
3
4
5
6
7
-field({group,
    [
     {default_func, get_default_group}
    ]}).

get_default_group() ->
    <<"main">>.

Сразу хочу рассказать о Fun. Везде, где в опциях упоминается функция, а это: deafult_func, валидаторы, хуки, Fun; может быть:

  • F::atom() – это равносильно простому вызову функции в том же модуле
  • {F::atom(), Args::list()} – вызов F в текущем модуле с дополнительными аргументами Args (всегда идут вначале)
  • {M::atom(), F::atom()} – равносильно вызову F в модуле M
  • {M::atom, F::atom(), Args::list()} – вызов F в модуле M с дополнительными аргументами Args.

Т.е. если get_default_group/0 значение по-умолчанию можно всеми ниже перечисленными способами:

1
2
3
4
{default_func, get_default_group}
{default_func, {get_default_group, []}}
{default_func, {?MODULE, get_default_group}}
{default_func, {?MODULE, get_default_group, []}}

Функция определена таким образом в виду того, что в атрибуте нельзя сделать вызов или указать лямбду.

Ограничение доступа к полям.

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

  • разрешения для чтения/записи
  • разрешения для сериализации/десериализации

В текущем примере особыми параметрами отличаются поля password и salt. salt – никогда не видна конечному пользователю, она нужна только для внутренних проверок. password – можно только писать, т.к. при сохранении в БД он солится и хэшируется, то для клиента он более не несет никакой смысловой нагрузки.

В обычной версии пока нечего писать, т.к. еще не определены функции для сериализации/десериализации, но к ним еще вернемся в следующем пункте.

В tq_record_transform существует опция {mode, AccessMode}, которая отвечает за все правила доступа к полям.

AccessMode :: r | w | rw | sr | sw | srw | rsw | srsw

  • r – в атоме означает, что поле можно читать
  • w – писать

Приставка s к r или w (образована от слова system) означает – только системный доступ. Исходя из вышесказанного получается, что:

  • r – может читать и пользователь и system
  • sr – может читать только system
  • w – может писать и пользователь и system
  • sw – может писать только system

Если в атоме отсутствует r – то вовсе не будет сгенерирован getter, если w – setter.

По умолчанию mode задан как rw.

Применимо к рассматриваемой модели:

1
2
3
4
5
6
7
8
9
10
-field({login, []}).
-field({email, []}).
-field({password,
    [
     {mode, srw}
    ]}).
-field({salt,
    [
     {mode, srsw}
    ]}).

Получается, внешний пользователь может писать, но не читать пароль и вовсе никак не может влиять на соль.

Сериализация

Если необходимо задать модели сразу много полей – это не всегда удобно делать через сеттеры, т.к. приходится много раз копировать объект. для этого лучше предусмотреть функции from_proplist/1 и from_proplist/2 которые принимает proplist полей.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from_proplist(List) ->
    from_proplist(List, new()).

from_proplist([], User) ->
    {ok, User};
from_proplist([{login, Login}|Rest], User) ->
    from_proplist(Rest, User:set_login(Login));
from_proplist([{email, Email}|Rest], User) ->
    from_proplist(Rest, User:set_email(Email));
from_proplist([{password, Password}|Rest], User) ->
    from_proplist(Rest, User:set_password(Password));
from_proplist([{salt, Salt}|Rest], User) ->
    from_proplist(Rest, User:set_salt(Salt));
from_proplist([{FieldName, _Value}|_Rest], _User);
    {error, {FieldName, unknown}}.

Пример:

1
2
3
4
5
6
{ok, User} = db_user:from_proplist(
    [
     {login, <<"my_login">>},
     {password, <<"password">>}
    ]).
UserLogin = db_user:login(User).

Для того, чтобы получить поля модели “извне”, надо реализовать еще одну функцию – from_ext_proplist (ext от external) т.к. во-первых в ключах proplist-а уже не атомы, а бинари, а во-вторых для поля могут требоваться дополнительные трансформации.

1
2
3
4
5
6
7
8
9
10
11
12
13
from_ext_proplist(List) ->
    from_ext_proplist(List, new()).

from_ext_proplist([], User) ->
    {ok, User};
from_ext_proplist([{<<"login">>, Login}|Rest], User) ->
    from_ext_proplist(Rest, User:set_login(Login));
from_ext_proplist([{<<"email">>, Email}|Rest], User) ->
    from_ext_proplist(Rest, User:set_email(Email));
from_ext_proplist([{<<"password">>, Password}|Rest], User) ->
    from_ext_proplist(Rest, User:set_password(Password));
from_ext_proplist([{<<"FieldName">>, _Value}|_Rest], _User);
    {error, {FieldName, unknown}}.

Обратите внимание, что from_ext_proplist не реализует преобразование из поля <<“salt”>>, т.к. внешнему пользователю не разрешено писать в это поле.

Еще одна полезная функция to_proplist/1, но тут все проще:

1
2
3
4
5
6
7
to_proplist(User) ->
    [
     {login, User:login()},
     {email, User:email()},
     {password, User:password()},
     {salt, User:salt()}
    ].

А теперь вспомним про mode, сколько еще предстоит писанины… Так что оставлю реализацию to_ext_proplist на ваше усмотрение.

Теперь про tq_record_transform.

Прежде чем говорить про *_ext_* функции сразу расскажу про опции поля from_ext и to_ext.

  • {from_ext, Fun} – Fun применяется к данным, если Fun вернет {ok, Data}, то будет вызван сеттер с полученными данными, если {error, Reason} то, к списку ошибок трансформации будет добавлена {FieldName, Reason}.
  • {to_ext, Fun} – Fun применяется к данным перед их отправкой пользователю. Функция должна вернуть просто Data.

А также полю можно указать опцию {type, Type}. Пока она используется только для указания parse_transform-у какую from_ext функцию и какие валидаторы по умолчанию использовать, но в планах использовать ее для построения спецификации функций. Поддерживаемые на данный момент типы – binary, non_empty_binary, non_neg_integer, non_neg_float, integer, float, boolean, date, time и datetime.

Функции from_proplist и from_ext_proplist реализованы несколько по-другому. Они отличаются тем, что дополнительно принимают опции safe | unsafe и ignore_unknown, и возвращает не первую ошибку, а все ошибки, которые возникли по ходу преобразования полей.

  • ignore_unknown – говорит продолжать выполнение, если встретилось неизвестное поле, а не выкидывать ошибку.
  • safe – запрещает задавать поля которые помечены как системные,
  • unsafe – разрешает задание системных полей

to_proplist и to_ext_proplist реализованы сходным образом.

Помимо вышеперечисленных функций генерируются fields/[2,3] и ext_fields/[2,3]. На вход они принимают, помимо модели, список полей, которые надо вернуть. Т.е. работают почти как to_proplist аналоги, но возвращают только часть запрошенных полей. Опции для них:

  • binary_key – означает, что список полей, которые надо вернуть содержит не атомы, а binary
  • unsafe – вернуть и системные поля тоже
  • ignore_unknow – игнорировать неизвестные имена полей

Список измененных полей

Довольно часто встречается такая необходимость, например, при сохранении в БД или валидации. Есть два способа определить, что поле изменилось:

  • Хранить старую версию и новую
  • Ставить флаг об изменении в record-е через setter

Первый способ заставляет хранить 2 версии одних и тех же данных, что порой слишком накладно по памяти. Так что реализуем 2-й. Для каждого record-а поля зададим еще одно с пометкой об изменении.

1
2
3
4
5
6
7
8
9
10
11
-record(db_user,
    {
     login,
     login_changed = false,
     email,
     email_changed = false,
     password,
     password_changed = false,
     salt,
     salt_changed = false
    }).

Все сеттеры меняются по аналогичному правило, например

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...

set_login(Login, User) ->
    case User#db_user.login of
        Login ->
            User;
        _ ->
            User#db_user{
                login=Login,
                login_changed=true
            }
    end.

...

Не стоит забывать про значения по умолчанию. Если они есть, то в них тоже стоит поменять маркер changed, например:

1
2
3
4
5
6
7
8
9
...

new() ->
    #db_user{
        group = <<"main">>,
        group_changed = true
    }.

...

Теперь 2 функции is_changed/2 и get_changed_field/2 для проверки поменялось ли поле, и получения списка измененных полей.

1
2
3
4
5
is_changed(login, User) -> User#db_user.login_changed;
is_changed(email, User) -> User#db_user.email_changed;
is_changed(password, User) -> User#db_user.password_changed;
is_changed(salt, User) -> User#db_user.salt_changed;
is_changed(_Field, User) -> throw({unknown_field, Field}).

Я использую throw вместо простого “let it fail”, мне кажется, что при таком подходе проще понять ошибку по логам.

1
2
3
4
5
6
7
8
9
get_changed_fields(User) ->
    Fields =
        [
         {login, User#db_user.login, User#db_user.login_changed},
         {email, User#db_user.email, User#db_user.email_changed},
         {password, User#db_user.password, User#db_user.password_changed},
         {salt, User#db_user.salt, User#db_user.salt_changed},
        ],
    [{Name, Value} || {Name, Value, true} <- Fields].

Код, сгенерированный tq_record_transform, особо ничем не отличается от приведенного выше, так что просто приведу пример использования.

1
2
3
4
5
6
7
8
9
10
1> Proplist = [{<<"login">>, <<"my_login">>}, {<<"password">>, <<"my_password">>}].
2> User = db_user:from_ext_proplist(Proplist).
3> User:is_changed(login).
true
4> User:is_changed(password).
true
5> User:is_changed(salt).
false
6> User:get_changed_fields().
[{login, <<"my_login">>}, {password, <<"my_password">>}].

Валидаторы

Валидатор – эта функция, которая принимает на вход данные и возвращает ok или {error, Reason}.

Определим валидаторы для нашей модели:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
check_email(Binary) ->
    case regexp_match("^[-\\w.]+@([A-z0-9][-A-z0-9]+\\.)+[A-z]{2,}$", Email) of
        {match, _} ->
            ok;
        nomatch ->
            {error, invalid}
    end.

...

validator(email) -> fun check_email/1,
validator(login) -> fun check_login/1,
...
validator(Field) -> throw({unknown_field, Field}).

Логику самой функции valid я писать не буду, просто упомяну, что проблема достаточно сложная, требуется учитывать обязательное ли это поле, получено он из БД или нет (is_new/1 обсудим в следующей части), валидаторы могут выстраиваться в цепочки, могут быть заданы не только для поля, но и для модели в целом.

В tq_transform_utils определено несколько валидаторов для типов. A tq_record_transform позволяет задать опции поля required, {validators, Fun | [Fun]} и опции модели {validators, Fun | [Fun]}. Про опции модели я пока не говорил, но они задаются через атрибут -model([Opt]).

В связи с новой информацией изменим модель так чтобы она удовлетворяла следующим критериям:

  • login, password и salt – обязательные поля
  • login и email не могут быть пустыми и должны соответствовать определенным критериям
  • password не меньше 6 символов.
db_user.erl
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
27
28
29
30
31
32
33
34
35
36
37
38
-module(db_user).

-compile({parse_transform, tq_record_transform}).

-field({login,
    [
     {type, non_empty_binary},
     required,
     {validators,
        [
         {validators, login}
        ]}
    ]}).

-field({email,
    [
     {type, non_empty_binary},
     {validators,
        [
         {validators, email}
        ]}
    ]}).

-field({password,
    [
     {type, non_empty_binary},
     required,
     {validators,
        [
         {validators, min_length, [6]}
        ]}
    ]}).

-field({salt,
    [
     {type, binary},
     required
    ]}).

Валидаторы:

validators.erl
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
-module(validators).

-export([
         min_length/2,
         email/1,
         login/1
        ])

regexp_match(Re, Bin) ->
    case re:run(Bin, Re) of
        {match, _} ->
            ok;
        nomatch ->
            {error, invalid}
    end.

email(Email) ->
    regexp_match("^[-\\w.]+@([A-z0-9][-A-z0-9]+\\.)+[A-z]{2,}$", Email).

login(Login) ->
    regexp_match("^\\w+([.-]?\\w+)+$", Login).

min_length(MinLength, Bin) when byte_size(Bin) < MinLength ->
    {error, {min_length, MinLength}};
min_length(_, _) ->
    ok.

Пример:

1
2
3
4
1> Proplist = [{<<"login">>, <<"my_login">>}, {<<"password">>, <<"pas">>}, {<<"email">>, <<"email@">>}].
2> User = db_user:from_ext_proplist(Proplist).
3> User:valid().
{error, [{password, {min_length, 6}}, {email, invalid}, {salt, required}]}

Итог

Если всю логику, которая описывается в декларативном виде благодаря tq_transform каждый раз описывать вручную, то потребуется огромное количество boilerplate кода а значит, количество требуемого времени и вероятность возникновения ошибок сильно увеличатся.

В следующей части я расскажу о втором плагине для tq_transtormtq_db, который позволяет сохранять модели в БД, о моей маленькой надстройке для sql, и том, где и как хранится мета информация о полях и модели.

P.S. Не так давно начал заниматься причесыванием кодовой базы для этих проектов, по ним пока мало документации ( на самом деле эта статья на данный момент наиболее полный справочник ). Так что, если будут вопросы, не стесняйтесь – задавайте, если пожелания или информация об ошибках и недочетах то слать их лучше или на github, или на мой email.

P.P.S Если интересно посмотреть сгенерированный код, то для этого есть специальная функция

1
1> tq_transform_utils:print_module(db_user).

Комментарии