Egobrain

Erlang ORM. Часть 2

В прошлой части я говорил про генерацию модели, но так и не осветил как соединить генерацию моделей и работу с БД. Сейчас это исправлю.

Все примеры Sql буду приводить с использованием PostgreSQL (epgsql) и драйвер для tq_db tq_postgres_driver.

Meta

Для того, чтобы управлять моделями требуется большое количество мета информации. Есть много способов ее хранить но я выбрал оптимальный для себя – функция $meta/1.
Она аргументом принимает ключ запрашиваемых данных.
Первым символом идет знак “$” (из-за этого приходится весь атом обрамлять в одинарные кавычки), это сделано для того чтобы подчеркнуть, что функция является системной и ее не желательно использовать в бизнес логике.
Также определена функция $meta/2, которая просто пробрасывает вызов на $meta/1, но нужна для работы механизма вызова через кортеж.

db_user.erl
1
2
3
4
5
'$meta'(Opt) ->
    ... .

'$meta'(Opt, _User) ->
    '$meta'(Opt).
1
2
3
4
5
6
db_user:'$meta'(Opt).

%% То же самое, что и

User = db_user:new().
User:'$meta'(Opt).

Опции которые по умолчанию задает tq_transform:

  • module – возвращает имя модуля для модели
  • {record_index,Field} – позиция поля Field в модели

tq_db расширяет этот список еще несколькими:

  • table – имя таблицы Sql
  • indexes – список индексных полей
  • {db_type, Field} – тип поля Field в базе данных
  • {db_alias, Field} – имя поля Field в базе данных
  • {db_fields, r} – список полей, которые разрешено писать в БД
  • {db_fields, w} – список полей, которые разрешено читать из БД

Для генерации tq_db необходимо использовать tq_sqlmodel_transform parse_transform, обязательно должна быть указана опция модели table, заданы БД типы для полей и хотя бы одно из них должно быть индексное.

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
45
46
47
48
49
50
51
52
53
-module(db_user).

-compile({parse_transform, tq_sqlmodel_transform}).

-field({id,
    [
     index,
     {type, integer},
     {db_type, integer}
    ]}).

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

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

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

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

-model([
     {table, <<"users">>}
    ])

Sql injection

Метод защиты от sql инъекций будет достаточно простой: каждому аргументу, который будет подставлен в результирующий Sql запрос в пару должно быть дописано описание типа, которое укажет драйверу БД как работать с этими данными. Драйвер должен взять на себя всю ответственность по проверке и экранированию данных.

Например:

1
query(<<"SELECT * FROM users WHERE id = $1">>, [{integer, 1}]).

Connection pool

Для работы с БД желательно использовать пул подключений. Имя пула может быть явно указан при генерации модели.

1
2
3
-model([
     {pool_name, db}
    ]).

По умолчанию используется пул с именем db.

Логика работы пула должна быть полностью реализована драйвером.

Select

Вспомним структуру модели db_user.

Типичный запрос для получения данных выглядит так:

1
2
{ok, C} = pgsql:connect("localhost", "postgres", "password", [{database, "users"}]).
{ok, _Columnts, Rows} = pgsql:equery(C, <<"SELECT login, email, password, salt FROM users">>, []).

Теперь в Rows лежит кортеж {Login, Email, Password, Salt}.

Теперь понадобится функция для преобразования этого кортежа в нашу модель.

1
2
3
4
5
6
to_model({Login, Email, Password, Salt}) ->
    User = db_user:new(),
    User2 = User:set_login(Login),
    User3 = User2:set_email(Email)
    User4 = User3:set_password(Password),
    User:set_salt(Salt).

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

1
2
3
query(C, Sql, Args, Constructor) ->
    {ok, _Columnts, Rows} = pgsql:equery(C, Sql, Args),
    [Constructor(Row) || Row <- Rows].

и использовать ее так:

1
2
{ok, C} = pgsql:connect("localhost", "postgres", "password", [{database, "users"}]).
query(C, <<"SELECT login, email, password, salt FROM users">>, [], fun to_model/1).

Функция to_model/1 получилась не универсальной, т.к. фиксировано количество полей, которые должны возвращаться из БД. Устраним этот досадный недостаток, введя функции field_constructor(FieldName), которая возвращает Setter для поля, и constructor(Fields), которая принимает список полей модели и возвращает аналог to_model/1 для преобразования кортежа в объект db_user.

