По долгу службы, мы много работаем с деньгами. Складываем, вычитаем, считаем проценты. Любому школьнику известно, что для этих расчетов не подходят обычные встроенные в язык типы: флоаты не сойдутся у финансовых аудиторов, большие целые принесут кучу проблем при конвертации (например, йены обходятся без дробных единиц, а в одном оманском риале — 1000 баиз, а не сто, как у всяких плебейских долларов и евро), и так далее. Существуют целые комитеты, определяющие стандарты (ISO 4217 — коды валют и ISO 24165 — идентификаторы цифровых токенов). Во всех более-менее современных языках есть библиотеки для работы с денежными суммами, реализующие стандарты и скрывающие от нас адовую арифметику без потерь точности.

В мире эликсира, почти всем, что связано с имплементацией комитетских стандартов, занимается Кип Коул. Удобной работе с деньгами мы обязаны тоже его библиотекам: ex_money — для собственно арифметики и money_sql для персистенса.

Работают они обе, как часы. Причем, сделаны (как и все, что делает Кип) — с умом. Для постгреса определяется пользовательский тип, позволяющий выполнять сложные запросы к записям, содержащим денежные суммы. К сожалению, до недавнего времени эти запросы требовали чистых фрагментов SQL и не были базонезависимыми. Так (примеры из документации), для выборки суммы нужно было написать что-то типа

Ecto.Query.select(Item, [l], type(sum(l.price), l.price))

а для сортировки по значению — и того хуже:

from l in Item, select: l.price, order_by: fragment("amount(price)")

Не то, чтобы это сильно мешало, но каждый раз вспоминать кусок голого сиквела — это слишком уж низкоуровнево. Мы можем лучше, учитывая открытость Ecto к пользовательским макросам общего назначения.


Кип поддерживает реализации тонны бесконечных стандартов, поэтому создать issue и потребовать реализовать базонезависимые хелперы — я отмел как вариант неприемлемый. А поскольку «нету ручечек — нету конфеточек», решил реализовать base-agnostic макросы сам. Вроде, никаких особенных подводных камней на этом пути не ожидалось.

Итерация 1. PostgreSQL, custom types.

Я начал с самого естественного, быстрого, и часто используемого варианта: пользовательский тип для постгреса. Я пока не знал, примет ли Кип пулл-реквест, поэтому начал с удовлетворения собственных потребностей. Мне были нужны сравнение и агрегации по разным валютам, что-то типа такого:

Organization
|> where([o], amount_ge(o.payroll, 100))
|> select([o], o.payroll)
|> Repo.all()
#⇒ [Money.new(:AUD, "100"), Money.new(:USD, "200")]

Organization
|> where([o], o.name == ^"Lemon Inc.")
|> total_by([o], o.payroll, :USD)
|> Repo.one()
#⇒ [Money.new(:USD, "210")]

Я засучил рукава и (памятуя о необходимости поддерживать в будущем несколько разных вариантов хранения) набросал простую архитектурку: макросы общего назначения (типа total_by/3) — опираются на макросы имплементации, привязанной к конкретному адаптеру базы. Для удобства использования, все эти импорты правильных модулей — завернуты в use/2.

use Money.Ecto.Query.API, adapter: Money.Ecto.Adapters.MySQL

Если кому потребуется прикрутить какой-нибудь MSSQL — потребуется написать только самый нижний уровень адаптера: собственно фрагменты для выемки из значения в таблице суммы и валюты. Что ж, определим для этого behaviour.

if Code.ensure_loaded?(Ecto.Query.API) do
  defmodule Money.Ecto.Query.API do
    @moduledoc "…"

    @doc """
    Native implementation of how to retrieve `amount` from the DB.
    For `Postgres`, it delegates to the function on the composite type,
      for other implementations it should return an `Ecto.Query.API.fragment/1`.
    """
    @macrocallback amount(Macro.t()) :: Macro.t()

    @doc "…"
    @macrocallback currency_code(Macro.t()) :: Macro.t()

    @doc "…"
    @macrocallback sum(Macro.t(), cast? :: boolean()) :: Macro.t()

    …

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

@doc """
`Ecto.Query.API` helper, allowing to filter records having one 
of currencies given as an argument.
_Example:_
```elixir
iex> Organization
...> |> where([o], currency_in(o.payroll, [:USD, :EUR]))
...> |> select([o], o.payroll)
...> |> Repo.all()
[Money.new(:EUR, "100"), Money.new(:USD, "100")]
```
"""
defmacro currency_in(field, currencies) when is_list(currencies) do
  currencies =
    currencies
    |> Enum.map(&to_string/1)
    |> Enum.map(&String.upcase/1)

  quote do
    currency_code(unquote(field)) in ^unquote(currencies)
  end
end

и все эти хелперы ничего не будут знать про потроха базы.

Для композитного пользовательского типа — реализация требуемых колбэков довольно тривиальна.

@behaviour Money.Ecto.Query.API

@impl Money.Ecto.Query.API
defmacro amount(field),
  do: quote(do: fragment("amount(?)", unquote(field)))

@impl Money.Ecto.Query.API
defmacro currency_code(field),
  do: quote(do: fragment("currency_code(?)", unquote(field)))

@impl Money.Ecto.Query.API
defmacro sum(field, cast? \\ true)

@impl Money.Ecto.Query.API
defmacro sum(field, false),
  do: quote(do: fragment("sum(?)", unquote(field)))

@impl Money.Ecto.Query.API
defmacro sum(field, true),
  do: quote(do: type(sum(unquote(field)), unquote(field)))

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

Итерация 2. PostgreSQL, map type.

Для обычного типа с двумя полями в постгресе — все оказалось чуть сложнее. Доступ к amount и currency_code — все еще тривиальный (но другой):

