Как стать автором
Обновить

Как и когда пора начинать коммитить в OSS

Уровень сложностиСложный
Время на прочтение5 мин
Количество просмотров2K

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


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

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

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

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

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

Теги:
Хабы:
Всего голосов 4: ↑2 и ↓2+2
Комментарии9

Публикации

Истории

Ближайшие события