db_user.erl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
field_constructor(login) ->     fun db_user:set_login/2;
field_constructor(email) ->     fun db_user:set_email/2;
field_constructor(password) ->  fun db_user:set_password/2;
field_constructor(salt) ->      fun db_user:set_salt/2.

constructor(Fields) ->
    fun(Data) ->
        Setters = [field_constructor(F) || F <- Fields],
        DataList = tuple_to_list(Data),
        SettersDataList = lists:zip(Setters, DataList),
        lists:foldl(
            fun({Set, Val}, User) -> Set(Val, User) end,
            db_user:new(),
            SettersDataList)
    end.

Кода больше, но он более универсален.

1
2
3
4
5
6
7
8
{ok, C} = pgsql:connect("localhost", "postgres", "password", [{database, "users"}]).
query(C, <<"SELECT login, email, password, salt FROM users">>, [],
    db_user:constructor([login, email, password, salt])).

% или

query(C, <<"SELECT login, email FROM users">>, [],
    db_user:constructor([login, email])).

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

1
2
3
4
5
6
7
...
field_constructor(login) ->
    fun(Val, User) ->
        NewVal = from_db(Val),
        User:set_login(NewVal)
    end;
...

tq_sqlmodel_transform может генерировать функции get и find для модели. Для этого надо явно указать через опцию модели

db_user.erl
1
2
3
4
-model([
     ...
     {generate, [get, find]}
    ]).

get – Арность функции равна количеству индексных полей модели.
find(Query, Args) – принимает в качестве параметров запрос Query, который представляет собой dsl и должен обрабатываться далее. Поговорим об это позже.

1
2
3
4
5
6
7
1> {ok, User} = db_user:get(1).
2> User:login().
<<"my_login">>

3> {ok, Users} = db_user:find(<<>>, []). % Select all
4> [U:login() || U <- Users].
[<<"my_login">>, ...].

Помимо представленных, для получения данных из БД можно воспользоваться более низкоуровневыми функциями из модулей tq_sql и tq_dsl.

Save

Для сохранения модели понадобится флаг is_new, который показывает является ли она новой или нет. В конструкторе должны быть сброшены флаг is_new и флаги изменения полей (_changed).

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
-record(db_user, {
    ...
    is_new = false
}).

new() ->
    #db_user{
        ...
        is_new = true
    }.

...

field_constructor(login) ->
    fun(Val, User) ->
        User:set_login(Val),
        User#db_user{login_changed=false}
    end;
...

constructor(Fields) ->
    fun(Data) ->
        Setters = [field_constructor(F) || F <- Fields],
        DataList = tuple_to_list(Data),
        SettersDataList = lists:zip(Setters, DataList),
        lists:foldl(
            fun({Set, Val}, User) -> Set(Val, User) end,
            #db_user{is_new=false},
            SettersDataList)
     end.

Так же добавим функцию is_new/1

db_user.erl
1
2
is_new(User) ->
    User#db_user{is_new = true}.

Наконец-таки перейдем к сохранению модели.
Необходимо проверить является ли модель новой и, в зависимости от результата, выполнить insert или update.

db_user.erl
1
2
3
4
5
6
7
save(User) ->
    case User:is_new() of
        true ->
            % INSERT
        false ->
            % UPDATE
    end.

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

