Repository files navigation

Introduction to Money SQL

Hex.pmHex.pmHex.pmHex.pm

Money_SQL implements a set of functions to store and retrieve data structured as a %Money{} type that is composed of an ISO 4217 currency code and a currency amount. See ex_money for details of using Money. Note that ex_money_sql depends on ex_money.

ex_money 6.0 and Localize {: .info}

From version 2.0, ex_money_sql requires ex_money ~> 6.0. ex_money 6.0 replaces the ex_cldr family of dependencies with the unified localize package and removes the compile-time CLDR backend system. Any MyApp.Cldr backend module, or configuration using :default_cldr_backend, should be removed. Locales are now configured through config :localize and accessed through the Localize module (for example Localize.put_locale/1). See the ex_money 6.0 migration guide for full details.

Postgrex JSON library {: .info}

ex_money_sql no longer declares jason as a dependency. Postgrex defaults to Jason for encoding json/jsonb columns, so configure a JSON library explicitly. Money.SQL.JSON is provided for this and works on every supported Elixir and OTP version: config :postgrex, :json_library, Money.SQL.JSON. It uses the Erlang :json module, which is built into OTP 27 and later; on OTP 26 add json_polyfill. Elixir 1.18's built-in JSON module and Jason also work. Postgrex captures this setting at compile time, so after changing it run mix deps.compile postgrex --force once.

Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning}

Please ensure that if you are using Ecto embedded schemas that include a money type that it is configured with the type Money.Ecto.Map.Type, NOTMoney.Ecto.Composite.Type.

In previous releases the misconfiguration of the type worked by accident. From ex_money_sql version 1.9.2 and subsequent releases an exception like ** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple will be raised. This is most likely an indication of type misconfiguration in an embedded schema.

Installation

ex_money_sql can be installed by adding ex_money_sql to your list of dependencies in mix.exs and then executing mix deps.get

defdepsdo[{:ex_money_sql,"~> 2.1"},...]end

Note that ex_money_sql is supported on Elixir 1.17 and later only.

Serializing to a Postgres database with Ecto

Money_SQL provides custom Ecto data types and a custom Postgres data type to provide serialization of Money.t types without losing precision whilst also maintaining the integrity of the {currency_code, amount} relationship. To serialise and retrieve money types from a database the following steps should be followed:

  1. First generate the migration to create the custom type:
mixmoney.gen.postgres.money_with_currency*creatingpriv/repo/migrations*creatingpriv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs
  1. Then migrate the database:
mixecto.migrate07:09:28.637[info]==Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0forward07:09:28.640[info]execute"CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)"07:09:28.647[info]==Migratedin0.0s
  1. Create your database migration with the new type (don't forget to mix ecto.migrate as well):
defmoduleMoneyTest.Repo.Migrations.CreateLedgerdouseEcto.Migrationdefchangedocreatetable(:ledgers)doadd:amount,:money_with_currencytimestamps()endendend
  1. Create your schema using the Money.Ecto.Composite.Type ecto type:
defmoduleLedgerdouseEcto.Schemaschema"ledgers"dofield:amount,Money.Ecto.Composite.Typetimestamps()endend
  1. Insert into the database:
iex>Repo.insert%Ledger{amount: Money.new(:USD,"100.00")}[debug] QUERY OK db=4.5msINSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3)[{"USD",#Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}]
  1. Retrieve from the database:
iex>Repo.allLedger[debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1msSELECT l0."amount", l0."inserted_at", l0."updated_at"FROM "ledgers" AS l0 [][%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, inserted_at: ~N[2017-02-21 00:15:40.979576], updated_at: ~N[2017-02-21 00:15:40.991391]}]

Serializing to a MySQL (or other non-Postgres) database with Ecto

Since MySQL does not support composite types, the :map type is used which in MySQL is implemented as a JSON column. The currency code and amount are serialised into this column.

defmodule MoneyTest.Repo.Migrations.CreateLedger do
use Ecto.Migration
def change do
create table(:ledgers) do
add :amount, :map
timestamps()
end
end
end

Create your schema using the Money.Ecto.Map.Type ecto type:

defmodule Ledger do
use Ecto.Schema
schema "ledgers" do
field :amount, Money.Ecto.Map.Type
timestamps()
end
end

Insert into the database:

iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)}
[debug] QUERY OK db=25.8ms
INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3)
RETURNING "id" [%{amount: "100", currency: "USD"},
{{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}]
{:ok,
%MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount: nil, amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}}

Retrieve from the database:

iex> Repo.all Ledger
[debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms
SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 []
[%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}]

Notes:

  1. In order to preserve precision of the decimal amount, the amount part of the %Money{} struct is serialised as a string. This is done because JSON serializes numeric values as either integer or float, neither of which would preserve precision of a decimal value.

  2. The precision of the serialized string value of amount is affected by the setting of Decimal.get_context. The default is 28 digits which should cater for your requirements.

  3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that CASTing the string value will restore some of that functionality. For example:

CAST(JSON_EXTRACT(amount_map, '$.amount') ASDECIMAL(20, 8)) AS amount;

Casting Money with Changesets

Then the schema type is Money.Ecto.Composite.Type then any option that is applicable to Money.parse/2 or Money.new/3 can be added to the field definition. These options will then be applied when Money.Ecto.Composite.Type.cast/2 or Money.Ecto.Composite.Type.load/3 is called. These functions are called with loading data from the database or when calling Ecto.Changeset.cast/3 is called. Typically this is useful to:

  1. Apply a default currency to a field input representing a money amount.
  2. Add formatting options to the returned t:Money that will be applied when calling Money.to_string/2

Consider the following example where a money amount will be considered in a default currency if no currency is applied:

Schema Example

The example below has three columns defined as Money.Ecto.Composite.Type.

  • :payroll will be cast as with the default currency :JPY if no currency field is provided. Note that if no :default_currency option is defined, the default currency will be derived from the current locale or configured :locale option.

  • :tax is defined with the option :fractional_digits. This option will be applied when formatting :tax with Money.to_string/2

  • :default is the t:Money that is used if the :value field is nil both when casting and when loading from the database.

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integertimestamps()enddefchangeset(organization,params\\%{})doorganization|>cast(params,[:payroll])endend

Embedded schema example

Embedded schemas are represented in Postgres as a jsobn data type which, in Elixir, is represented as a map. Therefore to include money fields in an embedded scheam, the Money.Ecto.Map.Type is used. Here is an example schema, extending the previous example:

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integerembeds_many:customers,Customerdofield:name,:stringfield:revenue,Money.Ecto.Map.Type,default: Money.new(:USD,0)endtimestamps()end

Changeset execution

In the following example, a default of :JPY currency (using our previous schema example) will be applied when casting the changeset.

iex>changeset=Organization.changeset(%Organization{},%{payroll: "0"})iex>changeset.changes.payroll==Money.new(:JPY,0)true

Postgres Database functions

Since the datatype used to store Money in Postgres is a composite type (called :money_with_currency), the standard aggregation functions like sum and average are not supported and the order_by clause doesn't perform as expected. Money provides mechanisms to provide these functions.

Plus operator +

Money defines a migration generator which, when migrated to the database with mix ecto.migrate, supports the + operator for :money_with_currency columns. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.plus_operator

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the + operator

iex>q=Ecto.Query.selectItem,[l],type(fragment("price + price"),l.price)#Ecto.Query<from l0 in Item, select: type(fragment("price + price"), l0.price)>iex>Repo.oneq[debug] QUERY OK source="items" db=5.6ms queue=0.5msSELECT price +price::money_with_currencyFROM "items" AS l0 [] #Money<:USD, 200>]

Aggregate functions

Support for some aggregate functions that operate on the Money.t/0 type. Currently those functions are:

  • sum
  • max
  • min
  • avg

Sum