fragment(~S|(?->>'amount')::int|

fragment(~S|?->>'currency'|

А вот для суммы пришлось немного вывернуться ужом (я уже предвкушал реализацию для MySQL и прикидывал, хватит ли в доме кофе и виски):

CASE COUNT(DISTINCT(?->>'currency'))
WHEN 0 THEN JSON_BUILD_OBJECT('currency', NULL, 'amount', 0)
WHEN 1 THEN JSON_BUILD_OBJECT('currency', MAX(?->>'currency'), 'amount', SUM((?->>'amount')::int))
ELSE NULL
END

Учитывая, что я с сиквелом в последний раз работал в прошлом веке, это заняло чуть ли не час. Зато после того, как я отладил его на ручных запросах, тесты из прошлого раунда — прошли сразу :)

Итерация 3. MySQL, гори в аду.

Я не буду описывать, какой крови мне стоило добиться работоспособности этих бесхитростных макросов в MySQL, я просто приведу код:

-- FRAGMENTS
-- ~S|CAST(JSON_EXTRACT(?, "$.amount") AS UNSIGNED)|
-- ~S|JSON_EXTRACT(?, "$.currency")|

IF(COUNT(DISTINCT(JSON_EXTRACT(?, "$.currency"))) < 2,
  JSON_OBJECT(
    "currency", JSON_EXTRACT(JSON_ARRAYAGG(JSON_EXTRACT(?, "$.currency")), "$[0]"),
    "amount", SUM(CAST(JSON_EXTRACT(?, "$.amount") AS UNSIGNED))
  ),
  NULL
)

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


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

Постарайтесь никогда так не делать, если вы в себе не уверены.
Постарайтесь никогда так не делать, если вы в себе не уверены.

Впрочем, ⅔ там тесты, так что все в порядке.

Вот так, всего за несколько дней, удалось не только существенно упростить нашей команде работу с полями, содержащими деньги во всех их проявлениях, но и сделать это правильно: не в кишках одного из наших микросервисов, и даже не в обособленном внутреннем артифакте, а там, где этому коду самое место: в оригинальной библиотеке, которой пользуются все, кому нужно работать с деньгами на эликсире.

Читатель, помни! Коммьюнити — штука важная и полезная, а тщательно отправляя пулл-реквесты — ты помогаешь обществу.

Комментарии (9)


  1. cupraer Автор
    16.06.2023 12:18

    Мда, зарекался я по-русски про Elixir писать…@Source ну хоть вы чего-нибудь скажите, что ли.


    1. Source
      16.06.2023 12:18

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

      А так вообще круто! Действительно удобнее стало.

      и сделать это правильно: не в кишках одного из наших микросервисов, и даже не в обособленном внутреннем артифакте, а там, где этому коду самое место: в оригинальной библиотеке, которой пользуются все

      Вот это я всячески одобряю. Сам стараюсь так же делать.


      1. cupraer Автор
        16.06.2023 12:18

        Прастити?

        > Работают они обе, как часы. Причем, сделаны (как и все, что делает Кип) — с умом. Для постгреса определяется пользовательский тип, позволяющий выполнять сложные запросы к записям, содержащим денежные суммы.


        1. Source
          16.06.2023 12:18

          Я имел в виду детали. Что из себя этот пользовательский тип представляет. Плюс дальше ещё MySQL фигурирует.

          В целом, можно это и по ссылке в самом проекте посмотреть. Это так, для полноты статьи скорее.


          1. cupraer Автор
            16.06.2023 12:18

            MySQL даже сам Кип поддерживает только постольку-поскольку, я его привел просто, чтобы похвастаться тем Франкенштейном, который агрегирует «нативно поддерживаемые» джейсон-поля :)

            Я всегда считал, что не хватает в нашей индустрии именно толчков в спину на «посмотреть чотам»; то есть, человек либо заинтересуется и пойдет разбираться, либо я хоть сто раз расскажи про внутреннее убранство — проглядит по диагонали, скажет «ух ты», и пойдет перекладывать джейсон.


            1. kai3341
              16.06.2023 12:18

              Какой Кип? Публикуя статью в хабе `MySQL`, вы обрекаете себя на то, что к вам придут питонщики, джаваскриптизёры, пэхапэшники и прочие. И, оказывается, никто из них почему-то не шарит за эликсир.

              Поэтому вопрос о нативных типах БД от @Source -- максимально логичный в данном контексте -- всё начинается и заканчивается внутри БД. Вместо ответа -- "Прастити?" и отсылка к авторитету.

              Давайте всё же будем профессионалами. Я присоединяюсь к вопросу -- как оно работает внутри БД? Меня интересует DML, но не нюансы вашей ORM


              1. cupraer Автор
                16.06.2023 12:18
                -1

                Давайте вы будете профессионалами без меня?

                Кроме того, окститесь, откуда на говнохабре профессионалы?


                1. Source
                  16.06.2023 12:18
                  +1

                  Как-то это по-детски обижаться на ресурс и его пользователей. Это ж позиция непризнанного гения (жертвы).

                  Вы выбрали интересную тему и заход был хороший. Но после "Во всех более-менее современных языках есть библиотеки для работы с денежными суммами, реализующие стандарты и скрывающие от нас адовую арифметику без потерь точности." стоило всё-таки погрузиться в детали как это работает на уровне БД и что там за адовая арифметика без потери точности. Тогда получилась бы статья для широкой аудитории, с параллельным продвижением Elixir.

                  Как, например, Фаулер продвигал Java своими книгами по рефакторингу и паттернам проектирования.

                  У вас же получилась узкоспециализованная статья, которая местами выглядит как хвастовство в стиле "во как я могу" (просто фрагменты кода и минимум пояснений по ним). Просто представьте, что читатель первый раз слышит про подобную библиотеку. А вы сходу что-то поверх неё делаете, предварительно не объяснив вообще ничего.

                  Безусловно вы имеете право и на такой формат. Но такие статьи всегда привлекают меньше внимания. Это факт, а не повод обижаться.


                  1. cupraer Автор
                    16.06.2023 12:18

                    Обидеть меня могут три с половиной человека, и ни один из них не зарегистрирован на хабре. Не, ну серьезно, какие нафиг обиды?

                    Формат, который вы рекомендуете, имеет право на существование, если бы мне хоть сколько было бы интересно продвижение. Но оно мне не интересно; я поделился интересным и очень недооцененным (на мой взгляд) подходом к построению библиотек: сделать жизнь пользователя легче, подумав об интерфейсе, а не только о реализации. Сама библиотека тут сбоку припека. Но главное — вынесено в заголовок (за который я огреб, разумеется «кликбейтности»).

                    И тут приходит какой-то хер с горы, и сообщает мне менторским тоном, как мне надо писать. Я класть хотел в принципе на хабр, но иногда альтруизм (который заставляет меня отвечать на SO и разгонять OSS) перебарывает, и я что-то перевожу на русский и публикую тут. Это — привелегия читателя, и свои чаяния о том, «как надо», особенно в таком тоне (вы же вот смогли как-то написать то же самое без неуместного апломба) — высказывать в комментариях не следует. Это как если бы на SO в комментарии к моему ответу пришел непоймикто, и начал учить, как правильно отвечать.

                    А фраза «Меня интересует DML» — это просто неприкрытое хамство. Интересует? — Почитай, доки прекрасные.