db_user.erl
1
2
3
4
5
6
7
8
9
10
11
db_changed_fields(Model) ->
    [
     {Name,Val} ||
        {Name,Val,true} <-
            [
             {login, User#db_init.login, User#db_user.login_changed},
             {email, User#db_init.email, User#db_user.email_changed},
             {password, User#db_init.password, User#db_user.password_changed},
             {salt, User#db_init.salt, User#db_user.salt_changed}
            ]
    ].

В списке должны быть только те поля, которые храняться в БД.

Если понадобиться перед сохранением сделать преобразвание полея to_db, то следует изменить реализацию этой функции на следующую:

db_user.erl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
db_changed_fields(Model) ->
    [
     {Name,Val()} ||
        {Name,Val,true} <-
            [
             {login,
              fun() -> to_db(User#db_init.login) end,
              User#db_user.login_changed},
             {email,
              fun() -> User#db_init.email end,
              User#db_user.email_changed},
             {password,
              fun() -> User#db_init.password end,
              User#db_user.password_changed},
             {salt,
              fun() -> User#db_init.salt end,
              User#db_user.salt_changed}
            ]
    ].

Я не буду приводить тут пример как реализовать в общем виде генерацию Sql для insert и update, имея всю мета информацию и данные – это будет тривиальным заданием. Кому интересно – могут посмотреть пример реализации из postgres драйвера: insert, update

Необходимость генерации функции save нужно указать через опцию модели generate.

db_user.erl
1
2
3
4
-model([
     ...
     {generate, [save, ...]}
    ]).

Hooks

Полезно иметь возможность задавать хуки для операций save и delete. Для этого в реализацию методов save добавляются пред и пост условия.

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
save(Model) ->
    case before_save_hook(Model) of
        {ok, Model2} ->
            case save_logic(Model2) of
                {ok, Model3} ->
                    after_save_hook(Model, Model3);
                {error, _Reason} = Err ->
                    Err
            end;
        {error, _Reason} = Err ->
            Err
    end.

delete(Model) ->
    case before_delete_hook(Model) of
        {ok, Model2} ->
            case save_logic(Model2) of
                {ok, Model3} ->
                    after_delete_hook(Model, Model3);
                {error, _Reason} = Err ->
                    Err
            end;
        {error, _Reason} = Err ->
            Err
    end.

Обратите внимание, что хук after_save_hook отличается количеством аргументов. Иногда бывает полезно иметь доступ к изначальному состоянию модели, к примеру, для проверки выполнялось ли первое сохранение модели или же обновление старой.

В tq_db хуки задаются через опции модели:

1
2
3
4
5
6
7
-model([
     {before_save, before_save_hook},
     {after_save, after_save_hook},

     {before_delete, before_delete_hook},
     {after_delete, after_delete_hook}
    ]).

SQL DSL.

Посмотрим на запрос на выборку данных

1
2
3
1> equery(C, <<"SELECT login, email, password, salt FROM users WHERE id = $1">>,
    [{integer, 1}]
    db_user:constructor([login, email, password, salt])).

Что сразу мне не нравится:

  • Я должен вручную писать alias поля в “select” части Sql запроса и имена полей в конструкторе и вручную контролировать корректность их заполения;
  • в “where” части я должен вручную писать alias-ы полей;
  • должен вручную вписывать типы аргументов для Args;
  • вручную указывать в запросе имя таблицы.

Я решил это дело автоматизировать путем введения дополнительных конструкций непосредственно в Sql.

Получились такие такое расширение:

  • $model.field_name или $field_name – в sql вместо этой конструкции будет подставлен alias поля, полученный через model:'$meta'({db_alias, field_name}).
  • ~model.field_name или ~field_name – в местах этих конструкций будет вставлен аргумент из Args. Для аргумента будет автоматически подставлен тип model:$meta'({db_type, field_name}). (Например для ~id, из аргумента 10 получится [{integer, 10}]).
  • #model – подстановка имени таблицы для модели model из model:'$meta'({db_alias, field_name}).
  • @model.field_name или @field_name – в sql вместо этой конструкции будет подставлен alias поля, полученный через model:'$meta'({db_alias, field_name}), field_name также будет подставлен в constructor.
  • @model.field_name(...) или @field_name(...)field_name будет подставлен в constructor, но alias не попадет в sql. Например, для запроса <<"SELECT @id(1)">>, в конструктор в качестве значения для поля id попадет 1.
  • @model.* или @* – извлечь все поля (которые помечены как read)
  • @model... или @... – извлечь поля, которые ранее не упоминались в этом sql запросе (которые помечены как read)

Т.к. часто возникают коллизии по именам, их иногда следует писать в виде “table_alias.field”, для указания синонимов таблицы при подстановке алиасов полей можно использовать фигурные скобки:

  • ${table_alias}model.field_name или ${table_alias}field_name
  • @{table_alias}model.field_name или @{table_alias}field_name
  • @{table_alias}model... или ${table_alias}...
  • @{table_alias}model.* или ${table_alias}*

    Весь представленый dsl был реализован для удовлетворения личных потребностей по работе с Sql. DSL реализуется драйвером, так что, при желании, его можно легко его заменить на свой.

Для работы с новым языком запросов используется модуль tq_dsl.

1
{ok, [User]} = tq_dsl:model_query(PoolName, db_user, <<"SELECT @* FROM #db_user WHERE $id = ~id">>, [1]).

Usage

Все готово к тому, чтобы привести комплексный пример. Перепишем модуль db_user по всем правилам, т.е. будем солить пароль при сохранении в БД, искать пользователя по связке login + password и т.п.

Первое что нужно сделать – добавить драйвер tq_db в зависимости проекта, в моем случае это tq_postgres_driver:

rebar.config
1
2
3
{deps, [
        {tq_transform, ".*", {git, "git://github.com/egobrain/tq_postgres_driver", {tag, "0.1.0"}}}
       ]}.

Далее необходимо запутить драйвер и задать параметры пула подключений

my_app.erl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
start() ->
    DbPool = [
         {size, 10},
         {max_overflow, 20},

         {hostname, "localhost"},
         {username, "postgres"},
         {password, "password"},
         {database, "users"}]
    ],

    % Load App
    application:load(tq_db),
    application:set_env(tq_db, pools, [{db, tq_postgres_driver, DbPool}]),

    % Start Apps
    application:start(tq_postgres_driver),
    application:start(tq_db).

описать модель

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
-module(db_user).

-compile({parse_transform, tq_sqlmodel_transform}).

-export([get/2]).

-field({id,
        [
         index,
         {type, non_neg_integer},
         {db_type, integer},
         {mode, r}
        ]}).

-field({login,
        [
         required,
         {type, non_empty_binary},
         {db_type, varchar},
         {validators,
            [
             {validators, min_length, [4]},
             {validators, max_length, [32]},
             {validators, login}
            ]}
        ]}).

-field({email,
        [
         required,
         {type, non_empty_binary},
         {db_type, varchar},
         {validators,
          [
           {validators, max_length, [254]},
           {validators, email}
          ]}
        ]}).

-field({password,
        [
         required,
         {type, non_empty_binary},
         {db_type, varchar},
         {mode, srw},
         {validators,
          [
           {validators, min_length, [6]},
           {validators, max_length, [64]}
          ]}
        ]}).

-field({salt,
        [
         required,
         {type, non_empty_binary},
         {db_type, varchar},
         {mode, srsw}
        ]}).

-model([
        {table, <<"user">>},
        {generate, [get, save, find, delete]},
        {before_save, [valid, salt_password]}
       ]).

get(login, Password) ->
    case find(<<"where $login = ~login limit 1">>, [Login]) of
        {ok, []} ->
            {error, unknown_login};
        {ok, [User]} ->
            case hash(Password, User:salt()) =:= User:password() of
                true ->
                    {ok, User};
                false ->
                    {error, wrong_password}
            end;
        {error, _} = Err ->
            Err
    end.

salt_password(Model) ->
    case Model:is_changed(password) of
        true ->
            Salt = gen_salt(),
            SaltedPassword = hash(Model:password(), Salt),
            Model:from_proplist(
                [
                 {salt, Salt},
                 {password, SaltedPassword}])
                ]);
        _ ->
            {ok, Model}
    end.

hash(Password, Salt) ->
    md5(<<Password/binary, Salt/binary>>).

gen_salt() ->
    list_to_binary([random:uniform(87) + 35 || _ <- lists:seq(1, 15)]).

md5(Binary) ->
    iolist_to_binary(
      [
       io_lib:format("~.16b",[N])
       || <<N>> <= crypto:hash(md5, Binary)
      ]).

и, для полноты картины, валидаторы

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
27
28
29
30
31
32
-module(validators).

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

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

max_length(MaxLength, Bin) when byte_size(Bin) > MaxLength ->
    {error, {max_length, MaxLength}};
max_length(_, _) ->
    ok.

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).

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

Пример использования:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1> {ok, User} = db_user:from_ext_proplist(
    [
     {<<"login">>, <<"my_login">>},
     {<<"password">>, <<"123">>},
     {<<"email">>, <<"my.login@my.mail.ru">>}
    ], [safe]).
2> User:save().
{error, [{password, {min_length, 6}}]}
3> User2 = User:set_password(<<"123456">>).
4> User2:save().
{ok, {db_user, ...}}
5> db_user:get(<<"login">>, <<"123">>).
{error, wrong_password}
6> {ok, User3} = db_user:get(<<"login">>, <<"123456">>).
7> User3:id().
1

На сегодня все. Но хочу сказать, что предстоит еще много работы для продолжения которой мне очень нужна обратная связь от сообщества, так что пишите мне, задавайте вопросы, говорите пожелания и давайте рекомендации… Спасибо за внимание.

Комментарии