Money_SQL provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing sum() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.sum_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function sum()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(sum(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(sum(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:sum,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Min and Max

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing min() and max() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.min_max_functions

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function min() or max()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(min(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(min(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:min,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Avg

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing avg() (average) aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.avg_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function avg()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(avg(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(avg(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTavg(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 100>]

The function Repo.aggregate/3 can also be used:

iex>Repo.aggregate(Item,:avg,:price)#Money<:USD, 100>

Note that similar to other aggregate functions, avg() requires all money values to have the same currency. Attempting to average money with different currencies will raise an exception.

Order_by with Money

Since :money_with_currency is a composite type, the default order_by results may surprise since the ordering is based upon the type structure, not the money amount. Postgres defines a means to access the components of a composite type and therefore sorting can be done in a more predictable fashion. For example:

# In this example we are decomposing the the composite column called# `price` and using the sub-field `amount` to perform the ordering.iex>q=fromlinItem,select: l.price,order_by: fragment("amount(price)")#Ecto.Query<from l in Item, order_by: [asc: fragment("amount(price)")],select: l.amount>iex>Repo.allq[debug] QUERY OK source="items" db=2.0msSELECT l0."price"FROM "items" AS l0 ORDER BY amount(price) [] [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>]

Note that the results may still be unexpected. The example above shows the correct ascending ordering by amount(price) however the ordering is not currency code aware and therefore mixed currencies will return a largely meaningless order.

About

Money functions for the serialization of a money data type in Elixir

Resources

Stars

34 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Introduction to Money SQL

Hex.pmHex.pmHex.pmHex.pm

Money_SQL implements a set of functions to store and retrieve data structured as a %Money{} type that is composed of an ISO 4217 currency code and a currency amount. See ex_money for details of using Money. Note that ex_money_sql depends on ex_money.

ex_money 6.0 and Localize {: .info}

From version 2.0, ex_money_sql requires ex_money ~> 6.0. ex_money 6.0 replaces the ex_cldr family of dependencies with the unified localize package and removes the compile-time CLDR backend system. Any MyApp.Cldr backend module, or configuration using :default_cldr_backend, should be removed. Locales are now configured through config :localize and accessed through the Localize module (for example Localize.put_locale/1). See the ex_money 6.0 migration guide for full details.

Postgrex JSON library {: .info}

ex_money_sql no longer declares jason as a dependency. Postgrex defaults to Jason for encoding json/jsonb columns, so configure a JSON library explicitly. Money.SQL.JSON is provided for this and works on every supported Elixir and OTP version: config :postgrex, :json_library, Money.SQL.JSON. It uses the Erlang :json module, which is built into OTP 27 and later; on OTP 26 add json_polyfill. Elixir 1.18's built-in JSON module and Jason also work. Postgrex captures this setting at compile time, so after changing it run mix deps.compile postgrex --force once.

Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning}

Please ensure that if you are using Ecto embedded schemas that include a money type that it is configured with the type Money.Ecto.Map.Type, NOTMoney.Ecto.Composite.Type.

In previous releases the misconfiguration of the type worked by accident. From ex_money_sql version 1.9.2 and subsequent releases an exception like ** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple will be raised. This is most likely an indication of type misconfiguration in an embedded schema.

Installation

ex_money_sql can be installed by adding ex_money_sql to your list of dependencies in mix.exs and then executing mix deps.get

defdepsdo[{:ex_money_sql,"~> 2.1"},...]end

Note that ex_money_sql is supported on Elixir 1.17 and later only.

Serializing to a Postgres database with Ecto

Money_SQL provides custom Ecto data types and a custom Postgres data type to provide serialization of Money.t types without losing precision whilst also maintaining the integrity of the {currency_code, amount} relationship. To serialise and retrieve money types from a database the following steps should be followed:

  1. First generate the migration to create the custom type:
mixmoney.gen.postgres.money_with_currency*creatingpriv/repo/migrations*creatingpriv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs
  1. Then migrate the database:
mixecto.migrate07:09:28.637[info]==Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0forward07:09:28.640[info]execute"CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)"07:09:28.647[info]==Migratedin0.0s
  1. Create your database migration with the new type (don't forget to mix ecto.migrate as well):
defmoduleMoneyTest.Repo.Migrations.CreateLedgerdouseEcto.Migrationdefchangedocreatetable(:ledgers)doadd:amount,:money_with_currencytimestamps()endendend
  1. Create your schema using the Money.Ecto.Composite.Type ecto type:
defmoduleLedgerdouseEcto.Schemaschema"ledgers"dofield:amount,Money.Ecto.Composite.Typetimestamps()endend
  1. Insert into the database:
iex>Repo.insert%Ledger{amount: Money.new(:USD,"100.00")}[debug] QUERY OK db=4.5msINSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3)[{"USD",#Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}]
  1. Retrieve from the database:
iex>Repo.allLedger[debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1msSELECT l0."amount", l0."inserted_at", l0."updated_at"FROM "ledgers" AS l0 [][%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, inserted_at: ~N[2017-02-21 00:15:40.979576], updated_at: ~N[2017-02-21 00:15:40.991391]}]

Serializing to a MySQL (or other non-Postgres) database with Ecto

Since MySQL does not support composite types, the :map type is used which in MySQL is implemented as a JSON column. The currency code and amount are serialised into this column.

defmodule MoneyTest.Repo.Migrations.CreateLedger do
use Ecto.Migration
def change do
create table(:ledgers) do
add :amount, :map
timestamps()
end
end
end

Create your schema using the Money.Ecto.Map.Type ecto type:

defmodule Ledger do
use Ecto.Schema
schema "ledgers" do
field :amount, Money.Ecto.Map.Type
timestamps()
end
end

Insert into the database:

iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)}
[debug] QUERY OK db=25.8ms
INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3)
RETURNING "id" [%{amount: "100", currency: "USD"},
{{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}]
{:ok,
%MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount: nil, amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}}

Retrieve from the database:

iex> Repo.all Ledger
[debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms
SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 []
[%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}]

Notes:

  1. In order to preserve precision of the decimal amount, the amount part of the %Money{} struct is serialised as a string. This is done because JSON serializes numeric values as either integer or float, neither of which would preserve precision of a decimal value.

  2. The precision of the serialized string value of amount is affected by the setting of Decimal.get_context. The default is 28 digits which should cater for your requirements.

  3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that CASTing the string value will restore some of that functionality. For example:

CAST(JSON_EXTRACT(amount_map, '$.amount') ASDECIMAL(20, 8)) AS amount;

Casting Money with Changesets

Then the schema type is Money.Ecto.Composite.Type then any option that is applicable to Money.parse/2 or Money.new/3 can be added to the field definition. These options will then be applied when Money.Ecto.Composite.Type.cast/2 or Money.Ecto.Composite.Type.load/3 is called. These functions are called with loading data from the database or when calling Ecto.Changeset.cast/3 is called. Typically this is useful to:

  1. Apply a default currency to a field input representing a money amount.
  2. Add formatting options to the returned t:Money that will be applied when calling Money.to_string/2

Consider the following example where a money amount will be considered in a default currency if no currency is applied:

Schema Example

The example below has three columns defined as Money.Ecto.Composite.Type.

  • :payroll will be cast as with the default currency :JPY if no currency field is provided. Note that if no :default_currency option is defined, the default currency will be derived from the current locale or configured :locale option.

  • :tax is defined with the option :fractional_digits. This option will be applied when formatting :tax with Money.to_string/2

  • :default is the t:Money that is used if the :value field is nil both when casting and when loading from the database.

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integertimestamps()enddefchangeset(organization,params\\%{})doorganization|>cast(params,[:payroll])endend

Embedded schema example

Embedded schemas are represented in Postgres as a jsobn data type which, in Elixir, is represented as a map. Therefore to include money fields in an embedded scheam, the Money.Ecto.Map.Type is used. Here is an example schema, extending the previous example:

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integerembeds_many:customers,Customerdofield:name,:stringfield:revenue,Money.Ecto.Map.Type,default: Money.new(:USD,0)endtimestamps()end

Changeset execution

In the following example, a default of :JPY currency (using our previous schema example) will be applied when casting the changeset.

iex>changeset=Organization.changeset(%Organization{},%{payroll: "0"})iex>changeset.changes.payroll==Money.new(:JPY,0)true

Postgres Database functions

Since the datatype used to store Money in Postgres is a composite type (called :money_with_currency), the standard aggregation functions like sum and average are not supported and the order_by clause doesn't perform as expected. Money provides mechanisms to provide these functions.

Plus operator +

Money defines a migration generator which, when migrated to the database with mix ecto.migrate, supports the + operator for :money_with_currency columns. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.plus_operator

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the + operator

iex>q=Ecto.Query.selectItem,[l],type(fragment("price + price"),l.price)#Ecto.Query<from l0 in Item, select: type(fragment("price + price"), l0.price)>iex>Repo.oneq[debug] QUERY OK source="items" db=5.6ms queue=0.5msSELECT price +price::money_with_currencyFROM "items" AS l0 [] #Money<:USD, 200>]

Aggregate functions

Support for some aggregate functions that operate on the Money.t/0 type. Currently those functions are:

  • sum
  • max
  • min
  • avg

Sum

Money_SQL provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing sum() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.sum_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function sum()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(sum(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(sum(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:sum,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Min and Max

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing min() and max() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.min_max_functions

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function min() or max()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(min(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(min(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:min,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Avg

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing avg() (average) aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.avg_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function avg()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(avg(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(avg(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTavg(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 100>]

The function Repo.aggregate/3 can also be used:

iex>Repo.aggregate(Item,:avg,:price)#Money<:USD, 100>

Note that similar to other aggregate functions, avg() requires all money values to have the same currency. Attempting to average money with different currencies will raise an exception.

Order_by with Money

Since :money_with_currency is a composite type, the default order_by results may surprise since the ordering is based upon the type structure, not the money amount. Postgres defines a means to access the components of a composite type and therefore sorting can be done in a more predictable fashion. For example:

# In this example we are decomposing the the composite column called# `price` and using the sub-field `amount` to perform the ordering.iex>q=fromlinItem,select: l.price,order_by: fragment("amount(price)")#Ecto.Query<from l in Item, order_by: [asc: fragment("amount(price)")],select: l.amount>iex>Repo.allq[debug] QUERY OK source="items" db=2.0msSELECT l0."price"FROM "items" AS l0 ORDER BY amount(price) [] [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>]

Note that the results may still be unexpected. The example above shows the correct ascending ordering by amount(price) however the ordering is not currency code aware and therefore mixed currencies will return a largely meaningless order.

About

Money functions for the serialization of a money data type in Elixir

Resources

Stars

34 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Introduction to Money SQL

Hex.pmHex.pmHex.pmHex.pm

Money_SQL implements a set of functions to store and retrieve data structured as a %Money{} type that is composed of an ISO 4217 currency code and a currency amount. See ex_money for details of using Money. Note that ex_money_sql depends on ex_money.

ex_money 6.0 and Localize {: .info}

From version 2.0, ex_money_sql requires ex_money ~> 6.0. ex_money 6.0 replaces the ex_cldr family of dependencies with the unified localize package and removes the compile-time CLDR backend system. Any MyApp.Cldr backend module, or configuration using :default_cldr_backend, should be removed. Locales are now configured through config :localize and accessed through the Localize module (for example Localize.put_locale/1). See the ex_money 6.0 migration guide for full details.

Postgrex JSON library {: .info}

ex_money_sql no longer declares jason as a dependency. Postgrex defaults to Jason for encoding json/jsonb columns, so configure a JSON library explicitly. Money.SQL.JSON is provided for this and works on every supported Elixir and OTP version: config :postgrex, :json_library, Money.SQL.JSON. It uses the Erlang :json module, which is built into OTP 27 and later; on OTP 26 add json_polyfill. Elixir 1.18's built-in JSON module and Jason also work. Postgrex captures this setting at compile time, so after changing it run mix deps.compile postgrex --force once.

Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning}

Please ensure that if you are using Ecto embedded schemas that include a money type that it is configured with the type Money.Ecto.Map.Type, NOTMoney.Ecto.Composite.Type.

In previous releases the misconfiguration of the type worked by accident. From ex_money_sql version 1.9.2 and subsequent releases an exception like ** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple will be raised. This is most likely an indication of type misconfiguration in an embedded schema.

Installation

ex_money_sql can be installed by adding ex_money_sql to your list of dependencies in mix.exs and then executing mix deps.get

defdepsdo[{:ex_money_sql,"~> 2.1"},...]end

Note that ex_money_sql is supported on Elixir 1.17 and later only.

Serializing to a Postgres database with Ecto

Money_SQL provides custom Ecto data types and a custom Postgres data type to provide serialization of Money.t types without losing precision whilst also maintaining the integrity of the {currency_code, amount} relationship. To serialise and retrieve money types from a database the following steps should be followed:

  1. First generate the migration to create the custom type:
mixmoney.gen.postgres.money_with_currency*creatingpriv/repo/migrations*creatingpriv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs
  1. Then migrate the database:
mixecto.migrate07:09:28.637[info]==Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0forward07:09:28.640[info]execute"CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)"07:09:28.647[info]==Migratedin0.0s
  1. Create your database migration with the new type (don't forget to mix ecto.migrate as well):
defmoduleMoneyTest.Repo.Migrations.CreateLedgerdouseEcto.Migrationdefchangedocreatetable(:ledgers)doadd:amount,:money_with_currencytimestamps()endendend
  1. Create your schema using the Money.Ecto.Composite.Type ecto type:
defmoduleLedgerdouseEcto.Schemaschema"ledgers"dofield:amount,Money.Ecto.Composite.Typetimestamps()endend
  1. Insert into the database:
iex>Repo.insert%Ledger{amount: Money.new(:USD,"100.00")}[debug] QUERY OK db=4.5msINSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3)[{"USD",#Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}]
  1. Retrieve from the database:
iex>Repo.allLedger[debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1msSELECT l0."amount", l0."inserted_at", l0."updated_at"FROM "ledgers" AS l0 [][%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, inserted_at: ~N[2017-02-21 00:15:40.979576], updated_at: ~N[2017-02-21 00:15:40.991391]}]

Serializing to a MySQL (or other non-Postgres) database with Ecto

Since MySQL does not support composite types, the :map type is used which in MySQL is implemented as a JSON column. The currency code and amount are serialised into this column.

defmodule MoneyTest.Repo.Migrations.CreateLedger do
use Ecto.Migration
def change do
create table(:ledgers) do
add :amount, :map
timestamps()
end
end
end

Create your schema using the Money.Ecto.Map.Type ecto type:

defmodule Ledger do
use Ecto.Schema
schema "ledgers" do
field :amount, Money.Ecto.Map.Type
timestamps()
end
end

Insert into the database:

iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)}
[debug] QUERY OK db=25.8ms
INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3)
RETURNING "id" [%{amount: "100", currency: "USD"},
{{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}]
{:ok,
%MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount: nil, amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}}

Retrieve from the database:

iex> Repo.all Ledger
[debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms
SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 []
[%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}]

Notes:

  1. In order to preserve precision of the decimal amount, the amount part of the %Money{} struct is serialised as a string. This is done because JSON serializes numeric values as either integer or float, neither of which would preserve precision of a decimal value.

  2. The precision of the serialized string value of amount is affected by the setting of Decimal.get_context. The default is 28 digits which should cater for your requirements.

  3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that CASTing the string value will restore some of that functionality. For example:

CAST(JSON_EXTRACT(amount_map, '$.amount') ASDECIMAL(20, 8)) AS amount;

Casting Money with Changesets

Then the schema type is Money.Ecto.Composite.Type then any option that is applicable to Money.parse/2 or Money.new/3 can be added to the field definition. These options will then be applied when Money.Ecto.Composite.Type.cast/2 or Money.Ecto.Composite.Type.load/3 is called. These functions are called with loading data from the database or when calling Ecto.Changeset.cast/3 is called. Typically this is useful to:

  1. Apply a default currency to a field input representing a money amount.
  2. Add formatting options to the returned t:Money that will be applied when calling Money.to_string/2

Consider the following example where a money amount will be considered in a default currency if no currency is applied:

Schema Example

The example below has three columns defined as Money.Ecto.Composite.Type.

  • :payroll will be cast as with the default currency :JPY if no currency field is provided. Note that if no :default_currency option is defined, the default currency will be derived from the current locale or configured :locale option.

  • :tax is defined with the option :fractional_digits. This option will be applied when formatting :tax with Money.to_string/2

  • :default is the t:Money that is used if the :value field is nil both when casting and when loading from the database.

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integertimestamps()enddefchangeset(organization,params\\%{})doorganization|>cast(params,[:payroll])endend

Embedded schema example

Embedded schemas are represented in Postgres as a jsobn data type which, in Elixir, is represented as a map. Therefore to include money fields in an embedded scheam, the Money.Ecto.Map.Type is used. Here is an example schema, extending the previous example:

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integerembeds_many:customers,Customerdofield:name,:stringfield:revenue,Money.Ecto.Map.Type,default: Money.new(:USD,0)endtimestamps()end

Changeset execution

In the following example, a default of :JPY currency (using our previous schema example) will be applied when casting the changeset.

iex>changeset=Organization.changeset(%Organization{},%{payroll: "0"})iex>changeset.changes.payroll==Money.new(:JPY,0)true

Postgres Database functions

Since the datatype used to store Money in Postgres is a composite type (called :money_with_currency), the standard aggregation functions like sum and average are not supported and the order_by clause doesn't perform as expected. Money provides mechanisms to provide these functions.

Plus operator +

Money defines a migration generator which, when migrated to the database with mix ecto.migrate, supports the + operator for :money_with_currency columns. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.plus_operator

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the + operator

iex>q=Ecto.Query.selectItem,[l],type(fragment("price + price"),l.price)#Ecto.Query<from l0 in Item, select: type(fragment("price + price"), l0.price)>iex>Repo.oneq[debug] QUERY OK source="items" db=5.6ms queue=0.5msSELECT price +price::money_with_currencyFROM "items" AS l0 [] #Money<:USD, 200>]

Aggregate functions

Support for some aggregate functions that operate on the Money.t/0 type. Currently those functions are:

  • sum
  • max
  • min
  • avg

Sum

Money_SQL provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing sum() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.sum_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function sum()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(sum(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(sum(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:sum,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Min and Max

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing min() and max() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.min_max_functions

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function min() or max()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(min(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(min(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:min,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Avg

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing avg() (average) aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.avg_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function avg()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(avg(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(avg(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTavg(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 100>]

The function Repo.aggregate/3 can also be used:

iex>Repo.aggregate(Item,:avg,:price)#Money<:USD, 100>

Note that similar to other aggregate functions, avg() requires all money values to have the same currency. Attempting to average money with different currencies will raise an exception.

Order_by with Money

Since :money_with_currency is a composite type, the default order_by results may surprise since the ordering is based upon the type structure, not the money amount. Postgres defines a means to access the components of a composite type and therefore sorting can be done in a more predictable fashion. For example:

# In this example we are decomposing the the composite column called# `price` and using the sub-field `amount` to perform the ordering.iex>q=fromlinItem,select: l.price,order_by: fragment("amount(price)")#Ecto.Query<from l in Item, order_by: [asc: fragment("amount(price)")],select: l.amount>iex>Repo.allq[debug] QUERY OK source="items" db=2.0msSELECT l0."price"FROM "items" AS l0 ORDER BY amount(price) [] [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>]

Note that the results may still be unexpected. The example above shows the correct ascending ordering by amount(price) however the ordering is not currency code aware and therefore mixed currencies will return a largely meaningless order.

About

Money functions for the serialization of a money data type in Elixir

Resources

Stars

34 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Introduction to Money SQL

Hex.pmHex.pmHex.pmHex.pm

Money_SQL implements a set of functions to store and retrieve data structured as a %Money{} type that is composed of an ISO 4217 currency code and a currency amount. See ex_money for details of using Money. Note that ex_money_sql depends on ex_money.

ex_money 6.0 and Localize {: .info}

From version 2.0, ex_money_sql requires ex_money ~> 6.0. ex_money 6.0 replaces the ex_cldr family of dependencies with the unified localize package and removes the compile-time CLDR backend system. Any MyApp.Cldr backend module, or configuration using :default_cldr_backend, should be removed. Locales are now configured through config :localize and accessed through the Localize module (for example Localize.put_locale/1). See the ex_money 6.0 migration guide for full details.

Postgrex JSON library {: .info}

ex_money_sql no longer declares jason as a dependency. Postgrex defaults to Jason for encoding json/jsonb columns, so configure a JSON library explicitly. Money.SQL.JSON is provided for this and works on every supported Elixir and OTP version: config :postgrex, :json_library, Money.SQL.JSON. It uses the Erlang :json module, which is built into OTP 27 and later; on OTP 26 add json_polyfill. Elixir 1.18's built-in JSON module and Jason also work. Postgrex captures this setting at compile time, so after changing it run mix deps.compile postgrex --force once.

Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning}

Please ensure that if you are using Ecto embedded schemas that include a money type that it is configured with the type Money.Ecto.Map.Type, NOTMoney.Ecto.Composite.Type.

In previous releases the misconfiguration of the type worked by accident. From ex_money_sql version 1.9.2 and subsequent releases an exception like ** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple will be raised. This is most likely an indication of type misconfiguration in an embedded schema.

Installation

ex_money_sql can be installed by adding ex_money_sql to your list of dependencies in mix.exs and then executing mix deps.get

defdepsdo[{:ex_money_sql,"~> 2.1"},...]end

Note that ex_money_sql is supported on Elixir 1.17 and later only.

Serializing to a Postgres database with Ecto

Money_SQL provides custom Ecto data types and a custom Postgres data type to provide serialization of Money.t types without losing precision whilst also maintaining the integrity of the {currency_code, amount} relationship. To serialise and retrieve money types from a database the following steps should be followed:

  1. First generate the migration to create the custom type:
mixmoney.gen.postgres.money_with_currency*creatingpriv/repo/migrations*creatingpriv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs
  1. Then migrate the database:
mixecto.migrate07:09:28.637[info]==Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0forward07:09:28.640[info]execute"CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)"07:09:28.647[info]==Migratedin0.0s
  1. Create your database migration with the new type (don't forget to mix ecto.migrate as well):
defmoduleMoneyTest.Repo.Migrations.CreateLedgerdouseEcto.Migrationdefchangedocreatetable(:ledgers)doadd:amount,:money_with_currencytimestamps()endendend
  1. Create your schema using the Money.Ecto.Composite.Type ecto type:
defmoduleLedgerdouseEcto.Schemaschema"ledgers"dofield:amount,Money.Ecto.Composite.Typetimestamps()endend
  1. Insert into the database:
iex>Repo.insert%Ledger{amount: Money.new(:USD,"100.00")}[debug] QUERY OK db=4.5msINSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3)[{"USD",#Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}]
  1. Retrieve from the database:
iex>Repo.allLedger[debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1msSELECT l0."amount", l0."inserted_at", l0."updated_at"FROM "ledgers" AS l0 [][%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, inserted_at: ~N[2017-02-21 00:15:40.979576], updated_at: ~N[2017-02-21 00:15:40.991391]}]

Serializing to a MySQL (or other non-Postgres) database with Ecto

Since MySQL does not support composite types, the :map type is used which in MySQL is implemented as a JSON column. The currency code and amount are serialised into this column.

defmodule MoneyTest.Repo.Migrations.CreateLedger do
use Ecto.Migration
def change do
create table(:ledgers) do
add :amount, :map
timestamps()
end
end
end

Create your schema using the Money.Ecto.Map.Type ecto type:

defmodule Ledger do
use Ecto.Schema
schema "ledgers" do
field :amount, Money.Ecto.Map.Type
timestamps()
end
end

Insert into the database:

iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)}
[debug] QUERY OK db=25.8ms
INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3)
RETURNING "id" [%{amount: "100", currency: "USD"},
{{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}]
{:ok,
%MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount: nil, amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}}

Retrieve from the database:

iex> Repo.all Ledger
[debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms
SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 []
[%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}]

Notes:

  1. In order to preserve precision of the decimal amount, the amount part of the %Money{} struct is serialised as a string. This is done because JSON serializes numeric values as either integer or float, neither of which would preserve precision of a decimal value.

  2. The precision of the serialized string value of amount is affected by the setting of Decimal.get_context. The default is 28 digits which should cater for your requirements.

  3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that CASTing the string value will restore some of that functionality. For example:

CAST(JSON_EXTRACT(amount_map, '$.amount') ASDECIMAL(20, 8)) AS amount;

Casting Money with Changesets

Then the schema type is Money.Ecto.Composite.Type then any option that is applicable to Money.parse/2 or Money.new/3 can be added to the field definition. These options will then be applied when Money.Ecto.Composite.Type.cast/2 or Money.Ecto.Composite.Type.load/3 is called. These functions are called with loading data from the database or when calling Ecto.Changeset.cast/3 is called. Typically this is useful to:

  1. Apply a default currency to a field input representing a money amount.
  2. Add formatting options to the returned t:Money that will be applied when calling Money.to_string/2

Consider the following example where a money amount will be considered in a default currency if no currency is applied:

Schema Example

The example below has three columns defined as Money.Ecto.Composite.Type.

  • :payroll will be cast as with the default currency :JPY if no currency field is provided. Note that if no :default_currency option is defined, the default currency will be derived from the current locale or configured :locale option.

  • :tax is defined with the option :fractional_digits. This option will be applied when formatting :tax with Money.to_string/2

  • :default is the t:Money that is used if the :value field is nil both when casting and when loading from the database.

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integertimestamps()enddefchangeset(organization,params\\%{})doorganization|>cast(params,[:payroll])endend

Embedded schema example

Embedded schemas are represented in Postgres as a jsobn data type which, in Elixir, is represented as a map. Therefore to include money fields in an embedded scheam, the Money.Ecto.Map.Type is used. Here is an example schema, extending the previous example:

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integerembeds_many:customers,Customerdofield:name,:stringfield:revenue,Money.Ecto.Map.Type,default: Money.new(:USD,0)endtimestamps()end

Changeset execution

In the following example, a default of :JPY currency (using our previous schema example) will be applied when casting the changeset.

iex>changeset=Organization.changeset(%Organization{},%{payroll: "0"})iex>changeset.changes.payroll==Money.new(:JPY,0)true

Postgres Database functions

Since the datatype used to store Money in Postgres is a composite type (called :money_with_currency), the standard aggregation functions like sum and average are not supported and the order_by clause doesn't perform as expected. Money provides mechanisms to provide these functions.

Plus operator +

Money defines a migration generator which, when migrated to the database with mix ecto.migrate, supports the + operator for :money_with_currency columns. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.plus_operator

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the + operator

iex>q=Ecto.Query.selectItem,[l],type(fragment("price + price"),l.price)#Ecto.Query<from l0 in Item, select: type(fragment("price + price"), l0.price)>iex>Repo.oneq[debug] QUERY OK source="items" db=5.6ms queue=0.5msSELECT price +price::money_with_currencyFROM "items" AS l0 [] #Money<:USD, 200>]

Aggregate functions

Support for some aggregate functions that operate on the Money.t/0 type. Currently those functions are:

  • sum
  • max
  • min
  • avg

Sum

Money_SQL provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing sum() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.sum_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function sum()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(sum(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(sum(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:sum,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Min and Max

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing min() and max() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.min_max_functions

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function min() or max()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(min(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(min(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:min,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Avg

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing avg() (average) aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.avg_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function avg()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(avg(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(avg(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTavg(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 100>]

The function Repo.aggregate/3 can also be used:

iex>Repo.aggregate(Item,:avg,:price)#Money<:USD, 100>

Note that similar to other aggregate functions, avg() requires all money values to have the same currency. Attempting to average money with different currencies will raise an exception.

Order_by with Money

Since :money_with_currency is a composite type, the default order_by results may surprise since the ordering is based upon the type structure, not the money amount. Postgres defines a means to access the components of a composite type and therefore sorting can be done in a more predictable fashion. For example:

# In this example we are decomposing the the composite column called# `price` and using the sub-field `amount` to perform the ordering.iex>q=fromlinItem,select: l.price,order_by: fragment("amount(price)")#Ecto.Query<from l in Item, order_by: [asc: fragment("amount(price)")],select: l.amount>iex>Repo.allq[debug] QUERY OK source="items" db=2.0msSELECT l0."price"FROM "items" AS l0 ORDER BY amount(price) [] [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>]

Note that the results may still be unexpected. The example above shows the correct ascending ordering by amount(price) however the ordering is not currency code aware and therefore mixed currencies will return a largely meaningless order.

About

Money functions for the serialization of a money data type in Elixir

Resources

Stars

34 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Introduction to Money SQL

Hex.pmHex.pmHex.pmHex.pm

Money_SQL implements a set of functions to store and retrieve data structured as a %Money{} type that is composed of an ISO 4217 currency code and a currency amount. See ex_money for details of using Money. Note that ex_money_sql depends on ex_money.

ex_money 6.0 and Localize {: .info}

From version 2.0, ex_money_sql requires ex_money ~> 6.0. ex_money 6.0 replaces the ex_cldr family of dependencies with the unified localize package and removes the compile-time CLDR backend system. Any MyApp.Cldr backend module, or configuration using :default_cldr_backend, should be removed. Locales are now configured through config :localize and accessed through the Localize module (for example Localize.put_locale/1). See the ex_money 6.0 migration guide for full details.

Postgrex JSON library {: .info}

ex_money_sql no longer declares jason as a dependency. Postgrex defaults to Jason for encoding json/jsonb columns, so configure a JSON library explicitly. Money.SQL.JSON is provided for this and works on every supported Elixir and OTP version: config :postgrex, :json_library, Money.SQL.JSON. It uses the Erlang :json module, which is built into OTP 27 and later; on OTP 26 add json_polyfill. Elixir 1.18's built-in JSON module and Jason also work. Postgrex captures this setting at compile time, so after changing it run mix deps.compile postgrex --force once.

Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning}

Please ensure that if you are using Ecto embedded schemas that include a money type that it is configured with the type Money.Ecto.Map.Type, NOTMoney.Ecto.Composite.Type.

In previous releases the misconfiguration of the type worked by accident. From ex_money_sql version 1.9.2 and subsequent releases an exception like ** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple will be raised. This is most likely an indication of type misconfiguration in an embedded schema.

Installation

ex_money_sql can be installed by adding ex_money_sql to your list of dependencies in mix.exs and then executing mix deps.get

defdepsdo[{:ex_money_sql,"~> 2.1"},...]end

Note that ex_money_sql is supported on Elixir 1.17 and later only.

Serializing to a Postgres database with Ecto

Money_SQL provides custom Ecto data types and a custom Postgres data type to provide serialization of Money.t types without losing precision whilst also maintaining the integrity of the {currency_code, amount} relationship. To serialise and retrieve money types from a database the following steps should be followed:

  1. First generate the migration to create the custom type:
mixmoney.gen.postgres.money_with_currency*creatingpriv/repo/migrations*creatingpriv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs
  1. Then migrate the database:
mixecto.migrate07:09:28.637[info]==Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0forward07:09:28.640[info]execute"CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)"07:09:28.647[info]==Migratedin0.0s
  1. Create your database migration with the new type (don't forget to mix ecto.migrate as well):
defmoduleMoneyTest.Repo.Migrations.CreateLedgerdouseEcto.Migrationdefchangedocreatetable(:ledgers)doadd:amount,:money_with_currencytimestamps()endendend
  1. Create your schema using the Money.Ecto.Composite.Type ecto type:
defmoduleLedgerdouseEcto.Schemaschema"ledgers"dofield:amount,Money.Ecto.Composite.Typetimestamps()endend
  1. Insert into the database:
iex>Repo.insert%Ledger{amount: Money.new(:USD,"100.00")}[debug] QUERY OK db=4.5msINSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3)[{"USD",#Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}]
  1. Retrieve from the database:
iex>Repo.allLedger[debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1msSELECT l0."amount", l0."inserted_at", l0."updated_at"FROM "ledgers" AS l0 [][%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, inserted_at: ~N[2017-02-21 00:15:40.979576], updated_at: ~N[2017-02-21 00:15:40.991391]}]

Serializing to a MySQL (or other non-Postgres) database with Ecto

Since MySQL does not support composite types, the :map type is used which in MySQL is implemented as a JSON column. The currency code and amount are serialised into this column.

defmodule MoneyTest.Repo.Migrations.CreateLedger do
use Ecto.Migration
def change do
create table(:ledgers) do
add :amount, :map
timestamps()
end
end
end

Create your schema using the Money.Ecto.Map.Type ecto type:

defmodule Ledger do
use Ecto.Schema
schema "ledgers" do
field :amount, Money.Ecto.Map.Type
timestamps()
end
end

Insert into the database:

iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)}
[debug] QUERY OK db=25.8ms
INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3)
RETURNING "id" [%{amount: "100", currency: "USD"},
{{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}]
{:ok,
%MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount: nil, amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}}

Retrieve from the database:

iex> Repo.all Ledger
[debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms
SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 []
[%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}]

Notes:

  1. In order to preserve precision of the decimal amount, the amount part of the %Money{} struct is serialised as a string. This is done because JSON serializes numeric values as either integer or float, neither of which would preserve precision of a decimal value.

  2. The precision of the serialized string value of amount is affected by the setting of Decimal.get_context. The default is 28 digits which should cater for your requirements.

  3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that CASTing the string value will restore some of that functionality. For example:

CAST(JSON_EXTRACT(amount_map, '$.amount') ASDECIMAL(20, 8)) AS amount;

Casting Money with Changesets

Then the schema type is Money.Ecto.Composite.Type then any option that is applicable to Money.parse/2 or Money.new/3 can be added to the field definition. These options will then be applied when Money.Ecto.Composite.Type.cast/2 or Money.Ecto.Composite.Type.load/3 is called. These functions are called with loading data from the database or when calling Ecto.Changeset.cast/3 is called. Typically this is useful to:

  1. Apply a default currency to a field input representing a money amount.
  2. Add formatting options to the returned t:Money that will be applied when calling Money.to_string/2

Consider the following example where a money amount will be considered in a default currency if no currency is applied:

Schema Example

The example below has three columns defined as Money.Ecto.Composite.Type.

  • :payroll will be cast as with the default currency :JPY if no currency field is provided. Note that if no :default_currency option is defined, the default currency will be derived from the current locale or configured :locale option.

  • :tax is defined with the option :fractional_digits. This option will be applied when formatting :tax with Money.to_string/2

  • :default is the t:Money that is used if the :value field is nil both when casting and when loading from the database.

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integertimestamps()enddefchangeset(organization,params\\%{})doorganization|>cast(params,[:payroll])endend

Embedded schema example

Embedded schemas are represented in Postgres as a jsobn data type which, in Elixir, is represented as a map. Therefore to include money fields in an embedded scheam, the Money.Ecto.Map.Type is used. Here is an example schema, extending the previous example:

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integerembeds_many:customers,Customerdofield:name,:stringfield:revenue,Money.Ecto.Map.Type,default: Money.new(:USD,0)endtimestamps()end

Changeset execution

In the following example, a default of :JPY currency (using our previous schema example) will be applied when casting the changeset.

iex>changeset=Organization.changeset(%Organization{},%{payroll: "0"})iex>changeset.changes.payroll==Money.new(:JPY,0)true

Postgres Database functions

Since the datatype used to store Money in Postgres is a composite type (called :money_with_currency), the standard aggregation functions like sum and average are not supported and the order_by clause doesn't perform as expected. Money provides mechanisms to provide these functions.

Plus operator +

Money defines a migration generator which, when migrated to the database with mix ecto.migrate, supports the + operator for :money_with_currency columns. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.plus_operator

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the + operator

iex>q=Ecto.Query.selectItem,[l],type(fragment("price + price"),l.price)#Ecto.Query<from l0 in Item, select: type(fragment("price + price"), l0.price)>iex>Repo.oneq[debug] QUERY OK source="items" db=5.6ms queue=0.5msSELECT price +price::money_with_currencyFROM "items" AS l0 [] #Money<:USD, 200>]

Aggregate functions

Support for some aggregate functions that operate on the Money.t/0 type. Currently those functions are:

  • sum
  • max
  • min
  • avg

Sum

Money_SQL provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing sum() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.sum_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function sum()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(sum(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(sum(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:sum,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Min and Max

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing min() and max() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.min_max_functions

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function min() or max()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(min(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(min(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:min,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Avg

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing avg() (average) aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.avg_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function avg()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(avg(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(avg(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTavg(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 100>]

The function Repo.aggregate/3 can also be used:

iex>Repo.aggregate(Item,:avg,:price)#Money<:USD, 100>

Note that similar to other aggregate functions, avg() requires all money values to have the same currency. Attempting to average money with different currencies will raise an exception.

Order_by with Money

Since :money_with_currency is a composite type, the default order_by results may surprise since the ordering is based upon the type structure, not the money amount. Postgres defines a means to access the components of a composite type and therefore sorting can be done in a more predictable fashion. For example:

# In this example we are decomposing the the composite column called# `price` and using the sub-field `amount` to perform the ordering.iex>q=fromlinItem,select: l.price,order_by: fragment("amount(price)")#Ecto.Query<from l in Item, order_by: [asc: fragment("amount(price)")],select: l.amount>iex>Repo.allq[debug] QUERY OK source="items" db=2.0msSELECT l0."price"FROM "items" AS l0 ORDER BY amount(price) [] [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>]

Note that the results may still be unexpected. The example above shows the correct ascending ordering by amount(price) however the ordering is not currency code aware and therefore mixed currencies will return a largely meaningless order.

About

Money functions for the serialization of a money data type in Elixir

Resources

Stars

34 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Introduction to Money SQL

Hex.pmHex.pmHex.pmHex.pm

Money_SQL implements a set of functions to store and retrieve data structured as a %Money{} type that is composed of an ISO 4217 currency code and a currency amount. See ex_money for details of using Money. Note that ex_money_sql depends on ex_money.

ex_money 6.0 and Localize {: .info}

From version 2.0, ex_money_sql requires ex_money ~> 6.0. ex_money 6.0 replaces the ex_cldr family of dependencies with the unified localize package and removes the compile-time CLDR backend system. Any MyApp.Cldr backend module, or configuration using :default_cldr_backend, should be removed. Locales are now configured through config :localize and accessed through the Localize module (for example Localize.put_locale/1). See the ex_money 6.0 migration guide for full details.

Postgrex JSON library {: .info}

ex_money_sql no longer declares jason as a dependency. Postgrex defaults to Jason for encoding json/jsonb columns, so configure a JSON library explicitly. Money.SQL.JSON is provided for this and works on every supported Elixir and OTP version: config :postgrex, :json_library, Money.SQL.JSON. It uses the Erlang :json module, which is built into OTP 27 and later; on OTP 26 add json_polyfill. Elixir 1.18's built-in JSON module and Jason also work. Postgrex captures this setting at compile time, so after changing it run mix deps.compile postgrex --force once.

Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning}

Please ensure that if you are using Ecto embedded schemas that include a money type that it is configured with the type Money.Ecto.Map.Type, NOTMoney.Ecto.Composite.Type.

In previous releases the misconfiguration of the type worked by accident. From ex_money_sql version 1.9.2 and subsequent releases an exception like ** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple will be raised. This is most likely an indication of type misconfiguration in an embedded schema.

Installation

ex_money_sql can be installed by adding ex_money_sql to your list of dependencies in mix.exs and then executing mix deps.get

defdepsdo[{:ex_money_sql,"~> 2.1"},...]end

Note that ex_money_sql is supported on Elixir 1.17 and later only.

Serializing to a Postgres database with Ecto

Money_SQL provides custom Ecto data types and a custom Postgres data type to provide serialization of Money.t types without losing precision whilst also maintaining the integrity of the {currency_code, amount} relationship. To serialise and retrieve money types from a database the following steps should be followed:

  1. First generate the migration to create the custom type:
mixmoney.gen.postgres.money_with_currency*creatingpriv/repo/migrations*creatingpriv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs
  1. Then migrate the database:
mixecto.migrate07:09:28.637[info]==Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0forward07:09:28.640[info]execute"CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)"07:09:28.647[info]==Migratedin0.0s
  1. Create your database migration with the new type (don't forget to mix ecto.migrate as well):
defmoduleMoneyTest.Repo.Migrations.CreateLedgerdouseEcto.Migrationdefchangedocreatetable(:ledgers)doadd:amount,:money_with_currencytimestamps()endendend
  1. Create your schema using the Money.Ecto.Composite.Type ecto type:
defmoduleLedgerdouseEcto.Schemaschema"ledgers"dofield:amount,Money.Ecto.Composite.Typetimestamps()endend
  1. Insert into the database:
iex>Repo.insert%Ledger{amount: Money.new(:USD,"100.00")}[debug] QUERY OK db=4.5msINSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3)[{"USD",#Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}]
  1. Retrieve from the database:
iex>Repo.allLedger[debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1msSELECT l0."amount", l0."inserted_at", l0."updated_at"FROM "ledgers" AS l0 [][%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, inserted_at: ~N[2017-02-21 00:15:40.979576], updated_at: ~N[2017-02-21 00:15:40.991391]}]

Serializing to a MySQL (or other non-Postgres) database with Ecto

Since MySQL does not support composite types, the :map type is used which in MySQL is implemented as a JSON column. The currency code and amount are serialised into this column.

defmodule MoneyTest.Repo.Migrations.CreateLedger do
use Ecto.Migration
def change do
create table(:ledgers) do
add :amount, :map
timestamps()
end
end
end

Create your schema using the Money.Ecto.Map.Type ecto type:

defmodule Ledger do
use Ecto.Schema
schema "ledgers" do
field :amount, Money.Ecto.Map.Type
timestamps()
end
end

Insert into the database:

iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)}
[debug] QUERY OK db=25.8ms
INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3)
RETURNING "id" [%{amount: "100", currency: "USD"},
{{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}]
{:ok,
%MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount: nil, amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}}

Retrieve from the database:

iex> Repo.all Ledger
[debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms
SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 []
[%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}]

Notes:

  1. In order to preserve precision of the decimal amount, the amount part of the %Money{} struct is serialised as a string. This is done because JSON serializes numeric values as either integer or float, neither of which would preserve precision of a decimal value.

  2. The precision of the serialized string value of amount is affected by the setting of Decimal.get_context. The default is 28 digits which should cater for your requirements.

  3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that CASTing the string value will restore some of that functionality. For example:

CAST(JSON_EXTRACT(amount_map, '$.amount') ASDECIMAL(20, 8)) AS amount;

Casting Money with Changesets

Then the schema type is Money.Ecto.Composite.Type then any option that is applicable to Money.parse/2 or Money.new/3 can be added to the field definition. These options will then be applied when Money.Ecto.Composite.Type.cast/2 or Money.Ecto.Composite.Type.load/3 is called. These functions are called with loading data from the database or when calling Ecto.Changeset.cast/3 is called. Typically this is useful to:

  1. Apply a default currency to a field input representing a money amount.
  2. Add formatting options to the returned t:Money that will be applied when calling Money.to_string/2

Consider the following example where a money amount will be considered in a default currency if no currency is applied:

Schema Example

The example below has three columns defined as Money.Ecto.Composite.Type.

  • :payroll will be cast as with the default currency :JPY if no currency field is provided. Note that if no :default_currency option is defined, the default currency will be derived from the current locale or configured :locale option.

  • :tax is defined with the option :fractional_digits. This option will be applied when formatting :tax with Money.to_string/2

  • :default is the t:Money that is used if the :value field is nil both when casting and when loading from the database.

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integertimestamps()enddefchangeset(organization,params\\%{})doorganization|>cast(params,[:payroll])endend

Embedded schema example

Embedded schemas are represented in Postgres as a jsobn data type which, in Elixir, is represented as a map. Therefore to include money fields in an embedded scheam, the Money.Ecto.Map.Type is used. Here is an example schema, extending the previous example:

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integerembeds_many:customers,Customerdofield:name,:stringfield:revenue,Money.Ecto.Map.Type,default: Money.new(:USD,0)endtimestamps()end

Changeset execution

In the following example, a default of :JPY currency (using our previous schema example) will be applied when casting the changeset.

iex>changeset=Organization.changeset(%Organization{},%{payroll: "0"})iex>changeset.changes.payroll==Money.new(:JPY,0)true

Postgres Database functions

Since the datatype used to store Money in Postgres is a composite type (called :money_with_currency), the standard aggregation functions like sum and average are not supported and the order_by clause doesn't perform as expected. Money provides mechanisms to provide these functions.

Plus operator +

Money defines a migration generator which, when migrated to the database with mix ecto.migrate, supports the + operator for :money_with_currency columns. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.plus_operator

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the + operator

iex>q=Ecto.Query.selectItem,[l],type(fragment("price + price"),l.price)#Ecto.Query<from l0 in Item, select: type(fragment("price + price"), l0.price)>iex>Repo.oneq[debug] QUERY OK source="items" db=5.6ms queue=0.5msSELECT price +price::money_with_currencyFROM "items" AS l0 [] #Money<:USD, 200>]

Aggregate functions

Support for some aggregate functions that operate on the Money.t/0 type. Currently those functions are:

  • sum
  • max
  • min
  • avg

Sum

Money_SQL provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing sum() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.sum_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function sum()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(sum(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(sum(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:sum,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Min and Max

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing min() and max() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.min_max_functions

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function min() or max()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(min(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(min(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:min,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Avg

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing avg() (average) aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.avg_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function avg()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(avg(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(avg(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTavg(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 100>]

The function Repo.aggregate/3 can also be used:

iex>Repo.aggregate(Item,:avg,:price)#Money<:USD, 100>

Note that similar to other aggregate functions, avg() requires all money values to have the same currency. Attempting to average money with different currencies will raise an exception.

Order_by with Money

Since :money_with_currency is a composite type, the default order_by results may surprise since the ordering is based upon the type structure, not the money amount. Postgres defines a means to access the components of a composite type and therefore sorting can be done in a more predictable fashion. For example:

# In this example we are decomposing the the composite column called# `price` and using the sub-field `amount` to perform the ordering.iex>q=fromlinItem,select: l.price,order_by: fragment("amount(price)")#Ecto.Query<from l in Item, order_by: [asc: fragment("amount(price)")],select: l.amount>iex>Repo.allq[debug] QUERY OK source="items" db=2.0msSELECT l0."price"FROM "items" AS l0 ORDER BY amount(price) [] [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>]

Note that the results may still be unexpected. The example above shows the correct ascending ordering by amount(price) however the ordering is not currency code aware and therefore mixed currencies will return a largely meaningless order.

About

Money functions for the serialization of a money data type in Elixir

Resources

Stars

34 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Introduction to Money SQL

Hex.pmHex.pmHex.pmHex.pm

Money_SQL implements a set of functions to store and retrieve data structured as a %Money{} type that is composed of an ISO 4217 currency code and a currency amount. See ex_money for details of using Money. Note that ex_money_sql depends on ex_money.

ex_money 6.0 and Localize {: .info}

From version 2.0, ex_money_sql requires ex_money ~> 6.0. ex_money 6.0 replaces the ex_cldr family of dependencies with the unified localize package and removes the compile-time CLDR backend system. Any MyApp.Cldr backend module, or configuration using :default_cldr_backend, should be removed. Locales are now configured through config :localize and accessed through the Localize module (for example Localize.put_locale/1). See the ex_money 6.0 migration guide for full details.

Postgrex JSON library {: .info}

ex_money_sql no longer declares jason as a dependency. Postgrex defaults to Jason for encoding json/jsonb columns, so configure a JSON library explicitly. Money.SQL.JSON is provided for this and works on every supported Elixir and OTP version: config :postgrex, :json_library, Money.SQL.JSON. It uses the Erlang :json module, which is built into OTP 27 and later; on OTP 26 add json_polyfill. Elixir 1.18's built-in JSON module and Jason also work. Postgrex captures this setting at compile time, so after changing it run mix deps.compile postgrex --force once.

Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning}

Please ensure that if you are using Ecto embedded schemas that include a money type that it is configured with the type Money.Ecto.Map.Type, NOTMoney.Ecto.Composite.Type.

In previous releases the misconfiguration of the type worked by accident. From ex_money_sql version 1.9.2 and subsequent releases an exception like ** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple will be raised. This is most likely an indication of type misconfiguration in an embedded schema.

Installation

ex_money_sql can be installed by adding ex_money_sql to your list of dependencies in mix.exs and then executing mix deps.get

defdepsdo[{:ex_money_sql,"~> 2.1"},...]end

Note that ex_money_sql is supported on Elixir 1.17 and later only.

Serializing to a Postgres database with Ecto

Money_SQL provides custom Ecto data types and a custom Postgres data type to provide serialization of Money.t types without losing precision whilst also maintaining the integrity of the {currency_code, amount} relationship. To serialise and retrieve money types from a database the following steps should be followed:

  1. First generate the migration to create the custom type:
mixmoney.gen.postgres.money_with_currency*creatingpriv/repo/migrations*creatingpriv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs
  1. Then migrate the database:
mixecto.migrate07:09:28.637[info]==Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0forward07:09:28.640[info]execute"CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)"07:09:28.647[info]==Migratedin0.0s
  1. Create your database migration with the new type (don't forget to mix ecto.migrate as well):
defmoduleMoneyTest.Repo.Migrations.CreateLedgerdouseEcto.Migrationdefchangedocreatetable(:ledgers)doadd:amount,:money_with_currencytimestamps()endendend
  1. Create your schema using the Money.Ecto.Composite.Type ecto type:
defmoduleLedgerdouseEcto.Schemaschema"ledgers"dofield:amount,Money.Ecto.Composite.Typetimestamps()endend
  1. Insert into the database:
iex>Repo.insert%Ledger{amount: Money.new(:USD,"100.00")}[debug] QUERY OK db=4.5msINSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3)[{"USD",#Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}]
  1. Retrieve from the database:
iex>Repo.allLedger[debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1msSELECT l0."amount", l0."inserted_at", l0."updated_at"FROM "ledgers" AS l0 [][%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, inserted_at: ~N[2017-02-21 00:15:40.979576], updated_at: ~N[2017-02-21 00:15:40.991391]}]

Serializing to a MySQL (or other non-Postgres) database with Ecto

Since MySQL does not support composite types, the :map type is used which in MySQL is implemented as a JSON column. The currency code and amount are serialised into this column.

defmodule MoneyTest.Repo.Migrations.CreateLedger do
use Ecto.Migration
def change do
create table(:ledgers) do
add :amount, :map
timestamps()
end
end
end

Create your schema using the Money.Ecto.Map.Type ecto type:

defmodule Ledger do
use Ecto.Schema
schema "ledgers" do
field :amount, Money.Ecto.Map.Type
timestamps()
end
end

Insert into the database:

iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)}
[debug] QUERY OK db=25.8ms
INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3)
RETURNING "id" [%{amount: "100", currency: "USD"},
{{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}]
{:ok,
%MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount: nil, amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}}

Retrieve from the database:

iex> Repo.all Ledger
[debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms
SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 []
[%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}]

Notes:

  1. In order to preserve precision of the decimal amount, the amount part of the %Money{} struct is serialised as a string. This is done because JSON serializes numeric values as either integer or float, neither of which would preserve precision of a decimal value.

  2. The precision of the serialized string value of amount is affected by the setting of Decimal.get_context. The default is 28 digits which should cater for your requirements.

  3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that CASTing the string value will restore some of that functionality. For example:

CAST(JSON_EXTRACT(amount_map, '$.amount') ASDECIMAL(20, 8)) AS amount;

Casting Money with Changesets

Then the schema type is Money.Ecto.Composite.Type then any option that is applicable to Money.parse/2 or Money.new/3 can be added to the field definition. These options will then be applied when Money.Ecto.Composite.Type.cast/2 or Money.Ecto.Composite.Type.load/3 is called. These functions are called with loading data from the database or when calling Ecto.Changeset.cast/3 is called. Typically this is useful to:

  1. Apply a default currency to a field input representing a money amount.
  2. Add formatting options to the returned t:Money that will be applied when calling Money.to_string/2

Consider the following example where a money amount will be considered in a default currency if no currency is applied:

Schema Example

The example below has three columns defined as Money.Ecto.Composite.Type.

  • :payroll will be cast as with the default currency :JPY if no currency field is provided. Note that if no :default_currency option is defined, the default currency will be derived from the current locale or configured :locale option.

  • :tax is defined with the option :fractional_digits. This option will be applied when formatting :tax with Money.to_string/2

  • :default is the t:Money that is used if the :value field is nil both when casting and when loading from the database.

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integertimestamps()enddefchangeset(organization,params\\%{})doorganization|>cast(params,[:payroll])endend

Embedded schema example

Embedded schemas are represented in Postgres as a jsobn data type which, in Elixir, is represented as a map. Therefore to include money fields in an embedded scheam, the Money.Ecto.Map.Type is used. Here is an example schema, extending the previous example:

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integerembeds_many:customers,Customerdofield:name,:stringfield:revenue,Money.Ecto.Map.Type,default: Money.new(:USD,0)endtimestamps()end

Changeset execution

In the following example, a default of :JPY currency (using our previous schema example) will be applied when casting the changeset.

iex>changeset=Organization.changeset(%Organization{},%{payroll: "0"})iex>changeset.changes.payroll==Money.new(:JPY,0)true

Postgres Database functions

Since the datatype used to store Money in Postgres is a composite type (called :money_with_currency), the standard aggregation functions like sum and average are not supported and the order_by clause doesn't perform as expected. Money provides mechanisms to provide these functions.

Plus operator +

Money defines a migration generator which, when migrated to the database with mix ecto.migrate, supports the + operator for :money_with_currency columns. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.plus_operator

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the + operator

iex>q=Ecto.Query.selectItem,[l],type(fragment("price + price"),l.price)#Ecto.Query<from l0 in Item, select: type(fragment("price + price"), l0.price)>iex>Repo.oneq[debug] QUERY OK source="items" db=5.6ms queue=0.5msSELECT price +price::money_with_currencyFROM "items" AS l0 [] #Money<:USD, 200>]

Aggregate functions

Support for some aggregate functions that operate on the Money.t/0 type. Currently those functions are:

  • sum
  • max
  • min
  • avg

Sum

Money_SQL provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing sum() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.sum_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function sum()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(sum(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(sum(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:sum,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Min and Max

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing min() and max() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.min_max_functions

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function min() or max()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(min(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(min(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:min,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Avg

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing avg() (average) aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.avg_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function avg()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(avg(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(avg(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTavg(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 100>]

The function Repo.aggregate/3 can also be used:

iex>Repo.aggregate(Item,:avg,:price)#Money<:USD, 100>

Note that similar to other aggregate functions, avg() requires all money values to have the same currency. Attempting to average money with different currencies will raise an exception.

Order_by with Money

Since :money_with_currency is a composite type, the default order_by results may surprise since the ordering is based upon the type structure, not the money amount. Postgres defines a means to access the components of a composite type and therefore sorting can be done in a more predictable fashion. For example:

# In this example we are decomposing the the composite column called# `price` and using the sub-field `amount` to perform the ordering.iex>q=fromlinItem,select: l.price,order_by: fragment("amount(price)")#Ecto.Query<from l in Item, order_by: [asc: fragment("amount(price)")],select: l.amount>iex>Repo.allq[debug] QUERY OK source="items" db=2.0msSELECT l0."price"FROM "items" AS l0 ORDER BY amount(price) [] [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>]

Note that the results may still be unexpected. The example above shows the correct ascending ordering by amount(price) however the ordering is not currency code aware and therefore mixed currencies will return a largely meaningless order.

About

Money functions for the serialization of a money data type in Elixir

Resources

Stars

34 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Introduction to Money SQL

Hex.pmHex.pmHex.pmHex.pm

Money_SQL implements a set of functions to store and retrieve data structured as a %Money{} type that is composed of an ISO 4217 currency code and a currency amount. See ex_money for details of using Money. Note that ex_money_sql depends on ex_money.

ex_money 6.0 and Localize {: .info}

From version 2.0, ex_money_sql requires ex_money ~> 6.0. ex_money 6.0 replaces the ex_cldr family of dependencies with the unified localize package and removes the compile-time CLDR backend system. Any MyApp.Cldr backend module, or configuration using :default_cldr_backend, should be removed. Locales are now configured through config :localize and accessed through the Localize module (for example Localize.put_locale/1). See the ex_money 6.0 migration guide for full details.

Postgrex JSON library {: .info}

ex_money_sql no longer declares jason as a dependency. Postgrex defaults to Jason for encoding json/jsonb columns, so configure a JSON library explicitly. Money.SQL.JSON is provided for this and works on every supported Elixir and OTP version: config :postgrex, :json_library, Money.SQL.JSON. It uses the Erlang :json module, which is built into OTP 27 and later; on OTP 26 add json_polyfill. Elixir 1.18's built-in JSON module and Jason also work. Postgrex captures this setting at compile time, so after changing it run mix deps.compile postgrex --force once.

Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning}

Please ensure that if you are using Ecto embedded schemas that include a money type that it is configured with the type Money.Ecto.Map.Type, NOTMoney.Ecto.Composite.Type.

In previous releases the misconfiguration of the type worked by accident. From ex_money_sql version 1.9.2 and subsequent releases an exception like ** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple will be raised. This is most likely an indication of type misconfiguration in an embedded schema.

Installation

ex_money_sql can be installed by adding ex_money_sql to your list of dependencies in mix.exs and then executing mix deps.get

defdepsdo[{:ex_money_sql,"~> 2.1"},...]end

Note that ex_money_sql is supported on Elixir 1.17 and later only.

Serializing to a Postgres database with Ecto

Money_SQL provides custom Ecto data types and a custom Postgres data type to provide serialization of Money.t types without losing precision whilst also maintaining the integrity of the {currency_code, amount} relationship. To serialise and retrieve money types from a database the following steps should be followed:

  1. First generate the migration to create the custom type:
mixmoney.gen.postgres.money_with_currency*creatingpriv/repo/migrations*creatingpriv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs
  1. Then migrate the database:
mixecto.migrate07:09:28.637[info]==Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0forward07:09:28.640[info]execute"CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)"07:09:28.647[info]==Migratedin0.0s
  1. Create your database migration with the new type (don't forget to mix ecto.migrate as well):
defmoduleMoneyTest.Repo.Migrations.CreateLedgerdouseEcto.Migrationdefchangedocreatetable(:ledgers)doadd:amount,:money_with_currencytimestamps()endendend
  1. Create your schema using the Money.Ecto.Composite.Type ecto type:
defmoduleLedgerdouseEcto.Schemaschema"ledgers"dofield:amount,Money.Ecto.Composite.Typetimestamps()endend
  1. Insert into the database:
iex>Repo.insert%Ledger{amount: Money.new(:USD,"100.00")}[debug] QUERY OK db=4.5msINSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3)[{"USD",#Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}]
  1. Retrieve from the database:
iex>Repo.allLedger[debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1msSELECT l0."amount", l0."inserted_at", l0."updated_at"FROM "ledgers" AS l0 [][%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, inserted_at: ~N[2017-02-21 00:15:40.979576], updated_at: ~N[2017-02-21 00:15:40.991391]}]

Serializing to a MySQL (or other non-Postgres) database with Ecto

Since MySQL does not support composite types, the :map type is used which in MySQL is implemented as a JSON column. The currency code and amount are serialised into this column.

defmodule MoneyTest.Repo.Migrations.CreateLedger do
use Ecto.Migration
def change do
create table(:ledgers) do
add :amount, :map
timestamps()
end
end
end

Create your schema using the Money.Ecto.Map.Type ecto type:

defmodule Ledger do
use Ecto.Schema
schema "ledgers" do
field :amount, Money.Ecto.Map.Type
timestamps()
end
end

Insert into the database:

iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)}
[debug] QUERY OK db=25.8ms
INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3)
RETURNING "id" [%{amount: "100", currency: "USD"},
{{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}]
{:ok,
%MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount: nil, amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}}

Retrieve from the database:

iex> Repo.all Ledger
[debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms
SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 []
[%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">,
amount_map: #Money<:USD, 100>, id: 3,
inserted_at: ~N[2017-02-21 00:15:40.979576],
updated_at: ~N[2017-02-21 00:15:40.991391]}]

Notes:

  1. In order to preserve precision of the decimal amount, the amount part of the %Money{} struct is serialised as a string. This is done because JSON serializes numeric values as either integer or float, neither of which would preserve precision of a decimal value.

  2. The precision of the serialized string value of amount is affected by the setting of Decimal.get_context. The default is 28 digits which should cater for your requirements.

  3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that CASTing the string value will restore some of that functionality. For example:

CAST(JSON_EXTRACT(amount_map, '$.amount') ASDECIMAL(20, 8)) AS amount;

Casting Money with Changesets

Then the schema type is Money.Ecto.Composite.Type then any option that is applicable to Money.parse/2 or Money.new/3 can be added to the field definition. These options will then be applied when Money.Ecto.Composite.Type.cast/2 or Money.Ecto.Composite.Type.load/3 is called. These functions are called with loading data from the database or when calling Ecto.Changeset.cast/3 is called. Typically this is useful to:

  1. Apply a default currency to a field input representing a money amount.
  2. Add formatting options to the returned t:Money that will be applied when calling Money.to_string/2

Consider the following example where a money amount will be considered in a default currency if no currency is applied:

Schema Example

The example below has three columns defined as Money.Ecto.Composite.Type.

  • :payroll will be cast as with the default currency :JPY if no currency field is provided. Note that if no :default_currency option is defined, the default currency will be derived from the current locale or configured :locale option.

  • :tax is defined with the option :fractional_digits. This option will be applied when formatting :tax with Money.to_string/2

  • :default is the t:Money that is used if the :value field is nil both when casting and when loading from the database.

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integertimestamps()enddefchangeset(organization,params\\%{})doorganization|>cast(params,[:payroll])endend

Embedded schema example

Embedded schemas are represented in Postgres as a jsobn data type which, in Elixir, is represented as a map. Therefore to include money fields in an embedded scheam, the Money.Ecto.Map.Type is used. Here is an example schema, extending the previous example:

defmoduleOrganizationdouseEcto.SchemaimportEcto.Changeset@primary_keyfalseschema"organizations"dofield:payroll,Money.Ecto.Composite.Type,default_currency: :JPYfield:tax,Money.Ecto.Composite.Type,fractional_digits: 4field:value,Money.Ecto.Composite.Type,default: Money.new(:USD,0)field:name,:stringfield:employee_count,:integerembeds_many:customers,Customerdofield:name,:stringfield:revenue,Money.Ecto.Map.Type,default: Money.new(:USD,0)endtimestamps()end

Changeset execution

In the following example, a default of :JPY currency (using our previous schema example) will be applied when casting the changeset.

iex>changeset=Organization.changeset(%Organization{},%{payroll: "0"})iex>changeset.changes.payroll==Money.new(:JPY,0)true

Postgres Database functions

Since the datatype used to store Money in Postgres is a composite type (called :money_with_currency), the standard aggregation functions like sum and average are not supported and the order_by clause doesn't perform as expected. Money provides mechanisms to provide these functions.

Plus operator +

Money defines a migration generator which, when migrated to the database with mix ecto.migrate, supports the + operator for :money_with_currency columns. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.plus_operator

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the + operator

iex>q=Ecto.Query.selectItem,[l],type(fragment("price + price"),l.price)#Ecto.Query<from l0 in Item, select: type(fragment("price + price"), l0.price)>iex>Repo.oneq[debug] QUERY OK source="items" db=5.6ms queue=0.5msSELECT price +price::money_with_currencyFROM "items" AS l0 [] #Money<:USD, 200>]

Aggregate functions

Support for some aggregate functions that operate on the Money.t/0 type. Currently those functions are:

  • sum
  • max
  • min
  • avg

Sum

Money_SQL provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing sum() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.sum_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function sum()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(sum(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(sum(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:sum,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTsum(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Min and Max

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing min() and max() aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.min_max_functions

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function min() or max()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(min(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(min(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 600>]

The function Repo.aggregate/3 can also be used. However at least ecto version 3.2.4 is required for this to work correctly for custom ecto types such as :money_with_currency.

iex>Repo.aggregate(Item,:min,:price)#Money<:USD, 600>

Note that to preserve the integrity of Money it is not permissable to aggregate money that has different currencies. If you attempt to aggregate money with different currencies the query will abort and an exception will be raised:

iex>Repo.allq[debug] QUERY ERROR source="items" db=4.5msSELECTmin(l0."price")::money_with_currencyFROM "items" AS l0 [] ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD

Avg

Money provides a migration generator which, when migrated to the database with mix ecto.migrate, supports performing avg() (average) aggregation on Money types. The steps are:

  1. Generate the migration by executing mix money.gen.postgres.avg_function

  2. Migrate the database by executing mix ecto.migrate

  3. Formulate an Ecto query to use the aggregate function avg()

# Formulate the query. Note the required use of the type()# expression which is needed to inform Ecto of the return# type of the functioniex>q=Ecto.Query.selectItem,[l],type(avg(l.price),l.price)#Ecto.Query<from l0 in Item, select: type(avg(l.price), l.price)>iex>Repo.allq[debug] QUERY OK source="items" db=6.1msSELECTavg(l0."price")::money_with_currencyFROM "items" AS l0 [] [#Money<:USD, 100>]

The function Repo.aggregate/3 can also be used:

iex>Repo.aggregate(Item,:avg,:price)#Money<:USD, 100>

Note that similar to other aggregate functions, avg() requires all money values to have the same currency. Attempting to average money with different currencies will raise an exception.

Order_by with Money

Since :money_with_currency is a composite type, the default order_by results may surprise since the ordering is based upon the type structure, not the money amount. Postgres defines a means to access the components of a composite type and therefore sorting can be done in a more predictable fashion. For example:

# In this example we are decomposing the the composite column called# `price` and using the sub-field `amount` to perform the ordering.iex>q=fromlinItem,select: l.price,order_by: fragment("amount(price)")#Ecto.Query<from l in Item, order_by: [asc: fragment("amount(price)")],select: l.amount>iex>Repo.allq[debug] QUERY OK source="items" db=2.0msSELECT l0."price"FROM "items" AS l0 ORDER BY amount(price) [] [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>]

Note that the results may still be unexpected. The example above shows the correct ascending ordering by amount(price) however the ordering is not currency code aware and therefore mixed currencies will return a largely meaningless order.

About

Money functions for the serialization of a money data type in Elixir

Resources

Stars

34 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages