По долгу службы, мы много работаем с деньгами. Складываем, вычитаем, считаем проценты. Любому школьнику известно, что для этих расчетов не подходят обычные встроенные в язык типы: флоаты не сойдутся у финансовых аудиторов, большие целые принесут кучу проблем при конвертации (например, йены обходятся без дробных единиц, а в одном оманском риале — 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.
В принципе, рассказывать больше нечего. Код попал в следующий релиз, невзирая на неприличные
Впрочем, ⅔ там тесты, так что все в порядке.
Вот так, всего за несколько дней, удалось не только существенно упростить нашей команде работу с полями, содержащими деньги во всех их проявлениях, но и сделать это правильно: не в кишках одного из наших микросервисов, и даже не в обособленном внутреннем артифакте, а там, где этому коду самое место: в оригинальной библиотеке, которой пользуются все, кому нужно работать с деньгами на эликсире.
Читатель, помни! Коммьюнити — штука важная и полезная, а тщательно отправляя пулл-реквесты — ты помогаешь обществу.