├── .dialyzer_ignore_warnings ├── .formatter.exs ├── .gitignore ├── .iex.exs ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── release.exs └── test.exs ├── lib ├── mix │ └── tasks │ │ ├── money_gen_minmax_functions.ex │ │ ├── money_gen_sum_function.ex │ │ ├── money_postgres_add_function.ex │ │ └── money_postgres_migration.ex ├── money │ ├── ddl.ex │ ├── ecto │ │ ├── money_ecto_composite_type.ex │ │ ├── money_ecto_map_type.ex │ │ ├── money_ecto_query_api.ex │ │ └── query_api │ │ │ ├── composite.ex │ │ │ └── map │ │ │ ├── mysql.ex │ │ │ └── postgres.ex │ ├── migration.ex │ └── validate.ex └── money_sql.ex ├── logo.png ├── mix.exs ├── mix.lock ├── mix ├── money_cldr.ex ├── repo.ex └── schema.ex ├── priv ├── SQL │ └── postgres │ │ ├── create_money_with_currency.sql │ │ ├── define_minmax_functions.sql │ │ ├── define_minus_operator.sql │ │ ├── define_negate_operator.sql │ │ ├── define_plus_operator.sql │ │ ├── define_sum_function.sql │ │ ├── drop_minmax_functions.sql │ │ ├── drop_minus_operator.sql │ │ ├── drop_money_with_currency.sql │ │ ├── drop_negate_operator.sql │ │ ├── drop_plus_operator.sql │ │ ├── drop_sum_function.sql │ │ └── get_currency_code_type.sql └── repo │ └── migrations │ ├── 20231028034804_add_money_with_currency_type_to_postgres.exs │ ├── 20231030034859_add_postgres_money_sum_function.exs │ ├── 20231030035131_add_postgres_money_plus_operator.exs │ ├── 20231030035136_add_postgres_money_minmax_functions.exs │ └── 20500930144804_create_test_table.exs └── test ├── db_test.exs ├── money_changeset_test.exs ├── money_ecto_test.exs ├── money_sql_test.exs ├── query_api ├── composite_test.exs ├── map_mysql_test.exs └── map_postgres_test.exs ├── support ├── changeset.ex └── test_cldr.ex └── test_helper.exs /.dialyzer_ignore_warnings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kipcole9/money_sql/a79ad430a8804b1f8c9eb93ad18a29d25d966700/.dialyzer_ignore_warnings -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | money_sql-*.tar 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | alias Ecto.Adapters.SQL 2 | alias Money.SQL.Repo 3 | 4 | import Money.Ecto.Query.API.Composite 5 | import Money.Ecto.Query.API 6 | 7 | import Ecto 8 | import Ecto.Query 9 | 10 | Repo.start_link() 11 | 12 | [m_usd: Money.new(:USD, 100), m_aud: Money.new(:AUD, 50), m_eur: Money.new(:EUR, 100)] 13 | |> tap(fn [m_usd: m_usd, m_aud: m_aud, m_eur: m_eur] -> 14 | {:ok, _} = Repo.insert(%Organization{revenue: m_eur, payroll: m_eur, name: "EU"}) 15 | {:ok, _} = Repo.insert(%Organization{revenue: m_usd, payroll: m_usd, name: "UE"}) 16 | {:ok, _} = Repo.insert(%Organization{revenue: m_usd, payroll: m_usd, name: "UE"}) 17 | {:ok, _} = Repo.insert(%Organization{revenue: m_aud, payroll: m_aud, name: "EU"}) 18 | {:ok, _} = Repo.insert(%Organization{revenue: m_aud, payroll: m_aud, name: "EU"}) 19 | {:ok, _} = Repo.insert(%Organization{revenue: m_aud, payroll: m_aud, name: "AU"}) 20 | {:ok, _} = Repo.insert(%Organization{revenue: nil, payroll: nil, name: "EU"}) 21 | end) 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | **Note** That `money_sql` is supported on Elixir 1.11 and later only. 4 | 5 | ## Money_SQL v1.11.0 6 | 7 | This is the changelog for Money_SQL v1.11.0 released on January 249h, 2023. 8 | 9 | ### Enhancements 10 | 11 | * When dumping a `Money.Ecto.Composite.Type`, detect if `dump/3` is being called by `Ecto.Type.embedded_dump/2`. If it is, then return a map that can be serialized to JSON. If it isn't (most cases) then return the tuple to be serialized to Postgres. See [papertrail issue](https://github.com/izelnakri/paper_trail/issues/230). 12 | 13 | ## Money_SQL v1.10.2 14 | 15 | This is the changelog for Money_SQL v1.10.2 released on December 13th, 2023. 16 | 17 | ### Bug Fixes 18 | 19 | * Don't propagate some Ecto schema fields in `Money.Ecto.Composite.Type.init/2`. This change deletes additional `Ecto.Schema.field/3` options so they don't get loaded as format_options. Thanks to @axelclark for the PR. Closes #40. 20 | 21 | * Fix Changelog headings and whitespace. Thanks to @c4710n for the PR. Closes #39. 22 | 23 | ## Money_SQL v1.10.1 24 | 25 | This is the changelog for Money_SQL v1.10.1 released on November 3rd, 2023. 26 | 27 | ### Bug Fixes 28 | 29 | * Fix compilation warnings on Elixir 1.16. 30 | 31 | * Fix migration generator for `money_with_currency` type. Thanks to @bigardone for the issue and PR. Closes #37, closes #38. 32 | 33 | ## Money_SQL v1.10.0 34 | 35 | This is the changelog for Money_SQL v1.10.0 released on October 30th, 2023. 36 | 37 | ### Bug Fixes 38 | 39 | * The mix tasks that generate database function migrations (`money.gen.postgres.sum_function`, `money.gen.postgres.plus_operator` and `money.gen.postgres.min_max_functions`) need to be aware of the type of the `money_with_currency` "currency_code" element in a Postgres database. In releases of `ex_money_sql` up to 1.7.1 the type was `char(3)`. In later releases is changed to the more canonical `varchar`. In turn, the database functions need to know the type for the internal accumulator. It is possible, as illustrated in issue #36, to have generated the `money_with_currency` "currency_code" type as `char(3)` and then move to a later release of `ex_money_sql`. In which case the money database function migrations would fail because they were built with `varchar` accumulators. This release will detect the underlying type of the `money_with_currency` "currency_code" element and adjust the migration accordingly. Thanks to @bigardone for the report and motivation to get this done. Closes #36. 40 | 41 | ### Enhancements 42 | 43 | * Adds database functions for unary negation and the operation `-`. Thanks to @zachdaniel for the PR. 44 | 45 | ## Money_SQL v1.9.3 46 | 47 | This is the changelog for Money_SQL v1.9.2 released on October 1st, 2023. 48 | 49 | ### Bug Fixes 50 | 51 | * Return an error if loading invalid amounts such as `Inf` and `NaN`. Thanks to @dhedlund for the report and PR. Closes #34. 52 | 53 | ## Money_SQL v1.9.2 54 | 55 | This is the changelog for Money_SQL v1.9.2 released on June 17th, 2022. 56 | 57 | ### Embedded Schema Configuration 58 | 59 | Please ensure that if you are using Ecto [embedded schemas](https://hexdocs.pm/ecto/embedded-schemas.html) that include a `money` type that it is configured with the type `Money.Ecto.Map.Type`, **NOT** `Money.Ecto.Composite.Type`. 60 | 61 | In previous releases the misconfiguration of the type worked by accident. In this release and subsequent releases you will likely see an exception like `** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for {"USD", Decimal.new("50.00")} of type Tuple`. This is most likely an indication of type misconfiguration in an embedded schema. 62 | 63 | ### Bug Fixes 64 | 65 | * Fixes dumping and loading of `Money.Ecto.Map.Type` when used in embedded schemas. Many thanks to @redrabbit for the issue and the PR. Closes #32. 66 | 67 | ## Money_SQL v1.9.1 68 | 69 | This is the changelog for Money_SQL v1.9.1 released on May 12th, 2022. 70 | 71 | ### Bug Fixes 72 | 73 | * Fixes casting a map when the `"amount"` is `nil`. Thanks to @treere for the report and PR. Closes #30. 74 | 75 | ## Money_SQL v1.9.0 76 | 77 | This is the changelog for Money_SQL v1.9.0 released on April 28th, 2022. 78 | 79 | ### Enhancements 80 | 81 | * Adds `Money.Ecto.Query.API` query helpers to simplify Ecto queries involving money columns. Thanks very much to @am-kantox for the excellent suggestion and PR. 82 | 83 | ## Money_SQL v1.8.0 84 | 85 | This is the changelog for Money_SQL v1.8.0 released on December 26th, 2022. 86 | 87 | ### Enhancements 88 | 89 | * Adds migrations and SQL functions to support `min` and `max` aggregate functions for Postgres when using the `money_with_currency` composite data type. The new mix task is `money.gen.postgres.min_max_functions`. 90 | 91 | * Renames the migration task `money.gen.postgres.aggregate_functions` to `money.gen.postgres.sum_function` to better reflect its intent. This change affects only new installations. It has no effect on pre-existing generated migrations. 92 | 93 | ## Money_SQL v1.7.3 94 | 95 | This is the changelog for Money_SQL v1.7.3 released on December 18th, 2022. 96 | 97 | ### Bug Fixes 98 | 99 | * When loading money from the database with the `Money.Ecto.Map.Type` type, do not do localized parsing of the amount. The amount is always saved using `Decimal.to_string/1` and therefore is not localized. It must not be parsed with localization on loading. 100 | 101 | ## Money_SQL v1.7.2 102 | 103 | This is the changelog for Money_SQL v1.7.2 released on August 27th, 2022. 104 | 105 | ### Change in data type 106 | 107 | * The "amount" component of `money_with_currency` in a Postgres database is now `varchar` instead of `char(3)`. This is both more canonical in a Postgres database and allows for the use of Digial Tokens (crypto currencies) which have a code greater than 3 characters long. 108 | 109 | ### Bug Fixes 110 | 111 | * Makes the aggregate functions parallel-safe which provides up to 100% speed improvement. Thanks to @milangupta1 for the PR. 112 | 113 | ## Money_SQL v1.7.1 114 | 115 | This is the changelog for Money_SQL v1.7.1 released on July 8th, 2022. 116 | 117 | ### Bug Fixes 118 | 119 | * Fixes casting a money map when the currency is `nil`. Thanks to @frahugo for the report. Closes #24. 120 | 121 | ## Money_SQL v1.7.0 122 | 123 | This is the changelog for Money_SQL v1.7.0 released on May 21st, 2022. 124 | 125 | ### Enhancements 126 | 127 | * Adds the module `Money.Validation` to provide [Ecto Changeset validations](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-validations-and-constraints). In particular it adds `Money.Validation.validate_money/3` which behaves exactly like `Ecto.Changeset.validate_number/3` only for `t:Money.t/0` types. 128 | 129 | ## Money_SQL v1.6.0 130 | 131 | This is the changelog for Money_SQL v1.6.0 released on December 31st, 2021. 132 | 133 | **Note** That `money_sql` is now supported on Elixir 1.10 and later only. 134 | 135 | ### Enhancements 136 | 137 | * `t:Money.Ecto.Composite.Type` and `t:Money.Ecto.Map.Type` now return the exception module when there is an error in `cast/1`. For example: 138 | 139 | ```elixir 140 | iex> Money.Ecto.Composite.Type.cast("") == 141 | {:error, 142 | [ 143 | exception: Money.InvalidAmountError, 144 | message: "Amount cannot be converted to a number: \"\"" 145 | ]} 146 | ``` 147 | The expected exceptions are: 148 | 149 | * `Money.InvalidAmountError` 150 | * `Money.UnknownCurrencyError` 151 | * `Money.ParseError` 152 | 153 | Thanks to @DaTrader for the enhancement request. 154 | 155 | ## Money_SQL v1.5.2 156 | 157 | This is the changelog for Money_SQL v1.5.2 released on December 13th, 2021. 158 | 159 | **Note** That `money_sql` is now supported on Elixir 1.10 and later only. 160 | 161 | ### Bug Fixes 162 | 163 | * Fixes `c:Ecto.ParameterizedType.embed_as/2` callback for the `Ecto.ParameterizedType` behaviour. Thanks to @nseantanly for the report and the PR. 164 | 165 | ## Money_SQL v1.5.1 166 | 167 | This is the changelog for Money_SQL v1.5.1 released on December 8th, 2021. 168 | 169 | **Note** That `money_sql` is now supported on Elixir 1.10 and later only. 170 | 171 | ### Bug Fixes 172 | 173 | * Implements `c:Ecto.ParameterizedType.equal?/3` callback for the `Ecto.ParameterizedType` behaviour. Thanks to @namhoangyojee for the report and the PR. 174 | 175 | * Adds `@impl Ecto.ParamaterizedType` to the relevant callbacks. 176 | 177 | ## Money_SQL v1.5.0 178 | 179 | This is the changelog for Money_SQL v1.5.0 released on September 25th, 2021. 180 | 181 | #### Enhancements 182 | 183 | * Adds a `+` operator for the Postgres type `:money_with_currency` 184 | 185 | * The name of the migration to create the `:money_with_currency` type has shortened to be `money.gen.postgres.money_with_currency` 186 | 187 | ## Money_SQL v1.4.5 188 | 189 | This is the changelog for Money_SQL v1.4.5 released on June 3rd, 2021. 190 | 191 | #### Bug Fixes 192 | 193 | * Remove conditional compilation in `Money.Ecto.Composite.Type` - the type is always `Ecto.ParameterizedType`. 194 | 195 | ## Money_SQL v1.4.4 196 | 197 | This is the changelog for Money_SQL v1.4.4 released on March 18th, 2021. 198 | 199 | #### Bug Fixes 200 | 201 | * Don't use `is_struct/1` guard to support compatibility on older Elixir releases 202 | 203 | ## Money_SQL v1.4.3 204 | 205 | This is the changelog for Money_SQL v1.4.3 released on February 17th, 2021. 206 | 207 | #### Bug Fixes 208 | 209 | * Don't propogate a `:default` option into the `t:Money` from the schema. Fixes #14. Thanks to @emaiax. 210 | 211 | ## Money_SQL v1.4.2 212 | 213 | This is the changelog for Money_SQL v1.4.2 released on February 12th, 2021. 214 | 215 | #### Bug Fixes 216 | 217 | * Dumping/loading `nil` returns `{:ok, nil}`. Thanks to @morinap. 218 | 219 | ## Money_SQL v1.4.1 220 | 221 | This is the changelog for Money_SQL v1.4.1 released on February 11th, 2021. 222 | 223 | #### Bug Fixes 224 | 225 | * Casting `nil` returns `{:ok, nil}`. Thanks to @morinap. 226 | 227 | ## Money_SQL v1.4.0 228 | 229 | This is the changelog for Money_SQL v1.4.0 released on February 10th, 2021. 230 | 231 | #### Bug Fixes 232 | 233 | * Fix parsing error handling in `Money.Ecto.Composite.Type.cast/2`. Thanks to @NikitaAvvakumov. Closes #10. 234 | 235 | * Fix casting localized amounts. Thanks to @olivermt. Closes #11. 236 | 237 | #### Enhancements 238 | 239 | * Changes `Money.Ecto.Composite.Type` and `Money.Ecto.Map.Type` to be `ParameterizedType`. As a result, Ecto 3.5 or later is required. This change allows configuration of format options for the `:money_with_currency` to added as parameters in the Ecto schema. For the example schema: 240 | ```elixir 241 | defmodule Organization do 242 | use Ecto.Schema 243 | 244 | @primary_key false 245 | schema "organizations" do 246 | field :payroll, Money.Ecto.Composite.Type 247 | field :tax, Money.Ecto.Composite.Type, fractional_digits: 4 248 | field :name, :string 249 | field :employee_count, :integer 250 | timestamps() 251 | end 252 | end 253 | ``` 254 | The field `:tax` will be instantiated as a `Money.t` with `:format_options` of `fractional_digits: 4`. 255 | 256 | ## Money_SQL v1.3.1 257 | 258 | This is the changelog for Money_SQL v1.3.1 released on September 30th, 2020. 259 | 260 | #### Bug Fixes 261 | 262 | * Fixes compatibility with both `Decimal` version `1.x` and `2.x`. Thanks to @doughsay and @coladarci for the report. Closes #8. 263 | 264 | ## Money_SQL v1.3.0 265 | 266 | This is the changelog for Money_SQL v1.3.0 released on January 30th, 2020. 267 | 268 | #### Enhancements 269 | 270 | * Updates to `ex_money` version `5.0`. Thanks to @morgz 271 | 272 | ## Money_SQL v1.2.1 273 | 274 | This is the changelog for Money_SQL v1.2.1 released on November 3rd, 2019. 275 | 276 | #### Bug Fixes 277 | 278 | * Fixes `Money.Ecto.Composite.Type` and `Money.Ecto.Map.Type` by ensuring the `load/1` and `cast/1` callbacks conform to their typespecs. Thanks to @bgracie. Closes #4 and #5. 279 | 280 | * Fixes the migration templates for `money.gen.postgres.aggregate_functions` to use `numeric` intermediate types rather than `numeric(20,8)`. For current installations it should be enough to run `mix money.gen.postgres.aggregate_functions` again followed by `mix ecto.migrate` to install the corrected aggregate function. 281 | 282 | ## Money_SQL v1.2.0 283 | 284 | This is the changelog for Money_SQL v1.2.0 released on November 2nd, 2019. 285 | 286 | #### Bug Fixes 287 | 288 | * Removes the precision specification from intermediate results of the `sum` aggregate function for Postgres. 289 | 290 | #### Enhancements 291 | 292 | * Adds `equal?/2` callbacks to the `Money.Ecto.Composite.Type` and `Money.Ecto.Map.Type` for `ecto_sql` version 3.2 293 | 294 | ## Money_SQL v1.1.0 295 | 296 | This is the changelog for Money_SQL v1.1.0 released on August 22nd, 2019. 297 | 298 | #### Enhancements 299 | 300 | * Renames the migration that generator that creates the Postgres composite type to be more meaningful. 301 | 302 | #### Bug Fixes 303 | 304 | * Correctly generate and execute migrations. Fixes #1 and #2. Thanks to @davidsulc, @KungPaoChicken. 305 | 306 | ## Money_SQL v1.0.0 307 | 308 | This is the changelog for Money_SQL v1.0.0 released on July 8th, 2019. 309 | 310 | #### Enhancements 311 | 312 | * Initial release. Extracted from [ex_money](https://hex.pm/packages/ex_money) 313 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## License 2 | 3 | Copyright 2017-2019 Kip Cole 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in 6 | compliance with the License. You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software distributed under the License 11 | is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 12 | implied. See the License for the specific language governing permissions and limitations under the 13 | License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction to Money SQL 2 | [![Hex.pm](https://img.shields.io/hexpm/v/ex_money_sql.svg)](https://hex.pm/packages/ex_money_sql) 3 | [![Hex.pm](https://img.shields.io/hexpm/dw/ex_money_sql.svg?)](https://hex.pm/packages/ex_money_sql) 4 | [![Hex.pm](https://img.shields.io/hexpm/dt/ex_money_sql.svg?)](https://hex.pm/packages/ex_money_sql) 5 | [![Hex.pm](https://img.shields.io/hexpm/l/ex_money_sql.svg)](https://hex.pm/packages/ex_money_sql) 6 | 7 | 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](https://hex.pm/packages/ex_money) for details of using `Money`. Note that `ex_money_sql` depends on `ex_money`. 8 | 9 | > #### Embedded Schema Configuration from ex_money_sql 1.9.2 {: .warning} 10 | > 11 | > Please ensure that if you are using Ecto [embedded schemas](https://hexdocs.pm/ecto/embedded-schemas.html) that include a `money` type that it is configured with the type `Money.Ecto.Map.Type`, **NOT** `Money.Ecto.Composite.Type`. 12 | > 13 | > 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. 14 | 15 | ## Installation 16 | 17 | `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` 18 | 19 | ```elixir 20 | def deps do 21 | [ 22 | {:ex_money_sql, "~> 1.0"}, 23 | ... 24 | ] 25 | end 26 | ``` 27 | Note that `ex_money_sql` is supported on Elixir 1.11 and later only. 28 | 29 | ## Serializing to a Postgres database with Ecto 30 | 31 | `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: 32 | 33 | 1. First generate the migration to create the custom type: 34 | 35 | ```elixir 36 | mix money.gen.postgres.money_with_currency 37 | * creating priv/repo/migrations 38 | * creating priv/repo/migrations/20161007234652_add_money_with_currency_type_to_postgres.exs 39 | ``` 40 | 41 | 2. Then migrate the database: 42 | 43 | ```elixir 44 | mix ecto.migrate 45 | 07:09:28.637 [info] == Running MoneyTest.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres.up/0 forward 46 | 07:09:28.640 [info] execute "CREATE TYPE public.money_with_currency AS (currency_code char(3), amount numeric)" 47 | 07:09:28.647 [info] == Migrated in 0.0s 48 | ``` 49 | 50 | 3. Create your database migration with the new type (don't forget to `mix ecto.migrate` as well): 51 | 52 | ```elixir 53 | defmodule MoneyTest.Repo.Migrations.CreateLedger do 54 | use Ecto.Migration 55 | 56 | def change do 57 | create table(:ledgers) do 58 | add :amount, :money_with_currency 59 | timestamps() 60 | end 61 | end 62 | end 63 | ``` 64 | 65 | 4. Create your schema using the `Money.Ecto.Composite.Type` ecto type: 66 | 67 | ```elixir 68 | defmodule Ledger do 69 | use Ecto.Schema 70 | 71 | schema "ledgers" do 72 | field :amount, Money.Ecto.Composite.Type 73 | 74 | timestamps() 75 | end 76 | end 77 | ``` 78 | 79 | 5. Insert into the database: 80 | 81 | ```elixir 82 | iex> Repo.insert %Ledger{amount: Money.new(:USD, "100.00")} 83 | [debug] QUERY OK db=4.5ms 84 | INSERT INTO "ledgers" ("amount","inserted_at","updated_at") VALUES ($1,$2,$3) 85 | [{"USD", #Decimal<100.00>}, {{2016, 10, 7}, {23, 12, 13, 0}}, {{2016, 10, 7}, {23, 12, 13, 0}}] 86 | ``` 87 | 88 | 6. Retrieve from the database: 89 | 90 | ```elixir 91 | iex> Repo.all Ledger 92 | [debug] QUERY OK source="ledgers" db=5.3ms decode=0.1ms queue=0.1ms 93 | SELECT l0."amount", l0."inserted_at", l0."updated_at" FROM "ledgers" AS l0 [] 94 | [%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, amount: #<:USD, 100.00>, 95 | inserted_at: ~N[2017-02-21 00:15:40.979576], 96 | updated_at: ~N[2017-02-21 00:15:40.991391]}] 97 | ``` 98 | 99 | ## Serializing to a MySQL (or other non-Postgres) database with Ecto 100 | 101 | 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. 102 | 103 | defmodule MoneyTest.Repo.Migrations.CreateLedger do 104 | use Ecto.Migration 105 | 106 | def change do 107 | create table(:ledgers) do 108 | add :amount, :map 109 | timestamps() 110 | end 111 | end 112 | end 113 | 114 | Create your schema using the `Money.Ecto.Map.Type` ecto type: 115 | 116 | defmodule Ledger do 117 | use Ecto.Schema 118 | 119 | schema "ledgers" do 120 | field :amount, Money.Ecto.Map.Type 121 | 122 | timestamps() 123 | end 124 | end 125 | 126 | Insert into the database: 127 | 128 | iex> Repo.insert %Ledger{amount_map: Money.new(:USD, 100)} 129 | [debug] QUERY OK db=25.8ms 130 | INSERT INTO "ledgers" ("amount_map","inserted_at","updated_at") VALUES ($1,$2,$3) 131 | RETURNING "id" [%{amount: "100", currency: "USD"}, 132 | {{2017, 2, 21}, {0, 15, 40, 979576}}, {{2017, 2, 21}, {0, 15, 40, 991391}}] 133 | 134 | {:ok, 135 | %MoneyTest.Thing{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, 136 | amount: nil, amount_map: #Money<:USD, 100>, id: 3, 137 | inserted_at: ~N[2017-02-21 00:15:40.979576], 138 | updated_at: ~N[2017-02-21 00:15:40.991391]}} 139 | 140 | Retrieve from the database: 141 | 142 | iex> Repo.all Ledger 143 | [debug] QUERY OK source="ledgers" db=16.1ms decode=0.1ms 144 | SELECT t0."id", t0."amount_map", t0."inserted_at", t0."updated_at" FROM "ledgers" AS t0 [] 145 | [%Ledger{__meta__: #Ecto.Schema.Metadata<:loaded, "ledgers">, 146 | amount_map: #Money<:USD, 100>, id: 3, 147 | inserted_at: ~N[2017-02-21 00:15:40.979576], 148 | updated_at: ~N[2017-02-21 00:15:40.991391]}] 149 | 150 | ### Notes: 151 | 152 | 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. 153 | 154 | 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. 155 | 156 | 3. Serializing the amount as a string means that SQL query arithmetic and equality operators will not work as expected. You may find that `CAST`ing the string value will restore some of that functionality. For example: 157 | 158 | ```sql 159 | CAST(JSON_EXTRACT(amount_map, '$.amount') AS DECIMAL(20, 8)) AS amount; 160 | ``` 161 | 162 | ## Casting Money with Changesets 163 | 164 | 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: 165 | 166 | 1. Apply a default currency to a field input representing a money amount. 167 | 2. Add formatting options to the returned `t:Money` that will be applied when calling `Money.to_string/2` 168 | 169 | Consider the following example where a money amount will be considered in a default currency if no currency is applied: 170 | 171 | ### Schema Example 172 | 173 | The example below has three columns defined as `Money.Ecto.Composite.Type`. 174 | 175 | * `: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. 176 | 177 | * `:tax` is defined with the option `:fractional_digits`. This option will be applied when formatting `:tax` with `Money.to_string/2` 178 | 179 | * `:default` is the `t:Money` that is used if the `:value` field is `nil` both when casting and when loading from the database. 180 | 181 | ```elixir 182 | defmodule Organization do 183 | use Ecto.Schema 184 | import Ecto.Changeset 185 | 186 | @primary_key false 187 | schema "organizations" do 188 | field :payroll, Money.Ecto.Composite.Type, default_currency: :JPY 189 | field :tax, Money.Ecto.Composite.Type, fractional_digits: 4 190 | field :value, Money.Ecto.Composite.Type, default: Money.new(:USD, 0) 191 | field :name, :string 192 | field :employee_count, :integer 193 | timestamps() 194 | end 195 | 196 | def changeset(organization, params \\ %{}) do 197 | organization 198 | |> cast(params, [:payroll]) 199 | end 200 | end 201 | ``` 202 | 203 | ### Embedded schema example 204 | 205 | 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: 206 | 207 | ```elixir 208 | defmodule Organization do 209 | use Ecto.Schema 210 | import Ecto.Changeset 211 | 212 | @primary_key false 213 | schema "organizations" do 214 | field :payroll, Money.Ecto.Composite.Type, default_currency: :JPY 215 | field :tax, Money.Ecto.Composite.Type, fractional_digits: 4 216 | field :value, Money.Ecto.Composite.Type, default: Money.new(:USD, 0) 217 | field :name, :string 218 | field :employee_count, :integer 219 | embeds_many :customers, Customer do 220 | field :name, :string 221 | field :revenue, Money.Ecto.Map.Type, default: Money.new(:USD, 0) 222 | end 223 | timestamps() 224 | end 225 | ``` 226 | 227 | ### Changeset execution 228 | 229 | In the following example, a default of `:JPY` currency (using our previous schema example) will be applied when casting the changeset. 230 | 231 | ```elixir 232 | iex> changeset = Organization.changeset(%Organization{}, %{payroll: "0"}) 233 | iex> changeset.changes.payroll == Money.new(:JPY, 0) 234 | true 235 | ``` 236 | 237 | ## Postgres Database functions 238 | 239 | 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. 240 | 241 | ### Plus operator `+` 242 | 243 | `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: 244 | 245 | 1. Generate the migration by executing `mix money.gen.postgres.plus_operator` 246 | 247 | 2. Migrate the database by executing `mix ecto.migrate` 248 | 249 | 3. Formulate an Ecto query to use the `+` operator 250 | ```elixir 251 | iex> q = Ecto.Query.select Item, [l], type(fragment("price + price"), l.price) 252 | #Ecto.Query 253 | iex> Repo.one q 254 | [debug] QUERY OK source="items" db=5.6ms queue=0.5ms 255 | SELECT price + price::money_with_currency FROM "items" AS l0 [] 256 | #Money<:USD, 200>] 257 | ``` 258 | 259 | ### Aggregate functions: sum() 260 | 261 | `Money` provides a migration generator which, when migrated to the database with `mix ecto.migrate`, supports performing `sum()` aggregation on `Money` types. The steps are: 262 | 263 | 1. Generate the migration by executing `mix money.gen.postgres.sum_function` 264 | 265 | 2. Migrate the database by executing `mix ecto.migrate` 266 | 267 | 3. Formulate an Ecto query to use the aggregate function `sum()` 268 | 269 | ```elixir 270 | # Formulate the query. Note the required use of the type() 271 | # expression which is needed to inform Ecto of the return 272 | # type of the function 273 | iex> q = Ecto.Query.select Item, [l], type(sum(l.price), l.price) 274 | #Ecto.Query 275 | iex> Repo.all q 276 | [debug] QUERY OK source="items" db=6.1ms 277 | SELECT sum(l0."price")::money_with_currency FROM "items" AS l0 [] 278 | [#Money<:USD, 600>] 279 | ``` 280 | 281 | The function `Repo.aggregate/3` can also be used. However at least [ecto version 3.2.4](https://hex/pm/packages/ecto/3.2.4) is required for this to work correctly for custom ecto types such as `:money_with_currency`. 282 | 283 | ```elixir 284 | iex> Repo.aggregate(Item, :sum, :price) 285 | #Money<:USD, 600> 286 | ``` 287 | 288 | **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: 289 | ```elixir 290 | iex> Repo.all q 291 | [debug] QUERY ERROR source="items" db=4.5ms 292 | SELECT sum(l0."price")::money_with_currency FROM "items" AS l0 [] 293 | ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD 294 | ``` 295 | 296 | ### Aggregate functions: min() and max() 297 | 298 | `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: 299 | 300 | 1. Generate the migration by executing `mix money.gen.postgres.min_max_functions` 301 | 302 | 2. Migrate the database by executing `mix ecto.migrate` 303 | 304 | 3. Formulate an Ecto query to use the aggregate function `min()` or `max()` 305 | 306 | ```elixir 307 | # Formulate the query. Note the required use of the type() 308 | # expression which is needed to inform Ecto of the return 309 | # type of the function 310 | iex> q = Ecto.Query.select Item, [l], type(min(l.price), l.price) 311 | #Ecto.Query 312 | iex> Repo.all q 313 | [debug] QUERY OK source="items" db=6.1ms 314 | SELECT min(l0."price")::money_with_currency FROM "items" AS l0 [] 315 | [#Money<:USD, 600>] 316 | ``` 317 | 318 | The function `Repo.aggregate/3` can also be used. However at least [ecto version 3.2.4](https://hex/pm/packages/ecto/3.2.4) is required for this to work correctly for custom ecto types such as `:money_with_currency`. 319 | 320 | ```elixir 321 | iex> Repo.aggregate(Item, :min, :price) 322 | #Money<:USD, 600> 323 | ``` 324 | 325 | **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: 326 | ```elixir 327 | iex> Repo.all q 328 | [debug] QUERY ERROR source="items" db=4.5ms 329 | SELECT min(l0."price")::money_with_currency FROM "items" AS l0 [] 330 | ** (Postgrex.Error) ERROR 22033 (): Incompatible currency codes. Expected all currency codes to be USD 331 | ``` 332 | 333 | ### Order_by with Money 334 | 335 | 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: 336 | ```elixir 337 | # In this example we are decomposing the the composite column called 338 | # `price` and using the sub-field `amount` to perform the ordering. 339 | iex> q = from l in Item, select: l.price, order_by: fragment("amount(price)") 340 | #Ecto.Query 342 | iex> Repo.all q 343 | [debug] QUERY OK source="items" db=2.0ms 344 | SELECT l0."price" FROM "items" AS l0 ORDER BY amount(price) [] 345 | [#Money<:USD, 100.00000000>, #Money<:USD, 200.00000000>, 346 | #Money<:USD, 300.00000000>, #Money<:AUD, 300.00000000>] 347 | ``` 348 | **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. 349 | 350 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | import_config "#{Mix.env()}.exs" 6 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ex_money, 4 | auto_start_exchange_rate_service: false, 5 | open_exchange_rates_app_id: {:system, "OPEN_EXCHANGE_RATES_APP_ID"}, 6 | exchange_rates_retrieve_every: 300_000, 7 | callback_module: Money.ExchangeRates.Callback, 8 | # preload_historic_rates: {~D[2017-01-01], ~D[2017-01-02]}, 9 | log_failure: :warn, 10 | log_info: :info, 11 | log_success: :info, 12 | json_library: Jason, 13 | exchange_rates_cache: Money.ExchangeRates.Cache.Dets, 14 | default_cldr_backend: Money.Cldr 15 | 16 | config :ex_money_sql, Money.SQL.Repo, 17 | username: "kip", 18 | database: "money_dev", 19 | hostname: "localhost", 20 | pool: Ecto.Adapters.SQL.Sandbox 21 | 22 | config :ex_money_sql, 23 | ecto_repos: [Money.SQL.Repo] 24 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ex_money, 4 | auto_start_exchange_rate_service: false, 5 | open_exchange_rates_app_id: {:system, "OPEN_EXCHANGE_RATES_APP_ID"}, 6 | exchange_rates_retrieve_every: 300_000, 7 | callback_module: Money.ExchangeRates.Callback, 8 | # preload_historic_rates: {~D[2017-01-01], ~D[2017-01-02]}, 9 | log_failure: :warn, 10 | log_info: :info, 11 | log_success: :info, 12 | json_library: Jason, 13 | exchange_rates_cache: Money.ExchangeRates.Cache.Dets, 14 | default_cldr_backend: Money.Cldr 15 | 16 | config :ex_money_sql, Money.SQL.Repo, 17 | username: "kip", 18 | database: "money_dev", 19 | hostname: "localhost", 20 | pool: Ecto.Adapters.SQL.Sandbox 21 | 22 | config :ex_money_sql, 23 | ecto_repos: [Money.SQL.Repo] 24 | -------------------------------------------------------------------------------- /config/release.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | ecto_repos = [Money.SQL.Repo] 4 | 5 | Enum.each(ecto_repos, fn repo -> 6 | config :ex_money_sql, repo, 7 | username: "kip", 8 | database: "money_dev", 9 | hostname: "localhost", 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | end) 12 | 13 | config :ex_money_sql, 14 | ecto_repos: ecto_repos 15 | 16 | config :ex_money, 17 | exchange_rates_retrieve_every: :never, 18 | log_failure: nil, 19 | log_info: nil, 20 | default_cldr_backend: Test.Cldr 21 | 22 | config :logger, level: :error 23 | -------------------------------------------------------------------------------- /lib/mix/tasks/money_gen_minmax_functions.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto) do 2 | defmodule Mix.Tasks.Money.Gen.Postgres.MinMaxFunctions do 3 | use Mix.Task 4 | 5 | import Mix.Generator 6 | import Mix.Ecto, except: [migrations_path: 1] 7 | import Macro, only: [camelize: 1, underscore: 1] 8 | import Money.Migration 9 | 10 | @shortdoc "Generates a migration to create a min and max functions for :money_with_currency" 11 | 12 | @moduledoc """ 13 | Generates a migration to add min and max aggregate functions 14 | to Postgres for the `money_with_currency` type. 15 | 16 | """ 17 | 18 | @doc false 19 | @dialyzer {:no_return, run: 1} 20 | 21 | def run(args) do 22 | no_umbrella!("money.gen.postgres.minmax_functions") 23 | repos = parse_repo(args) 24 | name = "add_postgres_money_minmax_functions" 25 | 26 | Enum.each(repos, fn repo -> 27 | ensure_repo(repo, args) 28 | path = Path.relative_to(migrations_path(repo), Mix.Project.app_path()) 29 | file = Path.join(path, "#{timestamp()}_#{underscore(name)}.exs") 30 | create_directory(path) 31 | 32 | assigns = [mod: Module.concat([repo, Migrations, camelize(name)])] 33 | 34 | content = 35 | assigns 36 | |> migration_template 37 | |> format_string! 38 | 39 | create_file(file, content) 40 | 41 | if open?(file) and Mix.shell().yes?("Do you want to run this migration?") do 42 | Mix.Task.run("ecto.migrate", [repo]) 43 | end 44 | end) 45 | end 46 | 47 | defp timestamp do 48 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 49 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 50 | end 51 | 52 | defp pad(i) when i < 10, do: <> 53 | defp pad(i), do: to_string(i) 54 | 55 | embed_template(:migration, """ 56 | defmodule <%= inspect @mod %> do 57 | use Ecto.Migration 58 | 59 | def up do 60 | <%= Money.DDL.execute_each(Money.DDL.define_minmax_functions(), "|> Money.Migration.adjust_for_type(repo())") %> 61 | end 62 | 63 | def down do 64 | <%= Money.DDL.execute_each(Money.DDL.drop_minmax_functions()) %> 65 | end 66 | end 67 | """) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/mix/tasks/money_gen_sum_function.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto) do 2 | defmodule Mix.Tasks.Money.Gen.Postgres.SumFunction do 3 | use Mix.Task 4 | 5 | import Mix.Generator 6 | import Mix.Ecto, except: [migrations_path: 1] 7 | import Macro, only: [camelize: 1, underscore: 1] 8 | import Money.Migration 9 | 10 | @shortdoc "Generates a migration to create a sum function for :money_with_currency" 11 | 12 | @moduledoc """ 13 | Generates a migration to add a sum aggregate function 14 | to Postgres for the `money_with_currency` type. 15 | 16 | """ 17 | 18 | @doc false 19 | @dialyzer {:no_return, run: 1} 20 | 21 | def run(args) do 22 | no_umbrella!("money.gen.postgres.sum_function") 23 | repos = parse_repo(args) 24 | name = "add_postgres_money_sum_function" 25 | 26 | Enum.each(repos, fn repo -> 27 | ensure_repo(repo, args) 28 | path = Path.relative_to(migrations_path(repo), Mix.Project.app_path()) 29 | file = Path.join(path, "#{timestamp()}_#{underscore(name)}.exs") 30 | create_directory(path) 31 | 32 | assigns = [mod: Module.concat([repo, Migrations, camelize(name)])] 33 | 34 | content = 35 | assigns 36 | |> migration_template 37 | |> format_string! 38 | 39 | create_file(file, content) 40 | 41 | if open?(file) and Mix.shell().yes?("Do you want to run this migration?") do 42 | Mix.Task.run("ecto.migrate", [repo]) 43 | end 44 | end) 45 | end 46 | 47 | defp timestamp do 48 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 49 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 50 | end 51 | 52 | defp pad(i) when i < 10, do: <> 53 | defp pad(i), do: to_string(i) 54 | 55 | embed_template(:migration, """ 56 | defmodule <%= inspect @mod %> do 57 | use Ecto.Migration 58 | 59 | def up do 60 | <%= Money.DDL.execute_each(Money.DDL.define_sum_function(), "|> Money.Migration.adjust_for_type(repo())") %> 61 | end 62 | 63 | def down do 64 | <%= Money.DDL.execute_each(Money.DDL.drop_sum_function()) %> 65 | end 66 | end 67 | """) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/mix/tasks/money_postgres_add_function.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto) do 2 | defmodule Mix.Tasks.Money.Gen.Postgres.PlusOperator do 3 | use Mix.Task 4 | 5 | import Mix.Generator 6 | import Mix.Ecto, except: [migrations_path: 1] 7 | import Macro, only: [camelize: 1, underscore: 1] 8 | import Money.Migration 9 | 10 | @shortdoc "Generates a migration to create a `+` operator for :money_with_currency" 11 | 12 | @moduledoc """ 13 | Generates a migration to add a `+` operator 14 | to Postgres for the `money_with_currency` type 15 | 16 | """ 17 | 18 | @doc false 19 | @dialyzer {:no_return, run: 1} 20 | 21 | def run(args) do 22 | no_umbrella!("money.gen.postgres.plus_operator") 23 | repos = parse_repo(args) 24 | name = "add_postgres_money_plus_operator" 25 | 26 | Enum.each(repos, fn repo -> 27 | ensure_repo(repo, args) 28 | path = Path.relative_to(migrations_path(repo), Mix.Project.app_path()) 29 | file = Path.join(path, "#{timestamp()}_#{underscore(name)}.exs") 30 | create_directory(path) 31 | 32 | assigns = [mod: Module.concat([repo, Migrations, camelize(name)])] 33 | 34 | content = 35 | assigns 36 | |> migration_template 37 | |> format_string! 38 | 39 | create_file(file, content) 40 | 41 | if open?(file) and Mix.shell().yes?("Do you want to run this migration?") do 42 | Mix.Task.run("ecto.migrate", [repo]) 43 | end 44 | end) 45 | end 46 | 47 | defp timestamp do 48 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 49 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 50 | end 51 | 52 | defp pad(i) when i < 10, do: <> 53 | defp pad(i), do: to_string(i) 54 | 55 | embed_template(:migration, """ 56 | defmodule <%= inspect @mod %> do 57 | use Ecto.Migration 58 | 59 | def up do 60 | <%= Money.DDL.execute_each(Money.DDL.define_plus_operator(), "|> Money.Migration.adjust_for_type(repo())") %> 61 | end 62 | 63 | def down do 64 | <%= Money.DDL.execute_each(Money.DDL.drop_plus_operator()) %> 65 | end 66 | end 67 | """) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/mix/tasks/money_postgres_migration.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto) do 2 | defmodule Mix.Tasks.Money.Gen.Postgres.MoneyWithCurrency do 3 | use Mix.Task 4 | 5 | import Macro, only: [camelize: 1, underscore: 1] 6 | import Mix.Generator 7 | import Mix.Ecto, except: [migrations_path: 1] 8 | import Money.Migration 9 | 10 | @shortdoc "Generates a migration to create the :money_with_currency " <> 11 | "type for Postgres" 12 | 13 | @moduledoc """ 14 | Generates a migration to add a composite type called `:money_with_currency` 15 | to a Postgres database. 16 | 17 | The `:money_with_currency` type created is a composite type and 18 | therefore may not be supported in other databases. 19 | """ 20 | 21 | @doc false 22 | @dialyzer {:no_return, run: 1} 23 | 24 | def run(args) do 25 | no_umbrella!("money.gen.money_with_currency") 26 | repos = parse_repo(args) 27 | name = "add_money_with_currency_type_to_postgres" 28 | 29 | Enum.each(repos, fn repo -> 30 | ensure_repo(repo, args) 31 | path = Path.relative_to(migrations_path(repo), Mix.Project.app_path()) 32 | file = Path.join(path, "#{timestamp()}_#{underscore(name)}.exs") 33 | create_directory(path) 34 | 35 | assigns = [mod: Module.concat([repo, Migrations, camelize(name)])] 36 | 37 | content = 38 | assigns 39 | |> migration_template 40 | |> format_string! 41 | 42 | create_file(file, content) 43 | 44 | if open?(file) and Mix.shell().yes?("Do you want to run this migration?") do 45 | Mix.Task.run("ecto.migrate", [repo]) 46 | end 47 | end) 48 | end 49 | 50 | defp timestamp do 51 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 52 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 53 | end 54 | 55 | defp pad(i) when i < 10, do: <> 56 | defp pad(i), do: to_string(i) 57 | 58 | embed_template(:migration, """ 59 | defmodule <%= inspect @mod %> do 60 | use Ecto.Migration 61 | 62 | def up do 63 | <%= Money.DDL.execute(Money.DDL.create_money_with_currency()) %> 64 | end 65 | 66 | def down do 67 | <%= Money.DDL.execute(Money.DDL.drop_money_with_currency()) %> 68 | end 69 | end 70 | """) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/money/ddl.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.DDL do 2 | @moduledoc """ 3 | Functions to return SQL DDL commands that support the 4 | creation and deletion of the `money_with_currency` database 5 | type and associated aggregate functions. 6 | """ 7 | 8 | # @doc since: "2.7.0" 9 | 10 | @default_db :postgres 11 | 12 | @supported_db_types :code.priv_dir(:ex_money_sql) 13 | |> Path.join("SQL") 14 | |> File.ls!() 15 | |> Enum.map(&String.to_atom/1) 16 | 17 | @doc """ 18 | Returns the SQL string which when executed will 19 | define the `money_with_currency` data type. 20 | 21 | ## Arguments 22 | 23 | * `db_type`: the type of the database for which the SQL 24 | string should be returned. Defaults to `:postgres` which 25 | is currently the only supported database type. 26 | 27 | """ 28 | def create_money_with_currency(db_type \\ @default_db) do 29 | read_sql_file(db_type, "create_money_with_currency.sql") 30 | end 31 | 32 | @doc """ 33 | Returns the SQL string which when executed will 34 | drop the `money_with_currency` data type. 35 | 36 | ## Arguments 37 | 38 | * `db_type`: the type of the database for which the SQL 39 | string should be returned. Defaults to `:postgres` which 40 | is currently the only supported database type. 41 | 42 | """ 43 | def drop_money_with_currency(db_type \\ @default_db) do 44 | read_sql_file(db_type, "drop_money_with_currency.sql") 45 | end 46 | 47 | @doc """ 48 | Returns the SQL string which when executed will 49 | define sum functions for the `money_with_currency` 50 | data type. 51 | 52 | ## Arguments 53 | 54 | * `db_type`: the type of the database for which the SQL 55 | string should be returned. Defaults to `:postgres` which 56 | is currently the only supported database type. 57 | 58 | """ 59 | def define_sum_function(db_type \\ @default_db) do 60 | read_sql_file(db_type, "define_sum_function.sql") 61 | end 62 | 63 | @doc """ 64 | Returns the SQL string which when executed will 65 | drop the sum functions for the `money_with_currency` 66 | data type. 67 | 68 | ## Arguments 69 | 70 | * `db_type`: the type of the database for which the SQL 71 | string should be returned. Defaults to `:postgres` which 72 | is currently the only supported database type. 73 | 74 | """ 75 | def drop_sum_function(db_type \\ @default_db) do 76 | read_sql_file(db_type, "drop_sum_function.sql") 77 | end 78 | 79 | @doc """ 80 | Returns the SQL string which when executed will 81 | define min and max functions for the `money_with_currency` 82 | data type. 83 | 84 | ## Arguments 85 | 86 | * `db_type`: the type of the database for which the SQL 87 | string should be returned. Defaults to `:postgres` which 88 | is currently the only supported database type. 89 | 90 | """ 91 | def define_minmax_functions(db_type \\ @default_db) do 92 | read_sql_file(db_type, "define_minmax_functions.sql") 93 | end 94 | 95 | @doc """ 96 | Returns the SQL string which when executed will 97 | drop the min and max functions for the `money_with_currency` 98 | data type. 99 | 100 | ## Arguments 101 | 102 | * `db_type`: the type of the database for which the SQL 103 | string should be returned. Defaults to `:postgres` which 104 | is currently the only supported database type. 105 | 106 | """ 107 | def drop_minmax_functions(db_type \\ @default_db) do 108 | read_sql_file(db_type, "drop_minmax_functions.sql") 109 | end 110 | 111 | @doc """ 112 | Returns the SQL string which when executed will 113 | define a `+` operator for the `money_with_currency` 114 | data type. 115 | 116 | ## Arguments 117 | 118 | * `db_type`: the type of the database for which the SQL 119 | string should be returned. Defaults to `:postgres` which 120 | is currently the only supported database type. 121 | 122 | """ 123 | def define_plus_operator(db_type \\ @default_db) do 124 | read_sql_file(db_type, "define_plus_operator.sql") 125 | end 126 | 127 | @doc """ 128 | Returns the SQL string which when executed will 129 | drop the `+` operator for the `money_with_currency` 130 | data type. 131 | 132 | ## Arguments 133 | 134 | * `db_type`: the type of the database for which the SQL 135 | string should be returned. Defaults to `:postgres` which 136 | is currently the only supported database type. 137 | 138 | """ 139 | def drop_plus_operator(db_type \\ @default_db) do 140 | read_sql_file(db_type, "drop_plus_operator.sql") 141 | end 142 | 143 | @doc """ 144 | Returns the SQL string which when executed will 145 | define an infix `-` operator for the `money_with_currency` 146 | data type. 147 | 148 | ## Arguments 149 | 150 | * `db_type`: the type of the database for which the SQL 151 | string should be returned. Defaults to `:postgres` which 152 | is currently the only supported database type. 153 | 154 | """ 155 | def define_minus_operator(db_type \\ @default_db) do 156 | read_sql_file(db_type, "define_minus_operator.sql") 157 | end 158 | 159 | @doc """ 160 | Returns the SQL string which when executed will 161 | drop the infix `-` operator for the `money_with_currency` 162 | data type. 163 | 164 | ## Arguments 165 | 166 | * `db_type`: the type of the database for which the SQL 167 | string should be returned. Defaults to `:postgres` which 168 | is currently the only supported database type. 169 | 170 | """ 171 | def drop_minus_operator(db_type \\ @default_db) do 172 | read_sql_file(db_type, "drop_minus_operator.sql") 173 | end 174 | 175 | @doc """ 176 | Returns the SQL string which when executed will 177 | define a unary `-` operator for the `money_with_currency` 178 | data type. 179 | 180 | ## Arguments 181 | 182 | * `db_type`: the type of the database for which the SQL 183 | string should be returned. Defaults to `:postgres` which 184 | is currently the only supported database type. 185 | 186 | """ 187 | def define_negate_operator(db_type \\ @default_db) do 188 | read_sql_file(db_type, "define_negate_operator.sql") 189 | end 190 | 191 | @doc """ 192 | Returns the SQL string which when executed will 193 | drop the unary `-` operator for the `money_with_currency` 194 | data type. 195 | 196 | ## Arguments 197 | 198 | * `db_type`: the type of the database for which the SQL 199 | string should be returned. Defaults to `:postgres` which 200 | is currently the only supported database type. 201 | 202 | """ 203 | def drop_negate_operator(db_type \\ @default_db) do 204 | read_sql_file(db_type, "drop_negate_operator.sql") 205 | end 206 | 207 | @doc """ 208 | Returns a string that will Ecto `execute` each SQL 209 | command. 210 | 211 | ## Arguments 212 | 213 | * `sql` is a string of SQL commands that are 214 | separated by three newlines ("\\n"), 215 | that is to say two blank lines between commands 216 | in the file. 217 | 218 | ## Example 219 | 220 | iex> Money.DDL.execute "SELECT name FROM customers;\n\n\nSELECT id FROM orders;" 221 | "execute \"\"\"\nSELECT name FROM customers;\n\n\nSELECT id FROM orders;\n\"\"\"" 222 | 223 | """ 224 | def execute_each(sql, append \\ "") do 225 | sql 226 | |> String.split("\n\n\n") 227 | |> Enum.map(&execute(&1, append)) 228 | |> Enum.join("\n") 229 | end 230 | 231 | @doc """ 232 | Returns a string that will Ecto `execute` a single SQL 233 | command. 234 | 235 | ## Arguments 236 | 237 | * `sql` is a single SQL command 238 | 239 | ## Example 240 | 241 | iex> Money.DDL.execute "SELECT name FROM customers;" 242 | "execute \"SELECT name FROM customers;\"" 243 | 244 | """ 245 | def execute(sql, append \\ "") do 246 | sql = String.trim_trailing(sql, "\n") 247 | 248 | if String.contains?(sql, "\n") do 249 | "execute \"\"\"\n" <> sql <> "\n\"\"\"" 250 | else 251 | "execute " <> inspect(sql) 252 | end 253 | |> Kernel.<>(append) 254 | end 255 | 256 | defp read_sql_file(db_type, file_name) when db_type in @supported_db_types do 257 | base_dir(db_type) 258 | |> Path.join(file_name) 259 | |> File.read!() 260 | end 261 | 262 | defp read_sql_file(db_type, file_name) do 263 | raise ArgumentError, 264 | "Database type #{db_type} does not have a SQL definition " <> 265 | "file #{inspect(file_name)}" 266 | end 267 | 268 | @app Mix.Project.config()[:app] 269 | defp base_dir(db_type) do 270 | :code.priv_dir(@app) 271 | |> Path.join(["SQL", "/#{db_type}"]) 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /lib/money/ecto/money_ecto_composite_type.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Type) do 2 | defmodule Money.Ecto.Composite.Type do 3 | @moduledoc """ 4 | Implements the Ecto.Type behaviour for a user-defined Postgres composite type 5 | called `:money_with_currency`. 6 | 7 | This is the preferred option for Postgres database since the serialized money 8 | amount is stored as a decimal number, 9 | 10 | """ 11 | 12 | use Ecto.ParameterizedType 13 | 14 | @doc false 15 | @impl Ecto.ParameterizedType 16 | def type(_params) do 17 | :money_with_currency 18 | end 19 | 20 | @doc false 21 | def cast_type(opts \\ []) do 22 | Ecto.ParameterizedType.init(__MODULE__, opts) 23 | end 24 | 25 | @doc false 26 | @impl Ecto.ParameterizedType 27 | def init(opts) do 28 | opts 29 | |> Keyword.delete(:field) 30 | |> Keyword.delete(:schema) 31 | |> Keyword.delete(:default) 32 | |> Keyword.delete(:source) 33 | |> Keyword.delete(:autogenerate) 34 | |> Keyword.delete(:read_after_writes) 35 | |> Keyword.delete(:load_in_query) 36 | |> Keyword.delete(:redact) 37 | |> Keyword.delete(:skip_default_validation) 38 | end 39 | 40 | # When loading from the database 41 | 42 | @doc false 43 | @impl Ecto.ParameterizedType 44 | def load(tuple, loader \\ nil, params \\ []) 45 | 46 | def load(nil, _loader, _params) do 47 | {:ok, nil} 48 | end 49 | 50 | def load({currency, amount}, _loader, params) do 51 | currency = String.trim_trailing(currency) 52 | 53 | with {:ok, currency_code} <- Money.validate_currency(currency), 54 | %Money{} = money <- Money.new(currency_code, amount, params) do 55 | {:ok, money} 56 | else 57 | _ -> :error 58 | end 59 | end 60 | 61 | def load(_, _, _) do 62 | :error 63 | end 64 | 65 | # Dumping to the database. We make the assumption that 66 | # since we are dumping from %Money{} structs that the 67 | # data is ok. 68 | 69 | @doc false 70 | @impl Ecto.ParameterizedType 71 | def dump(money, dumper \\ nil, params \\ []) 72 | 73 | def dump(%Money{} = money, dumper, _params) do 74 | if embedded_dump?(dumper) do 75 | Money.Ecto.Map.Type.dump(money) 76 | else 77 | {:ok, {to_string(money.currency), money.amount}} 78 | end 79 | end 80 | 81 | def dump(nil, _, _) do 82 | {:ok, nil} 83 | end 84 | 85 | def dump(_, _, _) do 86 | :error 87 | end 88 | 89 | # Detects if we are being called on behalf the embedded dumper. 90 | # In this case, we want to produce a map that can be serialized 91 | # to JSON. See [papertrail issue](https://github.com/izelnakri/paper_trail/issues/230). 92 | 93 | defp embedded_dump?(nil) do 94 | false 95 | end 96 | 97 | defp embedded_dump?(dumper) when is_function(dumper, 2) do 98 | case Function.info(dumper, :name) do 99 | {:name, :"-embedded_dump/3-fun-0-"} -> 100 | Function.info(dumper, :module) == {:module, Ecto.Type} 101 | 102 | _other -> 103 | false 104 | end 105 | end 106 | 107 | # Casting in changesets 108 | 109 | @doc """ 110 | Casts user input into `t:Money.t/0` struct. 111 | 112 | See `Money,Ecto.Composite.Type.cast/2`. 113 | 114 | """ 115 | def cast(money) do 116 | cast(money, []) 117 | end 118 | 119 | @doc """ 120 | Casts user input into `t:Money.t/0` struct. 121 | 122 | Its important to note that user input is expected 123 | to be in the format expected for the current locale 124 | (as determined by `Cldr.get_locale/0`) or in the locale 125 | specified by the `:locale` parameter. 126 | 127 | This can lead to unexpected results if the locale 128 | and the user data are not aligned. Consider the following 129 | example. 130 | 131 | * The current locale is `:de`. This means that the 132 | decimal separatator is defined ot be `,` and the 133 | grouping separatr is defined to be `.` 134 | 135 | * The user data (often, but not always, from a form) is 136 | `%{"currency" => "EUR", amount: "1.00"}`. 137 | 138 | In this case `cast/2` will return the equivalent of 139 | `Money.new(:EUR, "100")` *not* `Money.new(:EUR, "1.00")`. 140 | 141 | ### Arguments 142 | 143 | * `money` is a map containing the keys `currency` and`amount` 144 | as either strings or atoms OR a string that can be parsed 145 | to produce a `t:Money.t/0` struct. 146 | 147 | * `params` is a keyword list of option that is passed to 148 | `Money.new/3`. 149 | 150 | ### Returns 151 | 152 | * `{:ok, money}` or 153 | 154 | * `:error` 155 | 156 | ### Notes 157 | 158 | * If either the `money` or `amount` values are 159 | `nil`, then `{:ok, nil}` will be returned. 160 | 161 | * `amount` can be a string, an integer or a 162 | `t:Decimal.t/0`. 163 | 164 | * If a string is parsed then an attempt to parse 165 | the string into a currency and an amount is made 166 | using `Money.parse/2`. Parsing is locale specific. 167 | 168 | """ 169 | 170 | @impl Ecto.ParameterizedType 171 | def cast(nil, _params) do 172 | {:ok, nil} 173 | end 174 | 175 | def cast(%Money{} = money, _params) do 176 | {:ok, money} 177 | end 178 | 179 | def cast(%{"currency" => _, "amount" => ""}, _params) do 180 | {:ok, nil} 181 | end 182 | 183 | def cast(%{"currency" => _, "amount" => nil}, _params) do 184 | {:ok, nil} 185 | end 186 | 187 | def cast(%{"currency" => nil, "amount" => _amount}, _params) do 188 | {:error, exception: Money.UnknownCurrencyError, message: "Currency must not be `nil`"} 189 | end 190 | 191 | def cast(%{"currency" => currency, "amount" => amount}, params) 192 | when (is_binary(currency) or is_atom(currency)) and is_integer(amount) do 193 | with %Money{} = money <- Money.new(currency, amount, params) do 194 | {:ok, money} 195 | else 196 | {:error, {exception, message}} -> {:error, exception: exception, message: message} 197 | end 198 | end 199 | 200 | def cast(%{"currency" => currency, "amount" => amount}, params) 201 | when (is_binary(currency) or is_atom(currency)) and is_binary(amount) do 202 | with %Money{} = money <- Money.new(currency, amount, params) do 203 | {:ok, money} 204 | else 205 | {:error, {exception, message}} -> {:error, exception: exception, message: message} 206 | end 207 | end 208 | 209 | def cast(%{"currency" => currency, "amount" => %Decimal{} = amount}, params) 210 | when is_binary(currency) or is_atom(currency) do 211 | with %Money{} = money <- Money.new(currency, amount, params) do 212 | {:ok, money} 213 | else 214 | {:error, {exception, message}} -> {:error, exception: exception, message: message} 215 | end 216 | end 217 | 218 | def cast(%{currency: currency, amount: amount}, params) do 219 | cast(%{"currency" => currency, "amount" => amount}, params) 220 | end 221 | 222 | def cast(string, params) when is_binary(string) do 223 | case Money.parse(string, params) do 224 | {:error, {exception, message}} -> {:error, exception: exception, message: message} 225 | money -> {:ok, money} 226 | end 227 | end 228 | 229 | def cast(_money, _params) do 230 | :error 231 | end 232 | 233 | # embed_as is set to :dump because if it is set to 234 | # `:self` then `cast/2` will be called when loading. And 235 | # since casting is locale-sensitive, the results may 236 | # not be correct due to variations in the decimal and grouping 237 | # separators for different locales. This is because when casting 238 | # we don't know if the data is coming from user input (and therefore should 239 | # be locale awware) or from some JSON serialization (in which 240 | # case it should not be locale aware). 241 | 242 | @doc false 243 | def embed_as(term), do: embed_as(term, []) 244 | 245 | @doc false 246 | @impl Ecto.ParameterizedType 247 | def embed_as(_term, _params), do: :dump 248 | 249 | @doc """ 250 | Compares two money structs and return an boolean 251 | indicating if they are equal or not. 252 | 253 | ### Arguments 254 | 255 | * `money1` is any `t:Money.t/0` 256 | 257 | * `money2` is any `t:Money.t/0` 258 | 259 | ### Returns 260 | 261 | * `true` or `false`. 262 | 263 | """ 264 | def equal?(money1, money2), do: equal?(money1, money2, []) 265 | 266 | @doc """ 267 | Compares two money structs and return an boolean 268 | indicating if they are equal or not. 269 | 270 | ### Arguments 271 | 272 | * `money1` is any `t:Money.t/0` 273 | 274 | * `money2` is any `t:Money.t/0` 275 | 276 | * `params` which is ignored. 277 | 278 | ### Returns 279 | 280 | * `true` or `false`. 281 | 282 | """ 283 | @impl Ecto.ParameterizedType 284 | def equal?(money1, money2, _params) do 285 | Money.equal?(money1, money2) 286 | end 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /lib/money/ecto/money_ecto_map_type.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Type) do 2 | defmodule Money.Ecto.Map.Type do 3 | @moduledoc """ 4 | Implements Ecto.Type behaviour for Money, where the underlying schema type 5 | is a map. 6 | 7 | This is the required option for databases such as MySQL that do not support 8 | composite types. 9 | 10 | In order to preserve precision, the amount is serialized as a string since the 11 | JSON representation of a numeric value is either an integer or a float. 12 | 13 | `Decimal.to_string/1` is not guaranteed to produce a string that will round-trip 14 | convert back to the identical number. 15 | """ 16 | 17 | use Ecto.ParameterizedType 18 | 19 | defdelegate init(params), to: Money.Ecto.Composite.Type 20 | defdelegate cast(money), to: Money.Ecto.Composite.Type 21 | defdelegate cast(money, params), to: Money.Ecto.Composite.Type 22 | 23 | # New for ecto_sql 3.2 24 | defdelegate embed_as(term), to: Money.Ecto.Composite.Type 25 | defdelegate embed_as(term, params), to: Money.Ecto.Composite.Type 26 | defdelegate equal?(term1, term2), to: Money.Ecto.Composite.Type 27 | defdelegate equal?(term1, term2, params), to: Money.Ecto.Composite.Type 28 | 29 | def type(_params) do 30 | :map 31 | end 32 | 33 | def load(money, loader \\ nil, params \\ []) 34 | 35 | def load(nil, _loader, _params) do 36 | {:ok, nil} 37 | end 38 | 39 | def load(%{"currency" => currency, "amount" => amount}, _loader, params) 40 | when is_binary(amount) do 41 | with {amount, ""} <- Decimal.parse(amount), 42 | {:ok, currency} <- Money.validate_currency(currency), 43 | %Money{} = money <- Money.new(currency, amount, params) do 44 | {:ok, money} 45 | else 46 | _ -> :error 47 | end 48 | end 49 | 50 | def load(%{"currency" => currency, "amount" => amount}, _loader, params) 51 | when is_integer(amount) do 52 | with {:ok, currency} <- Money.validate_currency(currency) do 53 | {:ok, Money.new(currency, amount, params)} 54 | else 55 | _ -> :error 56 | end 57 | end 58 | 59 | def load(_, _, _) do 60 | :error 61 | end 62 | 63 | def dump(money, dumper \\ nil, params \\ []) 64 | 65 | def dump(%Money{currency: currency, amount: %Decimal{} = amount}, _dumper, _params) do 66 | {:ok, %{"currency" => to_string(currency), "amount" => Decimal.to_string(amount)}} 67 | end 68 | 69 | def dump(nil, _, _) do 70 | {:ok, nil} 71 | end 72 | 73 | def dump(_, _, _) do 74 | :error 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/money/ecto/money_ecto_query_api.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Query.API) do 2 | defmodule Money.Ecto.Query.API do 3 | @moduledoc """ 4 | Provides several helpers to query DB for the `Money` type. 5 | 6 | ### Usage 7 | 8 | In a module where you wish to use these helpers, add: 9 | 10 | use Money.Ecto.Query.API 11 | 12 | The default usage is designed to work with the `Money.Ecto.Composite.Type` 13 | implementation for Postgres databases. Altenative impkmentations can be 14 | made that comply with the `Money.Ecto.Query.API` behaviour. In that case 15 | 16 | use Money.Ecto.Query.API, adapter: MyAdapterModule 17 | 18 | See the Adapters section below. 19 | 20 | ### Implementation 21 | 22 | Under the hood it delegates to 23 | [`Ecto.Query.API.fragment/1`](https://hexdocs.pm/ecto/Ecto.Query.API.html#fragment/1-defining-custom-functions-using-macros-and-fragment), 24 | but might be helpful for compile-type sanity check for typos and 25 | better language server support. 26 | 27 | It is also designed to be an implementation-agnostic, meaning one can use 28 | these helpers without a necessity to explicitly specify a backing type. 29 | 30 | ### Adapters 31 | 32 | The default implementation recommends a `Composite` adapter, which is used by default. 33 | To use it with, say, `MySQL`, one should implement this behaviour for `MySQL` and declare 34 | the implementation as `use Money.Ecto.Query.API, adapter: MyImpl.MySQL.Adapter` 35 | 36 | Although the library provides the MySQL adapter too (`Money.Ecto.Query.API.Map.MySQL`) 37 | but it is not actively maintained, so use it on your own. 38 | 39 | If for some reason you use `Map` type with `Postgres`, helpers are still available 40 | with `use Money.Ecto.Query.API, adapter: Money.Ecto.Query.API.Map.Postgres` 41 | """ 42 | 43 | @doc """ 44 | Native implementation of how to retrieve `amount` from the DB. 45 | 46 | For `Postgres`, it delegates to the function on the composite type, 47 | for other implementation it should return a `Ecto.Query.API.fragment/1`. 48 | """ 49 | @macrocallback amount(Macro.t()) :: Macro.t() 50 | 51 | @doc """ 52 | Native implementation of how to retrieve `currency_code` from the DB. 53 | 54 | For `Postgres`, it delegates to the function on the composite type, 55 | for other implementation it should return a `Ecto.Query.API.fragment/1`. 56 | """ 57 | @macrocallback currency_code(Macro.t()) :: Macro.t() 58 | 59 | @doc """ 60 | Native implementation of how to `sum` several records having a field 61 | of the type `Money` in the DB. 62 | 63 | For `Postgres`, it delegates to the function on the composite type, 64 | for other implementation it should return a `Ecto.Query.API.fragment/1`. 65 | """ 66 | @macrocallback sum(Macro.t(), cast? :: boolean()) :: Macro.t() 67 | 68 | @doc """ 69 | Cast decimal to the value accepted by the database. 70 | """ 71 | @callback cast_decimal(Decimal.t()) :: any() 72 | 73 | @doc false 74 | defmacro __using__(opts \\ []) 75 | 76 | @doc false 77 | defmacro __using__([]), 78 | do: do_using(Money.Ecto.Query.API.Composite) 79 | 80 | @doc false 81 | defmacro __using__(adapter: adapter), 82 | do: do_using(adapter) 83 | 84 | defp do_using(adapter) do 85 | quote do 86 | import unquote(adapter) 87 | import Money.Ecto.Query.API 88 | end 89 | end 90 | 91 | @doc """ 92 | `Ecto.Query.API` helper, allowing to filter records having the same currency. 93 | 94 | _Example:_ 95 | 96 | ```elixir 97 | iex> Organization 98 | ...> |> where([o], currency_eq(o.payroll, :AUD)) 99 | ...> |> select([o], o.payroll) 100 | ...> |> Repo.all() 101 | [Money.new(:AUD, "50"), Money.new(:AUD, "70") 102 | ``` 103 | """ 104 | defmacro currency_eq(field, currency) when is_atom(currency) do 105 | currency = currency |> to_string() |> String.upcase() 106 | do_currency_eq(field, currency) 107 | end 108 | 109 | defmacro currency_eq(field, currency) when is_binary(currency) do 110 | do_currency_eq(field, currency) 111 | end 112 | 113 | defp do_currency_eq(field, <<_::binary-size(3)>> = currency) do 114 | quote do: currency_code(unquote(field)) == ^unquote(currency) 115 | end 116 | 117 | @doc """ 118 | `Ecto.Query.API` helper, allowing to filter records having the same amount. 119 | 120 | _Example:_ 121 | 122 | ```elixir 123 | iex> Organization 124 | ...> |> where([o], amount_eq(o.payroll, 100)) 125 | ...> |> select([o], o.payroll) 126 | ...> |> Repo.all() 127 | [Money.new(:EUR, "100"), Money.new(:USD, "100") 128 | ``` 129 | """ 130 | defmacro amount_eq(field, amount) when is_integer(amount) do 131 | quote do 132 | amount(unquote(field)) == ^unquote(amount) 133 | end 134 | end 135 | 136 | @doc """ 137 | `Ecto.Query.API` helper, allowing to filter records having the same amount and currency. 138 | 139 | _Example:_ 140 | 141 | ```elixir 142 | iex> Organization 143 | ...> |> where([o], money_eq(o.payroll, Money.new!(100, :USD))) 144 | ...> |> select([o], o.payroll) 145 | ...> |> Repo.all() 146 | [Money.new(:USD, "100"), Money.new(:USD, "100")] 147 | ``` 148 | """ 149 | defmacro money_eq(field, money) do 150 | quote do 151 | amount(unquote(field)) == ^cast_decimal(unquote(money).amount) and 152 | currency_code(unquote(field)) == ^to_string(unquote(money).currency) 153 | end 154 | end 155 | 156 | @doc """ 157 | `Ecto.Query.API` helper, allowing to filter records having one 158 | of currencies given as an argument. 159 | 160 | _Example:_ 161 | 162 | ```elixir 163 | iex> Organization 164 | ...> |> where([o], currency_in(o.payroll, [:USD, :EUR])) 165 | ...> |> select([o], o.payroll) 166 | ...> |> Repo.all() 167 | [Money.new(:EUR, "100"), Money.new(:USD, "100")] 168 | ``` 169 | """ 170 | defmacro currency_in(field, currencies) when is_list(currencies) do 171 | currencies = currencies |> Enum.map(&to_string/1) |> Enum.map(&String.upcase/1) 172 | 173 | quote do 174 | currency_code(unquote(field)) in ^unquote(currencies) 175 | end 176 | end 177 | 178 | @doc """ 179 | `Ecto.Query.API` helper, allowing to filter records having the amount 180 | in the range given as an argument. 181 | 182 | Accepts `[min, max]`, `{min. max}`, and `min..max` as a range. 183 | 184 | _Example:_ 185 | 186 | ```elixir 187 | iex> Organization 188 | ...> |> where([o], amount_in(o.payroll, 90..110)) 189 | ...> |> select([o], o.payroll) 190 | ...> |> Repo.all() 191 | [Money.new(:EUR, "100"), Money.new(:USD, "100")] 192 | ``` 193 | """ 194 | defmacro amount_in(field, [min, max]), 195 | do: do_amount_in(field, min, max) 196 | 197 | defmacro amount_in(field, {min, max}), 198 | do: do_amount_in(field, min, max) 199 | 200 | defmacro amount_in(field, {:.., _, [min, max]}), 201 | do: do_amount_in(field, min, max) 202 | 203 | defmacro amount_in(field, {:"..//", _, [min, max, 1]}), 204 | do: do_amount_in(field, min, max) 205 | 206 | defmacro amount_in(field, {:"..//", _, [min, max, {:-, _, [1]}]}), 207 | do: do_amount_in(field, max, min) 208 | 209 | defmacro amount_in(field, {:"..//", _, [_min, _max, step]}) do 210 | raise CompileError, 211 | file: __CALLER__.file, 212 | line: __CALLER__.line, 213 | description: 214 | "Ranges with a step (#{step}) are not supported for [#{Macro.to_string(field)}]" 215 | end 216 | 217 | defp do_amount_in(field, min, max) do 218 | quote do 219 | amount_ge(unquote(field), unquote(min)) and amount_le(unquote(field), unquote(max)) 220 | end 221 | end 222 | 223 | @doc """ 224 | `Ecto.Query.API` helper, allowing to filter records having the amount 225 | greater than or equal to the one given as an argument. 226 | 227 | _Example:_ 228 | 229 | ```elixir 230 | iex> Organization 231 | ...> |> where([o], amount_ge(o.payroll, 90)) 232 | ...> |> select([o], o.payroll) 233 | ...> |> Repo.all() 234 | [Money.new(:AUD, "90"), Money.new(:USD, "100")] 235 | ``` 236 | """ 237 | defmacro amount_ge(field, num) do 238 | quote do 239 | amount(unquote(field)) >= ^unquote(num) 240 | end 241 | end 242 | 243 | @doc """ 244 | `Ecto.Query.API` helper, allowing to filter records having the amount 245 | less than or equal to the one given as an argument. 246 | 247 | _Example:_ 248 | 249 | ```elixir 250 | iex> Organization 251 | ...> |> where([o], amount_le(o.payroll, 110)) 252 | ...> |> select([o], o.payroll) 253 | ...> |> Repo.all() 254 | [Money.new(:EUR, "100"), Money.new(:USD, "110")] 255 | ``` 256 | """ 257 | defmacro amount_le(field, num) do 258 | quote do 259 | amount(unquote(field)) <= ^unquote(num) 260 | end 261 | end 262 | 263 | @doc """ 264 | `Ecto.Query.API` helper, allowing to aggregate by currency, summing amount. 265 | For more sophisticated aggregation, resort to raw `fragment`. 266 | 267 | _Example:_ 268 | 269 | ```elixir 270 | iex> Organization 271 | ...> |> where([o], o.name == ^"Lemon Inc.") 272 | ...> |> total_by([o], o.payroll) 273 | ...> |> Repo.all() 274 | [Money.new(:EUR, "100"), Money.new(:USD, "210")] 275 | ``` 276 | """ 277 | defmacro total_by(query, binding, field) do 278 | quote do 279 | unquote(query) 280 | |> where(unquote(binding), not is_nil(unquote(field))) 281 | |> group_by(unquote(binding), [currency_code(unquote(field))]) 282 | |> select(unquote(binding), sum(unquote(field), true)) 283 | end 284 | end 285 | 286 | @doc """ 287 | `Ecto.Query.API` helper, allowing to aggregate by currency, suming amount. 288 | Same as `total_by/3`, but for the single currency only. 289 | 290 | _Example:_ 291 | 292 | ```elixir 293 | iex> Organization 294 | ...> |> where([o], o.name == ^"Lemon Inc.") 295 | ...> |> total_by([o], o.payroll, :USD) 296 | ...> |> Repo.one() 297 | [Money.new(:USD, "210")] 298 | ``` 299 | """ 300 | defmacro total_by(query, binding, field, currency) do 301 | currency = currency |> to_string() |> String.upcase() |> List.wrap() 302 | 303 | quote do 304 | unquote(query) 305 | |> where(unquote(binding), not is_nil(unquote(field))) 306 | |> where(unquote(binding), currency_in(unquote(field), unquote(currency))) 307 | |> group_by(unquote(binding), [currency_code(unquote(field))]) 308 | |> select(unquote(binding), sum(unquote(field), true)) 309 | end 310 | end 311 | end 312 | end 313 | -------------------------------------------------------------------------------- /lib/money/ecto/query_api/composite.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Query.API) do 2 | defmodule Money.Ecto.Query.API.Composite do 3 | @moduledoc false 4 | 5 | @behaviour Money.Ecto.Query.API 6 | 7 | @impl Money.Ecto.Query.API 8 | defmacro amount(field), 9 | do: quote(do: fragment("amount(?)", unquote(field))) 10 | 11 | @impl Money.Ecto.Query.API 12 | defmacro currency_code(field), 13 | do: quote(do: fragment("currency_code(?)", unquote(field))) 14 | 15 | @impl Money.Ecto.Query.API 16 | defmacro sum(field, cast? \\ true) 17 | 18 | @impl Money.Ecto.Query.API 19 | defmacro sum(field, false), 20 | do: quote(do: fragment("sum(?)", unquote(field))) 21 | 22 | @impl Money.Ecto.Query.API 23 | defmacro sum(field, true), 24 | do: quote(do: type(sum(unquote(field)), unquote(field))) 25 | 26 | @impl Money.Ecto.Query.API 27 | def cast_decimal(%Decimal{} = d), do: d 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/money/ecto/query_api/map/mysql.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Query.API) do 2 | defmodule Money.Ecto.Query.API.Map.MySQL do 3 | @moduledoc false 4 | 5 | @behaviour Money.Ecto.Query.API 6 | 7 | @impl Money.Ecto.Query.API 8 | defmacro amount(field), 9 | do: quote(do: fragment(~S|CAST(JSON_EXTRACT(?, "$.amount") AS UNSIGNED)|, unquote(field))) 10 | 11 | @impl Money.Ecto.Query.API 12 | defmacro currency_code(field), 13 | do: quote(do: fragment(~S|JSON_EXTRACT(?, "$.currency")|, unquote(field))) 14 | 15 | @impl Money.Ecto.Query.API 16 | defmacro sum(field, cast? \\ true) 17 | 18 | @sum_fragment """ 19 | IF(COUNT(DISTINCT(JSON_EXTRACT(?, "$.currency"))) < 2, 20 | JSON_OBJECT( 21 | "currency", JSON_EXTRACT(JSON_ARRAYAGG(JSON_EXTRACT(?, "$.currency")), "$[0]"), 22 | "amount", SUM(CAST(JSON_EXTRACT(?, "$.amount") AS UNSIGNED)) 23 | ), 24 | NULL 25 | ) 26 | """ 27 | @impl Money.Ecto.Query.API 28 | defmacro sum(field, false) do 29 | quote do: fragment(unquote(@sum_fragment), unquote(field), unquote(field), unquote(field)) 30 | end 31 | 32 | @impl Money.Ecto.Query.API 33 | defmacro sum(field, true), 34 | do: quote(do: type(sum(unquote(field), false), unquote(field))) 35 | 36 | @impl Money.Ecto.Query.API 37 | def cast_decimal(%Decimal{} = d), do: Decimal.to_integer(d) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/money/ecto/query_api/map/postgres.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Query.API) do 2 | defmodule Money.Ecto.Query.API.Map.Postgres do 3 | @moduledoc false 4 | 5 | @behaviour Money.Ecto.Query.API 6 | 7 | @impl Money.Ecto.Query.API 8 | defmacro amount(field), 9 | do: quote(do: fragment(~S|(?->>'amount')::int|, unquote(field))) 10 | 11 | @impl Money.Ecto.Query.API 12 | defmacro currency_code(field), 13 | do: quote(do: fragment(~S|?->>'currency'|, unquote(field))) 14 | 15 | @impl Money.Ecto.Query.API 16 | defmacro sum(field, cast? \\ true) 17 | 18 | @sum_fragment """ 19 | CASE COUNT(DISTINCT(?->>'currency')) 20 | WHEN 0 THEN JSON_BUILD_OBJECT('currency', NULL, 'amount', 0) 21 | WHEN 1 THEN JSON_BUILD_OBJECT('currency', MAX(?->>'currency'), 'amount', SUM((?->>'amount')::int)) 22 | ELSE NULL 23 | END 24 | """ 25 | @impl Money.Ecto.Query.API 26 | defmacro sum(field, false) do 27 | quote do: fragment(unquote(@sum_fragment), unquote(field), unquote(field), unquote(field)) 28 | end 29 | 30 | @impl Money.Ecto.Query.API 31 | defmacro sum(field, true), 32 | do: quote(do: type(sum(unquote(field), false), unquote(field))) 33 | 34 | @impl Money.Ecto.Query.API 35 | def cast_decimal(%Decimal{} = d), do: Decimal.to_integer(d) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/money/migration.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Migration do 2 | @moduledoc false 3 | 4 | def adjust_for_type(query, repo) do 5 | case postgres_money_with_currency_type(repo) do 6 | :varchar -> 7 | query 8 | :char_3 -> 9 | String.replace(query, "varchar", "char(3)") 10 | :not_postgres -> 11 | raise "Repo does not appear to be a Postgresql database" 12 | nil -> 13 | raise "No money_with_currency type is defined. " <> 14 | "Please run `mix money.gen.money_with_currency && mix ecto.migrate` first." 15 | end 16 | end 17 | 18 | def postgres_money_with_currency_type(repo) do 19 | query = read_sql_file("get_currency_code_type.sql") 20 | case repo.query!(query, [], log: false) do 21 | %Postgrex.Result{rows: [["character varying"]]} -> 22 | :varchar 23 | %Postgrex.Result{rows: [["character(3)"]]} -> 24 | :char_3 25 | %Postgrex.Result{rows: []} -> 26 | nil 27 | _other -> 28 | :not_postgres 29 | end 30 | end 31 | 32 | if Code.ensure_loaded?(Ecto.Migrator) && function_exported?(Ecto.Migrator, :migrations_path, 1) do 33 | def migrations_path(repo) do 34 | Ecto.Migrator.migrations_path(repo) 35 | end 36 | end 37 | 38 | if Code.ensure_loaded?(Mix.Ecto) && function_exported?(Mix.Ecto, :migrations_path, 1) do 39 | def migrations_path(repo) do 40 | Mix.Ecto.migrations_path(repo) 41 | end 42 | end 43 | 44 | if Code.ensure_loaded?(Code) && function_exported?(Code, :format_string!, 1) do 45 | @spec format_string!(String.t()) :: iodata() 46 | @dialyzer {:no_return, format_string!: 1} 47 | def format_string!(string) do 48 | Code.format_string!(string) 49 | end 50 | else 51 | @spec format_string!(String.t()) :: iodata() 52 | def format_string!(string) do 53 | string 54 | end 55 | end 56 | 57 | defp read_sql_file(file_name) do 58 | :code.priv_dir(:ex_money_sql) 59 | |> Path.join(["SQL", "/postgres", "/#{file_name}"]) 60 | |> File.read!() 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/money/validate.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Validate do 2 | @moduledoc """ 3 | Implements Ecto validations for the `t:Money.t/0` type based upon the 4 | `Money.Ecto.Composite.Type` type. 5 | 6 | """ 7 | 8 | @money_validators %{ 9 | less_than: "must be less than %{money}", 10 | greater_than: "must be greater than %{money}", 11 | less_than_or_equal_to: "must be less than or equal to %{money}", 12 | greater_than_or_equal_to: "must be greater than or equal to %{money}", 13 | equal_to: "must be equal to %{money}", 14 | not_equal_to: "must be not equal to %{money}" 15 | } 16 | 17 | @doc """ 18 | Validates the properties of a `t:Money.t/0`. 19 | 20 | This function, including its options, is designed to 21 | mirror the function `Ecto.Changeset.validate_number/3`. 22 | 23 | ## Options 24 | 25 | * `:less_than` 26 | * `:greater_than` 27 | * `:less_than_or_equal_to` 28 | * `:greater_than_or_equal_to` 29 | * `:equal_to` 30 | * `:not_equal_to` 31 | * `:message` - the message on failure, defaults to one of: 32 | * "must be less than %{money}" 33 | * "must be greater than %{money}" 34 | * "must be less than or equal to %{money}" 35 | * "must be greater than or equal to %{money}" 36 | * "must be equal to %{money}" 37 | * "must be not equal to %{money}" 38 | 39 | ## Examples 40 | 41 | validate_money(changeset, :value, less_than: Money.new(:USD, 200)) 42 | validate_money(changeset, :value, less_than_or_equal_to: Money.new(:USD, 200) 43 | validate_money(changeset, :value, less_than_or_equal_to: Money.new(:USD, 100)) 44 | validate_money(changeset, :value, greater_than: Money.new(:USD, 50)) 45 | validate_money(changeset, :value, greater_than_or_equal_to: Money.new(:USD, 50)) 46 | validate_money(changeset, :value, greater_than_or_equal_to: Money.new(:USD, 100)) 47 | 48 | """ 49 | @spec validate_money(Ecto.Changeset.t(), atom, Keyword.t()) :: Ecto.Changeset.t() 50 | def validate_money(changeset, field, opts) do 51 | Ecto.Changeset.validate_change(changeset, field, {:money, opts}, fn 52 | field, value -> 53 | {message, opts} = Keyword.pop(opts, :message) 54 | 55 | Enum.find_value(opts, [], fn {spec_key, target_value} -> 56 | case Map.fetch(@money_validators, spec_key) do 57 | {:ok, default_message} -> 58 | validate_money(field, value, message || default_message, spec_key, target_value) 59 | 60 | :error -> 61 | supported_options = @money_validators |> Map.keys() 62 | 63 | raise ArgumentError, """ 64 | unknown option #{inspect(spec_key)} given to validate_money/3 65 | The supported options are: 66 | #{supported_options} 67 | """ 68 | end 69 | end) 70 | end) 71 | end 72 | 73 | defp validate_money(field, %Money{} = value, message, spec_key, %Money{} = target_value) do 74 | result = Money.compare(value, target_value) 75 | 76 | case money_compare(result, spec_key) do 77 | true -> 78 | nil 79 | 80 | false -> 81 | [{field, {message, validation: :money, kind: spec_key, money: target_value}}] 82 | 83 | {:error, {_exception, reason}} -> 84 | [{field, {reason, validation: :money, kind: spec_key, money: target_value}}] 85 | end 86 | end 87 | 88 | defp validate_money(_field, value, _message, _spec_key, %Money{} = _target_value) do 89 | raise ArgumentError, "expected value to be of type Money, got: #{inspect(value)}" 90 | end 91 | 92 | defp validate_money(_field, %Money{} = _value, _message, _spec_key, target_value) do 93 | raise ArgumentError, 94 | "expected target_value to be of type Money, got: #{inspect(target_value)}" 95 | end 96 | 97 | defp validate_money(_field, value, _message, _spec_key, target_value) do 98 | raise ArgumentError, 99 | "expected value and target_value to be of type Money, " <> 100 | "got value: #{inspect(value)} and target_value: #{target_value}" 101 | end 102 | 103 | defp money_compare(:lt, spec), do: spec in [:less_than, :less_than_or_equal_to, :not_equal_to] 104 | 105 | defp money_compare(:gt, spec), 106 | do: spec in [:greater_than, :greater_than_or_equal_to, :not_equal_to] 107 | 108 | defp money_compare(:eq, spec), 109 | do: spec in [:equal_to, :less_than_or_equal_to, :greater_than_or_equal_to] 110 | 111 | defp money_compare(other, _spec), do: other 112 | end 113 | -------------------------------------------------------------------------------- /lib/money_sql.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Sql do 2 | @moduledoc false 3 | # require Money 4 | end 5 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kipcole9/money_sql/a79ad430a8804b1f8c9eb93ad18a29d25d966700/logo.png -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Sql.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.11.0" 5 | 6 | def project do 7 | [ 8 | app: :ex_money_sql, 9 | version: @version, 10 | elixir: "~> 1.11", 11 | name: "Money SQL", 12 | source_url: "https://github.com/kipcole9/money_sql", 13 | docs: docs(), 14 | build_embedded: Mix.env() == :prod, 15 | start_permanent: Mix.env() == :prod, 16 | deps: deps(), 17 | description: description(), 18 | package: package(), 19 | test_coverage: [tool: ExCoveralls], 20 | aliases: aliases(), 21 | elixirc_paths: elixirc_paths(Mix.env()), 22 | dialyzer: [ 23 | ignore_warnings: ".dialyzer_ignore_warnings", 24 | plt_add_apps: ~w(inets jason mix ecto ecto_sql eex)a 25 | ], 26 | compilers: Mix.compilers() 27 | ] 28 | end 29 | 30 | defp description do 31 | "Money functions for the serialization a money data type." 32 | end 33 | 34 | defp package do 35 | [ 36 | maintainers: ["Kip Cole"], 37 | licenses: ["Apache-2.0"], 38 | links: %{ 39 | "GitHub" => "https://github.com/kipcole9/money_sql", 40 | "Readme" => "https://github.com/kipcole9/money_sql/blob/v#{@version}/README.md", 41 | "Changelog" => "https://github.com/kipcole9/money_sql/blob/v#{@version}/CHANGELOG.md" 42 | }, 43 | files: [ 44 | "lib", 45 | "priv/SQL", 46 | "config", 47 | "mix.exs", 48 | "README.md", 49 | "CHANGELOG.md", 50 | "LICENSE.md" 51 | ] 52 | ] 53 | end 54 | 55 | def application do 56 | [ 57 | extra_applications: [:logger] 58 | ] 59 | end 60 | 61 | def docs do 62 | [ 63 | source_ref: "v#{@version}", 64 | extras: ["README.md", "CHANGELOG.md", "LICENSE.md"], 65 | main: "readme", 66 | logo: "logo.png", 67 | skip_undefined_reference_warnings_on: ["CHANGELOG.md", "README.md"], 68 | formatters: ["html"] 69 | ] 70 | end 71 | 72 | defp aliases do 73 | [ 74 | test: ["ecto.drop --quiet", "ecto.create --quiet", "ecto.migrate --quiet", "test"] 75 | ] 76 | end 77 | 78 | defp deps do 79 | [ 80 | {:ex_money, "~> 5.7"}, 81 | {:jason, "~> 1.0"}, 82 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 83 | {:ecto, "~> 3.5"}, 84 | {:ecto_sql, "~> 3.0"}, 85 | {:postgrex, "~> 0.15"}, 86 | {:myxql, "~> 0.4", only: :test}, 87 | {:benchee, "~> 1.0", optional: true, only: :dev}, 88 | {:exprof, "~> 0.2", only: :dev, runtime: false}, 89 | {:ex_doc, "~> 0.22", only: [:dev, :test, :release]}, 90 | {:earmark, "~> 1.4", only: [:dev, :test, :release]} 91 | ] 92 | end 93 | 94 | defp elixirc_paths(:test), do: ["lib", "test", "mix", "test/support"] 95 | defp elixirc_paths(:dev), do: ["lib", "mix"] 96 | defp elixirc_paths(_), do: ["lib"] 97 | end 98 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "cldr_utils": {:hex, :cldr_utils, "2.28.0", "ce309d11b79fc13e1f22f808b5e3c1647102b01b11734ca8cb0296ca6d406fe4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "e7ac4bcea0fdbc11b5295ef30dd7b18d0922512399361af06a97198e57d23742"}, 4 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 5 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 6 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 7 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 9 | "digital_token": {:hex, :digital_token, "0.6.0", "13e6de581f0b1f6c686f7c7d12ab11a84a7b22fa79adeb4b50eec1a2d278d258", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "2455d626e7c61a128b02a4a8caddb092548c3eb613ac6f6a85e4cbb6caddc4d1"}, 10 | "earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"}, 11 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 12 | "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, 13 | "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, 14 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 15 | "ex_cldr": {:hex, :ex_cldr, "2.40.0", "624717778dbf0a8cd307f1576eabbd44470c16190172abf293fed24150440a5a", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "113394b6dd23aaf7912da583aab103d9cf082b9821bc4a6e287543a895af7cb4"}, 16 | "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.16.2", "670d96cc4fb18cfebd82488ed687742683be2d0725d66ec051578d4b13539aa8", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "2ccfac2838f4df8c8e5424dbc68eb2f3ac9eeb45e10365050901f7ac7a914ce1"}, 17 | "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.33.2", "c5587a8d84214d9cc42e7827e4c3bed2aa9e52505a55b10540020725954ded2c", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.16", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "49f1dbaddc1ad6e3f496a97fa425d25b3ae89e8178ce0416d9909deaf2e5ad80"}, 18 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 19 | "ex_money": {:hex, :ex_money, "5.17.0", "9064a30d877d85b3e3ec7ca52339542037473bc71fc5c5b9f6c31d86516002b9", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.33", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "226aa8906c85cb121f1d0ead0b108b259ba68d92c8fc79fa1758d520ff5c84c0"}, 20 | "exprintf": {:hex, :exprintf, "0.2.1", "b7e895dfb00520cfb7fc1671303b63b37dc3897c59be7cbf1ae62f766a8a0314", [:mix], [], "hexpm", "20a0e8c880be90e56a77fcc82533c5d60c643915c7ce0cc8aa1e06ed6001da28"}, 21 | "exprof": {:hex, :exprof, "0.2.4", "13ddc0575a6d24b52e7c6809d2a46e9ad63a4dd179628698cdbb6c1f6e497c98", [:mix], [{:exprintf, "~> 0.2", [hex: :exprintf, repo: "hexpm", optional: false]}], "hexpm", "0884bcb66afc421c75d749156acbb99034cc7db6d3b116c32e36f32551106957"}, 22 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 23 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 24 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 25 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 26 | "myxql": {:hex, :myxql, "0.7.1", "7c7b75aa82227cd2bc9b7fbd4de774fb19a1cdb309c219f411f82ca8860f8e01", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a491cdff53353a09b5850ac2d472816ebe19f76c30b0d36a43317a67c9004936"}, 27 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 28 | "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"}, 29 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 30 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 31 | } 32 | -------------------------------------------------------------------------------- /mix/money_cldr.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.Cldr do 2 | @moduledoc false 3 | 4 | use Cldr, 5 | locales: ["en", "de", "it", "es", "fr"], 6 | default_locale: "en", 7 | providers: [Cldr.Number] 8 | end 9 | -------------------------------------------------------------------------------- /mix/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.SQL.Repo do 2 | use Ecto.Repo, 3 | otp_app: :ex_money_sql, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | end 7 | 8 | -------------------------------------------------------------------------------- /mix/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Organization do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key false 6 | schema "organizations" do 7 | field :payroll, Money.Ecto.Composite.Type, default_currency: :JPY 8 | field :tax, Money.Ecto.Composite.Type, fractional_digits: 4 9 | field :value, Money.Ecto.Composite.Type, default: Money.new(:USD, 0) 10 | field :revenue, Money.Ecto.Map.Type, default: Money.new(:AUD, 0) 11 | field :name, :string 12 | field :employee_count, :integer 13 | embeds_many :customers, Customer do 14 | field :name, :string 15 | field :revenue, Money.Ecto.Map.Type, default: Money.new(:USD, 0) 16 | end 17 | timestamps() 18 | end 19 | 20 | def changeset(organization, params \\ %{}) do 21 | organization 22 | |> cast(params, [:payroll]) 23 | |> cast_embed(:customers, with: &customer_changeset/2) 24 | end 25 | 26 | def customer_changeset(customer, params \\ %{}) do 27 | cast(customer, params, [:name, :revenue]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /priv/SQL/postgres/create_money_with_currency.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE public.money_with_currency AS (currency_code varchar, amount numeric); -------------------------------------------------------------------------------- /priv/SQL/postgres/define_minmax_functions.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION money_min_state_function(agg_state money_with_currency, money money_with_currency) 2 | RETURNS money_with_currency 3 | IMMUTABLE 4 | STRICT 5 | LANGUAGE plpgsql 6 | SET search_path = '' 7 | AS $$ 8 | DECLARE 9 | expected_currency varchar; 10 | aggregate numeric; 11 | min numeric; 12 | BEGIN 13 | IF currency_code(agg_state) IS NULL then 14 | expected_currency := currency_code(money); 15 | aggregate := 0; 16 | ELSE 17 | expected_currency := currency_code(agg_state); 18 | aggregate := amount(agg_state); 19 | END IF; 20 | 21 | IF currency_code(money) = expected_currency THEN 22 | IF amount(money) < aggregate THEN 23 | min := amount(money); 24 | ELSE 25 | min := aggregate; 26 | END IF; 27 | return row(expected_currency, min); 28 | ELSE 29 | RAISE EXCEPTION 30 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 31 | USING HINT = 'Please ensure all columns have the same currency code', 32 | ERRCODE = '22033'; 33 | END IF; 34 | END; 35 | $$; 36 | 37 | 38 | CREATE OR REPLACE FUNCTION money_min_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency) 39 | RETURNS money_with_currency 40 | IMMUTABLE 41 | STRICT 42 | LANGUAGE plpgsql 43 | SET search_path = '' 44 | AS $$ 45 | DECLARE 46 | min numeric; 47 | BEGIN 48 | IF currency_code(agg_state1) = currency_code(agg_state2) THEN 49 | IF amount(agg_state1) < amount(agg_state2) THEN 50 | min := amount(agg_state1); 51 | ELSE 52 | min := amount(agg_state2); 53 | END IF; 54 | return row(currency_code(agg_state1), min); 55 | ELSE 56 | RAISE EXCEPTION 57 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 58 | USING HINT = 'Please ensure all columns have the same currency code', 59 | ERRCODE = '22033'; 60 | END IF; 61 | END; 62 | $$; 63 | 64 | 65 | CREATE AGGREGATE min(money_with_currency) 66 | ( 67 | sfunc = money_min_state_function, 68 | stype = money_with_currency, 69 | combinefunc = money_min_combine_function, 70 | parallel = SAFE 71 | ); 72 | 73 | 74 | CREATE OR REPLACE FUNCTION money_max_state_function(agg_state money_with_currency, money money_with_currency) 75 | RETURNS money_with_currency 76 | IMMUTABLE 77 | STRICT 78 | LANGUAGE plpgsql 79 | SET search_path = '' 80 | AS $$ 81 | DECLARE 82 | expected_currency varchar; 83 | aggregate numeric; 84 | max numeric; 85 | BEGIN 86 | IF currency_code(agg_state) IS NULL then 87 | expected_currency := currency_code(money); 88 | aggregate := 0; 89 | ELSE 90 | expected_currency := currency_code(agg_state); 91 | aggregate := amount(agg_state); 92 | END IF; 93 | 94 | IF currency_code(money) = expected_currency THEN 95 | IF amount(money) > aggregate THEN 96 | max := amount(money); 97 | ELSE 98 | max := aggregate; 99 | END IF; 100 | return row(expected_currency, max); 101 | ELSE 102 | RAISE EXCEPTION 103 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 104 | USING HINT = 'Please ensure all columns have the same currency code', 105 | ERRCODE = '22033'; 106 | END IF; 107 | END; 108 | $$; 109 | 110 | 111 | CREATE OR REPLACE FUNCTION money_max_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency) 112 | RETURNS money_with_currency 113 | IMMUTABLE 114 | STRICT 115 | LANGUAGE plpgsql 116 | SET search_path = '' 117 | AS $$ 118 | DECLARE 119 | max numeric; 120 | BEGIN 121 | IF currency_code(agg_state1) = currency_code(agg_state2) THEN 122 | IF amount(agg_state1) > amount(agg_state2) THEN 123 | max := amount(agg_state1); 124 | ELSE 125 | max := amount(agg_state2); 126 | END IF; 127 | return row(currency_code(agg_state1), max); 128 | ELSE 129 | RAISE EXCEPTION 130 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 131 | USING HINT = 'Please ensure all columns have the same currency code', 132 | ERRCODE = '22033'; 133 | END IF; 134 | END; 135 | $$; 136 | 137 | 138 | CREATE AGGREGATE max(money_with_currency) 139 | ( 140 | sfunc = money_max_state_function, 141 | stype = money_with_currency, 142 | combinefunc = money_max_combine_function, 143 | parallel = SAFE 144 | ); 145 | -------------------------------------------------------------------------------- /priv/SQL/postgres/define_minus_operator.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION money_sub(money_1 money_with_currency, money_2 money_with_currency) 2 | RETURNS money_with_currency 3 | IMMUTABLE 4 | STRICT 5 | LANGUAGE plpgsql 6 | SET search_path = '' 7 | AS $$ 8 | DECLARE 9 | currency varchar; 10 | subtraction numeric; 11 | BEGIN 12 | IF currency_code(money_1) = currency_code(money_2) THEN 13 | currency := currency_code(money_1); 14 | subtraction := amount(money_1) - amount(money_2); 15 | return row(currency, subtraction); 16 | ELSE 17 | RAISE EXCEPTION 18 | 'Incompatible currency codes for - operator. Expected both currency codes to be %', currency_code(money_1) 19 | USING HINT = 'Please ensure both columns have the same currency code', 20 | ERRCODE = '22033'; 21 | END IF; 22 | END; 23 | $$; 24 | 25 | 26 | CREATE OPERATOR - ( 27 | leftarg = money_with_currency, 28 | rightarg = money_with_currency, 29 | procedure = money_sub, 30 | commutator = - 31 | ); 32 | -------------------------------------------------------------------------------- /priv/SQL/postgres/define_negate_operator.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION money_negate(money_1 money_with_currency) 2 | RETURNS money_with_currency 3 | IMMUTABLE 4 | STRICT 5 | LANGUAGE plpgsql 6 | SET search_path = '' 7 | AS $$ 8 | DECLARE 9 | currency varchar; 10 | addition numeric; 11 | BEGIN 12 | currency := currency_code(money_1); 13 | addition := amount(money_1) * -1; 14 | return row(currency, addition); 15 | END; 16 | $$; 17 | 18 | 19 | CREATE OPERATOR - ( 20 | rightarg = money_with_currency, 21 | procedure = money_neg 22 | ); 23 | -------------------------------------------------------------------------------- /priv/SQL/postgres/define_plus_operator.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION money_add(money_1 money_with_currency, money_2 money_with_currency) 2 | RETURNS money_with_currency 3 | IMMUTABLE 4 | STRICT 5 | LANGUAGE plpgsql 6 | SET search_path = '' 7 | AS $$ 8 | DECLARE 9 | currency varchar; 10 | addition numeric; 11 | BEGIN 12 | IF currency_code(money_1) = currency_code(money_2) THEN 13 | currency := currency_code(money_1); 14 | addition := amount(money_1) + amount(money_2); 15 | return row(currency, addition); 16 | ELSE 17 | RAISE EXCEPTION 18 | 'Incompatible currency codes for + operator. Expected both currency codes to be %', currency_code(money_1) 19 | USING HINT = 'Please ensure both columns have the same currency code', 20 | ERRCODE = '22033'; 21 | END IF; 22 | END; 23 | $$; 24 | 25 | 26 | CREATE OPERATOR + ( 27 | leftarg = money_with_currency, 28 | rightarg = money_with_currency, 29 | procedure = money_add, 30 | commutator = + 31 | ); -------------------------------------------------------------------------------- /priv/SQL/postgres/define_sum_function.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION money_sum_state_function(agg_state money_with_currency, money money_with_currency) 2 | RETURNS money_with_currency 3 | IMMUTABLE 4 | STRICT 5 | LANGUAGE plpgsql 6 | SET search_path = '' 7 | AS $$ 8 | DECLARE 9 | expected_currency varchar; 10 | aggregate numeric; 11 | addition numeric; 12 | BEGIN 13 | if currency_code(agg_state) IS NULL then 14 | expected_currency := currency_code(money); 15 | aggregate := 0; 16 | else 17 | expected_currency := currency_code(agg_state); 18 | aggregate := amount(agg_state); 19 | end if; 20 | 21 | IF currency_code(money) = expected_currency THEN 22 | addition := aggregate + amount(money); 23 | return row(expected_currency, addition); 24 | ELSE 25 | RAISE EXCEPTION 26 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 27 | USING HINT = 'Please ensure all columns have the same currency code', 28 | ERRCODE = '22033'; 29 | END IF; 30 | END; 31 | $$; 32 | 33 | 34 | CREATE OR REPLACE FUNCTION money_sum_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency) 35 | RETURNS money_with_currency 36 | IMMUTABLE 37 | STRICT 38 | LANGUAGE plpgsql 39 | SET search_path = '' 40 | AS $$ 41 | BEGIN 42 | IF currency_code(agg_state1) = currency_code(agg_state2) THEN 43 | return row(currency_code(agg_state1), amount(agg_state1) + amount(agg_state2)); 44 | ELSE 45 | RAISE EXCEPTION 46 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 47 | USING HINT = 'Please ensure all columns have the same currency code', 48 | ERRCODE = '22033'; 49 | END IF; 50 | END; 51 | $$; 52 | 53 | 54 | CREATE OR REPLACE AGGREGATE sum(money_with_currency) 55 | ( 56 | sfunc = money_sum_state_function, 57 | stype = money_with_currency, 58 | combinefunc = money_sum_combine_function, 59 | parallel = SAFE 60 | ); 61 | -------------------------------------------------------------------------------- /priv/SQL/postgres/drop_minmax_functions.sql: -------------------------------------------------------------------------------- 1 | DROP AGGREGATE IF EXISTS max(money_with_currency); 2 | 3 | 4 | DROP FUNCTION IF EXISTS money_max_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency); 5 | 6 | 7 | DROP FUNCTION IF EXISTS money_max_state_function(agg_state money_with_currency, money money_with_currency); 8 | 9 | 10 | DROP AGGREGATE IF EXISTS min(money_with_currency); 11 | 12 | 13 | DROP FUNCTION IF EXISTS money_min_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency); 14 | 15 | 16 | DROP FUNCTION IF EXISTS money_min_state_function(agg_state money_with_currency, money money_with_currency); 17 | -------------------------------------------------------------------------------- /priv/SQL/postgres/drop_minus_operator.sql: -------------------------------------------------------------------------------- 1 | DROP OPERATOR - (money_with_currency, money_with_currency); 2 | 3 | 4 | DROP FUNCTION IF EXISTS money_sub(money_1 money_with_currency, money_2 money_with_currency); 5 | -------------------------------------------------------------------------------- /priv/SQL/postgres/drop_money_with_currency.sql: -------------------------------------------------------------------------------- 1 | DROP TYPE public.money_with_currency; -------------------------------------------------------------------------------- /priv/SQL/postgres/drop_negate_operator.sql: -------------------------------------------------------------------------------- 1 | DROP OPERATOR -(none, money_with_currency); 2 | 3 | 4 | DROP FUNCTION IF EXISTS money_neg(money_1 money_with_currency); 5 | -------------------------------------------------------------------------------- /priv/SQL/postgres/drop_plus_operator.sql: -------------------------------------------------------------------------------- 1 | DROP OPERATOR + (money_with_currency, money_with_currency); 2 | 3 | 4 | DROP FUNCTION IF EXISTS money_add(money_1 money_with_currency, money_2 money_with_currency); -------------------------------------------------------------------------------- /priv/SQL/postgres/drop_sum_function.sql: -------------------------------------------------------------------------------- 1 | DROP AGGREGATE IF EXISTS sum(money_with_currency); 2 | 3 | 4 | DROP FUNCTION IF EXISTS money_sum_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency); 5 | 6 | 7 | DROP FUNCTION IF EXISTS money_sum_state_function(agg_state money_with_currency, money money_with_currency); 8 | -------------------------------------------------------------------------------- /priv/SQL/postgres/get_currency_code_type.sql: -------------------------------------------------------------------------------- 1 | -- Gets the type of the currency_code column 2 | -- https://stackoverflow.com/questions/9535937/is-there-a-way-to-show-a-user-defined-postgresql-enumerated-type-definition 3 | WITH types AS ( 4 | SELECT n.nspname, 5 | pg_catalog.format_type ( t.oid, NULL ) AS obj_name, 6 | CASE 7 | WHEN t.typrelid != 0 THEN CAST ( 'tuple' AS pg_catalog.text ) 8 | WHEN t.typlen < 0 THEN CAST ( 'var' AS pg_catalog.text ) 9 | ELSE CAST ( t.typlen AS pg_catalog.text ) 10 | END AS obj_type, 11 | coalesce ( pg_catalog.obj_description ( t.oid, 'pg_type' ), '' ) AS description 12 | FROM pg_catalog.pg_type t 13 | JOIN pg_catalog.pg_namespace n 14 | ON n.oid = t.typnamespace 15 | WHERE ( t.typrelid = 0 16 | OR ( SELECT c.relkind = 'c' 17 | FROM pg_catalog.pg_class c 18 | WHERE c.oid = t.typrelid ) ) 19 | AND NOT EXISTS ( 20 | SELECT 1 21 | FROM pg_catalog.pg_type el 22 | WHERE el.oid = t.typelem 23 | AND el.typarray = t.oid ) 24 | AND n.nspname <> 'pg_catalog' 25 | AND n.nspname <> 'information_schema' 26 | AND n.nspname !~ '^pg_toast' 27 | ), 28 | cols AS ( 29 | SELECT n.nspname::text AS schema_name, 30 | pg_catalog.format_type ( t.oid, NULL ) AS obj_name, 31 | a.attname::text AS column_name, 32 | pg_catalog.format_type ( a.atttypid, a.atttypmod ) AS data_type, 33 | a.attnotnull AS is_required, 34 | a.attnum AS ordinal_position, 35 | pg_catalog.col_description ( a.attrelid, a.attnum ) AS description 36 | FROM pg_catalog.pg_attribute a 37 | JOIN pg_catalog.pg_type t 38 | ON a.attrelid = t.typrelid 39 | JOIN pg_catalog.pg_namespace n 40 | ON ( n.oid = t.typnamespace ) 41 | JOIN types 42 | ON ( types.nspname = n.nspname 43 | AND types.obj_name = pg_catalog.format_type ( t.oid, NULL ) ) 44 | WHERE a.attnum > 0 45 | AND NOT a.attisdropped 46 | ) 47 | SELECT cols.data_type 48 | FROM cols 49 | WHERE cols.obj_name='money_with_currency' and cols.column_name = 'currency_code'; 50 | -------------------------------------------------------------------------------- /priv/repo/migrations/20231028034804_add_money_with_currency_type_to_postgres.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.SQL.Repo.Migrations.AddMoneyWithCurrencyTypeToPostgres do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute("CREATE TYPE public.money_with_currency AS (currency_code varchar, amount numeric);") 6 | end 7 | 8 | def down do 9 | execute("DROP TYPE public.money_with_currency;") 10 | end 11 | end -------------------------------------------------------------------------------- /priv/repo/migrations/20231030034859_add_postgres_money_sum_function.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.SQL.Repo.Migrations.AddPostgresMoneySumFunction do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute( 6 | """ 7 | CREATE OR REPLACE FUNCTION money_sum_state_function(agg_state money_with_currency, money money_with_currency) 8 | RETURNS money_with_currency 9 | IMMUTABLE 10 | STRICT 11 | LANGUAGE plpgsql 12 | SET search_path = '' 13 | AS $$ 14 | DECLARE 15 | expected_currency varchar; 16 | aggregate numeric; 17 | addition numeric; 18 | BEGIN 19 | if currency_code(agg_state) IS NULL then 20 | expected_currency := currency_code(money); 21 | aggregate := 0; 22 | else 23 | expected_currency := currency_code(agg_state); 24 | aggregate := amount(agg_state); 25 | end if; 26 | 27 | IF currency_code(money) = expected_currency THEN 28 | addition := aggregate + amount(money); 29 | return row(expected_currency, addition); 30 | ELSE 31 | RAISE EXCEPTION 32 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 33 | USING HINT = 'Please ensure all columns have the same currency code', 34 | ERRCODE = '22033'; 35 | END IF; 36 | END; 37 | $$; 38 | """ 39 | |> Money.Migration.adjust_for_type(repo()) 40 | ) 41 | 42 | execute( 43 | """ 44 | CREATE OR REPLACE FUNCTION money_sum_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency) 45 | RETURNS money_with_currency 46 | IMMUTABLE 47 | STRICT 48 | LANGUAGE plpgsql 49 | AS $$ 50 | BEGIN 51 | IF currency_code(agg_state1) = currency_code(agg_state2) THEN 52 | return row(currency_code(agg_state1), amount(agg_state1) + amount(agg_state2)); 53 | ELSE 54 | RAISE EXCEPTION 55 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 56 | USING HINT = 'Please ensure all columns have the same currency code', 57 | ERRCODE = '22033'; 58 | END IF; 59 | END; 60 | $$; 61 | """ 62 | |> Money.Migration.adjust_for_type(repo()) 63 | ) 64 | 65 | execute( 66 | """ 67 | CREATE OR REPLACE AGGREGATE sum(money_with_currency) 68 | ( 69 | sfunc = money_sum_state_function, 70 | stype = money_with_currency, 71 | combinefunc = money_sum_combine_function, 72 | parallel = SAFE 73 | ); 74 | """ 75 | |> Money.Migration.adjust_for_type(repo()) 76 | ) 77 | end 78 | 79 | def down do 80 | execute("DROP AGGREGATE IF EXISTS sum(money_with_currency);") 81 | 82 | execute( 83 | "DROP FUNCTION IF EXISTS money_sum_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency);" 84 | ) 85 | 86 | execute( 87 | "DROP FUNCTION IF EXISTS money_sum_state_function(agg_state money_with_currency, money money_with_currency);" 88 | ) 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /priv/repo/migrations/20231030035131_add_postgres_money_plus_operator.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.SQL.Repo.Migrations.AddPostgresMoneyPlusOperator do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute( 6 | """ 7 | CREATE OR REPLACE FUNCTION money_add(money_1 money_with_currency, money_2 money_with_currency) 8 | RETURNS money_with_currency 9 | IMMUTABLE 10 | STRICT 11 | LANGUAGE plpgsql 12 | SET search_path = '' 13 | AS $$ 14 | DECLARE 15 | currency varchar; 16 | addition numeric; 17 | BEGIN 18 | IF currency_code(money_1) = currency_code(money_2) THEN 19 | currency := currency_code(money_1); 20 | addition := amount(money_1) + amount(money_2); 21 | return row(currency, addition); 22 | ELSE 23 | RAISE EXCEPTION 24 | 'Incompatible currency codes for + operator. Expected both currency codes to be %', currency_code(money_1) 25 | USING HINT = 'Please ensure both columns have the same currency code', 26 | ERRCODE = '22033'; 27 | END IF; 28 | END; 29 | $$; 30 | """ 31 | |> Money.Migration.adjust_for_type(repo()) 32 | ) 33 | 34 | execute( 35 | """ 36 | CREATE OPERATOR + ( 37 | leftarg = money_with_currency, 38 | rightarg = money_with_currency, 39 | procedure = money_add, 40 | commutator = + 41 | ); 42 | """ 43 | |> Money.Migration.adjust_for_type(repo()) 44 | ) 45 | end 46 | 47 | def down do 48 | execute("DROP OPERATOR + (money_with_currency, money_with_currency);") 49 | 50 | execute( 51 | "DROP FUNCTION IF EXISTS money_add(money_1 money_with_currency, money_2 money_with_currency);" 52 | ) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /priv/repo/migrations/20231030035136_add_postgres_money_minmax_functions.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.SQL.Repo.Migrations.AddPostgresMoneyMinmaxFunctions do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute( 6 | """ 7 | CREATE OR REPLACE FUNCTION money_min_state_function(agg_state money_with_currency, money money_with_currency) 8 | RETURNS money_with_currency 9 | IMMUTABLE 10 | STRICT 11 | LANGUAGE plpgsql 12 | SET search_path = '' 13 | AS $$ 14 | DECLARE 15 | expected_currency varchar; 16 | aggregate numeric; 17 | min numeric; 18 | BEGIN 19 | IF currency_code(agg_state) IS NULL then 20 | expected_currency := currency_code(money); 21 | aggregate := 0; 22 | ELSE 23 | expected_currency := currency_code(agg_state); 24 | aggregate := amount(agg_state); 25 | END IF; 26 | 27 | IF currency_code(money) = expected_currency THEN 28 | IF amount(money) < aggregate THEN 29 | min := amount(money); 30 | ELSE 31 | min := aggregate; 32 | END IF; 33 | return row(expected_currency, min); 34 | ELSE 35 | RAISE EXCEPTION 36 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 37 | USING HINT = 'Please ensure all columns have the same currency code', 38 | ERRCODE = '22033'; 39 | END IF; 40 | END; 41 | $$; 42 | """ 43 | |> Money.Migration.adjust_for_type(repo()) 44 | ) 45 | 46 | execute( 47 | """ 48 | CREATE OR REPLACE FUNCTION money_min_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency) 49 | RETURNS money_with_currency 50 | IMMUTABLE 51 | STRICT 52 | LANGUAGE plpgsql 53 | AS $$ 54 | DECLARE 55 | min numeric; 56 | BEGIN 57 | IF currency_code(agg_state1) = currency_code(agg_state2) THEN 58 | IF amount(agg_state1) < amount(agg_state2) THEN 59 | min := amount(agg_state1); 60 | ELSE 61 | min := amount(agg_state2); 62 | END IF; 63 | return row(currency_code(agg_state1), min); 64 | ELSE 65 | RAISE EXCEPTION 66 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 67 | USING HINT = 'Please ensure all columns have the same currency code', 68 | ERRCODE = '22033'; 69 | END IF; 70 | END; 71 | $$; 72 | """ 73 | |> Money.Migration.adjust_for_type(repo()) 74 | ) 75 | 76 | execute( 77 | """ 78 | CREATE AGGREGATE min(money_with_currency) 79 | ( 80 | sfunc = money_min_state_function, 81 | stype = money_with_currency, 82 | combinefunc = money_min_combine_function, 83 | parallel = SAFE 84 | ); 85 | """ 86 | |> Money.Migration.adjust_for_type(repo()) 87 | ) 88 | 89 | execute( 90 | """ 91 | CREATE OR REPLACE FUNCTION money_max_state_function(agg_state money_with_currency, money money_with_currency) 92 | RETURNS money_with_currency 93 | IMMUTABLE 94 | STRICT 95 | LANGUAGE plpgsql 96 | AS $$ 97 | DECLARE 98 | expected_currency varchar; 99 | aggregate numeric; 100 | max numeric; 101 | BEGIN 102 | IF currency_code(agg_state) IS NULL then 103 | expected_currency := currency_code(money); 104 | aggregate := 0; 105 | ELSE 106 | expected_currency := currency_code(agg_state); 107 | aggregate := amount(agg_state); 108 | END IF; 109 | 110 | IF currency_code(money) = expected_currency THEN 111 | IF amount(money) > aggregate THEN 112 | max := amount(money); 113 | ELSE 114 | max := aggregate; 115 | END IF; 116 | return row(expected_currency, max); 117 | ELSE 118 | RAISE EXCEPTION 119 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 120 | USING HINT = 'Please ensure all columns have the same currency code', 121 | ERRCODE = '22033'; 122 | END IF; 123 | END; 124 | $$; 125 | """ 126 | |> Money.Migration.adjust_for_type(repo()) 127 | ) 128 | 129 | execute( 130 | """ 131 | CREATE OR REPLACE FUNCTION money_max_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency) 132 | RETURNS money_with_currency 133 | IMMUTABLE 134 | STRICT 135 | LANGUAGE plpgsql 136 | AS $$ 137 | DECLARE 138 | max numeric; 139 | BEGIN 140 | IF currency_code(agg_state1) = currency_code(agg_state2) THEN 141 | IF amount(agg_state1) > amount(agg_state2) THEN 142 | max := amount(agg_state1); 143 | ELSE 144 | max := amount(agg_state2); 145 | END IF; 146 | return row(currency_code(agg_state1), max); 147 | ELSE 148 | RAISE EXCEPTION 149 | 'Incompatible currency codes. Expected all currency codes to be %', expected_currency 150 | USING HINT = 'Please ensure all columns have the same currency code', 151 | ERRCODE = '22033'; 152 | END IF; 153 | END; 154 | $$; 155 | """ 156 | |> Money.Migration.adjust_for_type(repo()) 157 | ) 158 | 159 | execute( 160 | """ 161 | CREATE AGGREGATE max(money_with_currency) 162 | ( 163 | sfunc = money_max_state_function, 164 | stype = money_with_currency, 165 | combinefunc = money_max_combine_function, 166 | parallel = SAFE 167 | ); 168 | """ 169 | |> Money.Migration.adjust_for_type(repo()) 170 | ) 171 | end 172 | 173 | def down do 174 | execute("DROP AGGREGATE IF EXISTS max(money_with_currency);") 175 | 176 | execute( 177 | "DROP FUNCTION IF EXISTS money_max_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency);" 178 | ) 179 | 180 | execute( 181 | "DROP FUNCTION IF EXISTS money_max_state_function(agg_state money_with_currency, money money_with_currency);" 182 | ) 183 | 184 | execute("DROP AGGREGATE IF EXISTS min(money_with_currency);") 185 | 186 | execute( 187 | "DROP FUNCTION IF EXISTS money_min_combine_function(agg_state1 money_with_currency, agg_state2 money_with_currency);" 188 | ) 189 | 190 | execute( 191 | "DROP FUNCTION IF EXISTS money_min_state_function(agg_state money_with_currency, money money_with_currency);" 192 | ) 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /priv/repo/migrations/20500930144804_create_test_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Repo.Migrations.CreateMoneyTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:organizations) do 6 | add :name, :string 7 | add :employee_count, :integer 8 | add :payroll, :money_with_currency 9 | add :tax, :money_with_currency 10 | add :value, :money_with_currency 11 | add :revenue, :map 12 | add :customers, {:array, :map} 13 | timestamps() 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/db_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.DB.Test do 2 | use Money.SQL.RepoCase 3 | 4 | test "insert a record with a money amount" do 5 | m = Money.new(:USD, 100) 6 | assert {:ok, struct} = Repo.insert(%Organization{payroll: m}) 7 | assert Money.compare(m, struct.payroll) == :eq 8 | end 9 | 10 | test "insert a record with a money amount with params" do 11 | m = Money.new(:USD, 100) 12 | {:ok, _} = Repo.insert(%Organization{name: "a", tax: m}) 13 | struct = Repo.get_by(Organization, name: "a") 14 | 15 | assert Money.compare(m, struct.tax) == :eq 16 | assert struct.tax.format_options == [fractional_digits: 4] 17 | end 18 | 19 | test "insert a record with a default money amount without params" do 20 | m = Money.new(:USD, 0) 21 | {:ok, _} = Repo.insert(%Organization{name: "a"}) 22 | struct = Repo.get_by(Organization, name: "a") 23 | 24 | assert struct.value == m 25 | assert Money.compare(m, struct.value) == :eq 26 | assert struct.value.format_options == [] 27 | end 28 | 29 | test "select aggregate function sum on a :money_with_currency type" do 30 | m = Money.new(:USD, 100) 31 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 32 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 33 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 34 | sum = select(Organization, [o], type(sum(o.payroll), o.payroll)) |> Repo.one() 35 | assert Money.compare(sum, Money.new(:USD, 300)) == :eq 36 | end 37 | 38 | test "Repo.aggregate function sum on a :money_with_currency type" do 39 | m = Money.new(:USD, 100) 40 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 41 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 42 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 43 | sum = Repo.aggregate(Organization, :sum, :payroll) 44 | assert Money.compare(sum, Money.new(:USD, 300)) == :eq 45 | end 46 | 47 | test "Exception is raised if trying to sum different currencies" do 48 | m = Money.new(:USD, 100) 49 | m2 = Money.new(:AUD, 100) 50 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 51 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 52 | {:ok, _} = Repo.insert(%Organization{payroll: m2}) 53 | 54 | assert_raise Postgrex.Error, fn -> 55 | Repo.aggregate(Organization, :sum, :payroll) 56 | end 57 | end 58 | 59 | test "aggregate from a keyword query using a schema module" do 60 | m = Money.new(:USD, 100) 61 | m2 = Money.new(:USD, 100) 62 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 63 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 64 | {:ok, _} = Repo.insert(%Organization{payroll: m2}) 65 | 66 | query = 67 | from( 68 | organization in Organization, 69 | select: %{ 70 | total: type(sum(organization.payroll), organization.payroll) 71 | } 72 | ) 73 | 74 | assert Repo.all(query) == [%{total: Money.new(:USD, 300)}] 75 | end 76 | 77 | test "keyword query using a schema module casting with a type" do 78 | m = Money.new(:USD, 100) 79 | m2 = Money.new(:USD, 100) 80 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 81 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 82 | {:ok, _} = Repo.insert(%Organization{payroll: m2}) 83 | 84 | query = 85 | from( 86 | organization in Organization, 87 | select: %{ 88 | total: type(sum(organization.payroll), ^Money.Ecto.Composite.Type.cast_type()) 89 | } 90 | ) 91 | 92 | assert Repo.all(query) == [%{total: Money.new(:USD, 300)}] 93 | end 94 | 95 | test "aggregate from a keyword query using a schemaLESS query" do 96 | m = Money.new(:USD, 100) 97 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 98 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 99 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 100 | 101 | query = 102 | from( 103 | organization in "organizations", 104 | select: %{ 105 | total: 106 | type( 107 | sum(organization.payroll), 108 | ^Money.Ecto.Composite.Type.cast_type() 109 | ) 110 | } 111 | ) 112 | 113 | assert Repo.all(query) == [%{total: Money.new(:USD, 300)}] 114 | end 115 | 116 | test "select using Ecto functional query composition" do 117 | m = Money.new(:USD, 100) 118 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 119 | 120 | query = 121 | from(Organization) 122 | |> select([o], %{money: o.payroll}) 123 | 124 | assert [%{money: ^m}] = Repo.all(query) 125 | end 126 | 127 | test "select distinct aggregate function sum on a :money_with_currency type" do 128 | m = Money.new(:USD, 100) 129 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 130 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 131 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 132 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 200)}) 133 | 134 | query = select(Organization, [o], type(fragment("SUM(DISTINCT ?)", o.payroll), o.payroll)) 135 | sum = query |> Repo.one() 136 | assert Money.compare(sum, Money.new(:USD, 300)) == :eq 137 | end 138 | 139 | test "filter on a currency type" do 140 | m = Money.new(:USD, 100) 141 | m2 = Money.new(:AUD, 200) 142 | 143 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 144 | {:ok, _} = Repo.insert(%Organization{payroll: m2}) 145 | {:ok, _} = Repo.insert(%Organization{payroll: m}) 146 | {:ok, _} = Repo.insert(%Organization{payroll: m2}) 147 | 148 | query = 149 | from(o in Organization, 150 | where: fragment("currency_code(payroll)") == "USD", 151 | select: sum(o.payroll) 152 | ) 153 | 154 | result = query |> Repo.one() 155 | 156 | assert result == Money.new(:USD, 200) 157 | end 158 | 159 | test "nil values for money is ok" do 160 | assert {:ok, _} = Repo.insert(%Organization{name: "a", payroll: nil}) 161 | organization = Repo.get_by(Organization, name: "a") 162 | assert is_nil(organization.payroll) 163 | end 164 | 165 | test "Plus operator a :money_with_currency type" do 166 | m = Money.new(:USD, 100) 167 | {:ok, _} = Repo.insert(%Organization{payroll: m, tax: m}) 168 | 169 | query = 170 | from(o in Organization, 171 | select: type(fragment("payroll + tax"), o.payroll) 172 | ) 173 | 174 | assert Repo.one(query) == Money.new(:USD, 200) 175 | end 176 | 177 | test "Plus operator with incompatible money currencies" do 178 | m = Money.new(:USD, 100) 179 | n = Money.new(:AUD, 100) 180 | 181 | {:ok, _} = Repo.insert(%Organization{payroll: m, tax: n}) 182 | 183 | query = 184 | from(o in Organization, 185 | select: type(fragment("payroll + tax"), o.payroll) 186 | ) 187 | 188 | assert_raise Postgrex.Error, fn -> 189 | Repo.one(query) 190 | end 191 | end 192 | 193 | test "plus operator with Ecto multi update :inc" do 194 | alias Ecto.Multi 195 | amount = Money.new(:USD, 100) 196 | 197 | {:ok, _} = Repo.insert(%Organization{payroll: amount}) 198 | {:ok, _} = Repo.insert(%Organization{payroll: amount}) 199 | 200 | {:ok, %{organization: {2, nil}}} = 201 | Multi.new() 202 | |> Multi.update_all( 203 | :organization, 204 | fn _ -> 205 | from(o in Organization, 206 | update: [inc: [payroll: type(^amount, ^Money.Ecto.Composite.Type.cast_type())]] 207 | ) 208 | end, 209 | [] 210 | ) 211 | |> Repo.transaction() 212 | 213 | result = from(o in Organization, select: sum(o.payroll)) |> Repo.one() 214 | assert result == Money.new(:USD, 400) 215 | end 216 | 217 | test "order by money" do 218 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 100)}) 219 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 200)}) 220 | 221 | assert [%Organization{payroll: p1}, %Organization{payroll: p2}] = 222 | Repo.all(from(o in Organization, order_by: o.payroll)) 223 | 224 | assert p1 == Money.new(:USD, 100) 225 | assert p2 == Money.new(:USD, 200) 226 | end 227 | 228 | test "order by money descending" do 229 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 100)}) 230 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 200)}) 231 | 232 | assert [%Organization{payroll: p1}, %Organization{payroll: p2}] = 233 | Repo.all(from(o in Organization, order_by: [desc: o.payroll])) 234 | 235 | assert p1 == Money.new(:USD, 200) 236 | assert p2 == Money.new(:USD, 100) 237 | end 238 | 239 | test "order by money of different currency" do 240 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 100)}) 241 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:AUD, 200)}) 242 | 243 | assert [%Organization{payroll: p1}, %Organization{payroll: p2}] = 244 | Repo.all(from(o in Organization, order_by: o.payroll)) 245 | 246 | assert p1 == Money.new(:AUD, 200) 247 | assert p2 == Money.new(:USD, 100) 248 | end 249 | 250 | test "min and max on money" do 251 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 100)}) 252 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 200)}) 253 | 254 | assert Repo.one(from(o in Organization, select: fragment("max(amount(payroll))"))) == 255 | Decimal.new("200") 256 | 257 | assert Repo.one(from(o in Organization, select: max(o.payroll))) == Money.new(:USD, "200") 258 | assert Repo.one(from(o in Organization, select: min(o.payroll))) == Money.new(:USD, "100") 259 | end 260 | 261 | test "min max raise exceptions if the currencies are different" do 262 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:AUD, 100)}) 263 | {:ok, _} = Repo.insert(%Organization{payroll: Money.new(:USD, 200)}) 264 | 265 | assert_raise Postgrex.Error, fn -> 266 | Repo.one(from(o in Organization, select: max(o.payroll))) 267 | end 268 | 269 | assert_raise Postgrex.Error, fn -> 270 | Repo.one(from(o in Organization, select: min(o.payroll))) 271 | end 272 | end 273 | 274 | test "selecting money ordering" do 275 | alias Ecto.Adapters.SQL 276 | 277 | assert {:ok, %Postgrex.Result{rows: [[true]]}} = 278 | SQL.query(Repo, "select ('USD', 100) < ('USD', 200)", []) 279 | 280 | assert {:ok, %Postgrex.Result{rows: [[true]]}} = 281 | SQL.query(Repo, "select ('USD', 100) = ('USD', 100)", []) 282 | 283 | assert {:ok, %Postgrex.Result{rows: [[false]]}} = 284 | SQL.query(Repo, "select ('USD', 100) < ('USD', 100)", []) 285 | 286 | assert {:ok, %Postgrex.Result{rows: [[true]]}} = 287 | SQL.query(Repo, "select ('AUD', 100) < ('USD', 100)", []) 288 | end 289 | end 290 | -------------------------------------------------------------------------------- /test/money_changeset_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Changeset.Test do 2 | use ExUnit.Case 3 | import Money.Validate 4 | import Money.ValidationSupport 5 | 6 | test "Changeset default currency" do 7 | changeset = Organization.changeset(%Organization{}, %{payroll: "0"}) 8 | assert changeset.changes.payroll == Money.new(:JPY, 0) 9 | end 10 | 11 | test "Changeset default currency in embedded schema" do 12 | changeset = Organization.changeset(%Organization{}, %{customers: [%{revenue: "12345.67"}]}) 13 | assert hd(changeset.changes.customers).changes.revenue == Money.new(:USD, "12345.67") 14 | end 15 | 16 | test "money positive validation" do 17 | assert validate_money(test_changeset(), :value, less_than: Money.new(:USD, 200)).valid? 18 | 19 | assert validate_money(test_changeset(), :value, less_than_or_equal_to: Money.new(:USD, 200)).valid? 20 | 21 | assert validate_money(test_changeset(), :value, less_than_or_equal_to: Money.new(:USD, 100)).valid? 22 | 23 | assert validate_money(test_changeset(), :value, greater_than: Money.new(:USD, 50)).valid? 24 | 25 | assert validate_money(test_changeset(), :value, greater_than_or_equal_to: Money.new(:USD, 50)).valid? 26 | 27 | assert validate_money(test_changeset(), :value, greater_than_or_equal_to: Money.new(:USD, 100)).valid? 28 | 29 | assert validate_money(test_changeset(), :value, equal_to: Money.new(:USD, 100)).valid? 30 | 31 | assert validate_money(test_changeset(), :value, 32 | greater_than: Money.new(:USD, 50), 33 | less_than: Money.new(:USD, 200) 34 | ).valid? 35 | end 36 | 37 | test "money negative validation" do 38 | refute validate_money(test_changeset(), :value, less_than: Money.new(:AUD, 200)).valid? 39 | 40 | assert validate_money(test_changeset(), :value, less_than: Money.new(:USD, 50)).errors == 41 | [ 42 | value: 43 | {"must be less than %{money}", 44 | [validation: :money, kind: :less_than, money: Money.new(:USD, 50)]} 45 | ] 46 | end 47 | 48 | test "Non-money changeset and comparison values" do 49 | assert validate_money(test_changeset(), :value, less_than: Money.new(:AUD, 200)).errors == 50 | [ 51 | value: 52 | {"Cannot compare monies with different currencies. Received :USD and :AUD.", 53 | [validation: :money, kind: :less_than, money: Money.new(:AUD, 200)]} 54 | ] 55 | 56 | assert_raise ArgumentError, ~r/expected target_value to be of type Money/, fn -> 57 | validate_money(test_changeset(), :value, less_than: 200) 58 | end 59 | 60 | assert_raise ArgumentError, ~r/expected value to be of type Money/, fn -> 61 | validate_money(non_money_changeset(), :employee_count, less_than: Money.new(:USD, 200)) 62 | end 63 | 64 | assert_raise ArgumentError, ~r/expected value and target_value to be of type Money/, fn -> 65 | validate_money(non_money_changeset(), :employee_count, less_than: 200) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/money_ecto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Ecto.Test do 2 | use ExUnit.Case 3 | 4 | describe "Money.Ecto.Composite.Type specific tests" do 5 | test "load a tuple with an unknown currency code produces an error" do 6 | assert Money.Ecto.Composite.Type.load({"INVALID", 100}) == :error 7 | end 8 | 9 | test "load a tuple produces a Money struct" do 10 | assert Money.Ecto.Composite.Type.load({"USD", 100}) == {:ok, Money.new(:USD, 100)} 11 | end 12 | 13 | test "load treats NaN values as error" do 14 | assert Money.Ecto.Composite.Type.load({"USD", "NaN"}) == :error 15 | end 16 | 17 | test "load treats Inf values as error" do 18 | assert Money.Ecto.Composite.Type.load({"USD", "Inf"}) == :error 19 | end 20 | 21 | test "dump a money struct" do 22 | assert Money.Ecto.Composite.Type.dump(Money.new(:USD, 100)) == 23 | {:ok, {"USD", Decimal.new(100)}} 24 | end 25 | 26 | test "cast returns a parse error" do 27 | assert Money.Ecto.Composite.Type.cast("(USD)") == 28 | {:error, [exception: Money.ParseError, message: "Could not parse \"(USD)\"."]} 29 | end 30 | 31 | test "case with empty input returns an error" do 32 | assert Money.Ecto.Composite.Type.cast("") == 33 | {:error, 34 | [exception: Money.Invalid, message: "Unable to create money from :USD and \"\""]} 35 | end 36 | end 37 | 38 | describe "Money.Ecto.Map.Type specific tests" do 39 | test "load a json map with a string amount produces a Money struct" do 40 | assert Money.Ecto.Map.Type.load(%{"currency" => "USD", "amount" => "100"}) == 41 | {:ok, Money.new(:USD, 100)} 42 | end 43 | 44 | test "load a json map with a number amount produces a Money struct" do 45 | assert Money.Ecto.Map.Type.load(%{"currency" => "USD", "amount" => 100}) == 46 | {:ok, Money.new(:USD, 100)} 47 | end 48 | 49 | test "load a json map with an unknown currency code produces an error" do 50 | assert Money.Ecto.Map.Type.load(%{"currency" => "AAA", "amount" => 100}) == :error 51 | end 52 | 53 | test "load treats NaN values as error" do 54 | assert Money.Ecto.Map.Type.load(%{"currency" => "USD", "amount" => "NaN"}) == :error 55 | end 56 | 57 | test "load treats Inf values as error" do 58 | assert Money.Ecto.Map.Type.load(%{"currency" => "USD", "amount" => "Inf"}) == :error 59 | end 60 | 61 | test "dump a money struct" do 62 | assert Money.Ecto.Map.Type.dump(Money.new(:USD, 100)) == 63 | {:ok, %{"amount" => "100", "currency" => "USD"}} 64 | end 65 | 66 | test "dump and load a money struct when the locale uses non-default separators" do 67 | Cldr.with_locale("de", Test.Cldr, fn -> 68 | money = Money.new(:USD, "100,34") 69 | dumped = Money.Ecto.Map.Type.dump(money) 70 | assert dumped == {:ok, %{"amount" => "100.34", "currency" => "USD"}} 71 | 72 | cast = Money.Ecto.Map.Type.load(elem(dumped, 1)) 73 | assert cast == {:ok, money} 74 | end) 75 | end 76 | 77 | test "loads a money struct from an embedded schema when the locale uses non-default separator" do 78 | data = %{ 79 | "revenue" => %{ 80 | "amount" => "12345.67", 81 | "currency" => "EUR" 82 | } 83 | } 84 | 85 | Cldr.with_locale("de", Test.Cldr, fn -> 86 | customer = Ecto.embedded_load(Organization.Customer, data, :json) 87 | assert customer.revenue == Money.new(:EUR, "12345,67") 88 | end) 89 | end 90 | 91 | test "dump a money struct with Ecto.embedded_dump/3" do 92 | organization = %Organization{payroll: Money.new(:USD, "100.23")} 93 | dumped = Ecto.embedded_dump(organization, :json) 94 | 95 | assert dumped == %{ 96 | name: nil, 97 | value: %{"amount" => "0", "currency" => "USD"}, 98 | payroll: %{"amount" => "100.23", "currency" => "USD"}, 99 | tax: nil, 100 | revenue: %{"amount" => "0", "currency" => "AUD"}, 101 | employee_count: nil, 102 | customers: [], 103 | inserted_at: nil, 104 | updated_at: nil 105 | } 106 | end 107 | end 108 | 109 | for ecto_type_module <- [Money.Ecto.Composite.Type, Money.Ecto.Map.Type] do 110 | test "#{inspect(ecto_type_module)}: dump anything other than a Money struct or a 2-tuple is an error" do 111 | assert unquote(ecto_type_module).dump(100) == :error 112 | end 113 | 114 | test "#{inspect(ecto_type_module)}: cast a map with the current structure but an empty amount" do 115 | assert unquote(ecto_type_module).cast(%{"currency" => "USD", "amount" => ""}) == {:ok, nil} 116 | end 117 | 118 | test "#{inspect(ecto_type_module)}: cast a map with the current structure but a nil amount" do 119 | assert unquote(ecto_type_module).cast(%{"currency" => "USD", "amount" => nil}) == {:ok, nil} 120 | end 121 | 122 | test "#{inspect(ecto_type_module)}: cast a money struct" do 123 | assert unquote(ecto_type_module).cast(Money.new(:USD, 100)) == {:ok, Money.new(:USD, 100)} 124 | end 125 | 126 | test "#{inspect(ecto_type_module)}: cast a map with string keys and values" do 127 | assert unquote(ecto_type_module).cast(%{"currency" => "USD", "amount" => "100"}) == 128 | {:ok, Money.new(:USD, 100)} 129 | end 130 | 131 | test "#{inspect(ecto_type_module)}: cast a map with string keys and numeric amount" do 132 | assert unquote(ecto_type_module).cast(%{"currency" => "USD", "amount" => 100}) == 133 | {:ok, Money.new(:USD, 100)} 134 | end 135 | 136 | test "#{inspect(ecto_type_module)}: cast a map with string keys, atom currency, and string amount" do 137 | assert unquote(ecto_type_module).cast(%{"currency" => :USD, "amount" => "100"}) == 138 | {:ok, Money.new(100, :USD)} 139 | end 140 | 141 | test "#{inspect(ecto_type_module)}: cast a map with string keys, atom currency, and numeric amount" do 142 | assert unquote(ecto_type_module).cast(%{"currency" => :USD, "amount" => 100}) == 143 | {:ok, Money.new(100, :USD)} 144 | end 145 | 146 | test "#{inspect(ecto_type_module)}: cast a map with string keys and invalid currency" do 147 | assert unquote(ecto_type_module).cast(%{"currency" => "AAA", "amount" => 100}) == 148 | {:error, 149 | exception: Money.UnknownCurrencyError, message: "The currency \"AAA\" is invalid"} 150 | end 151 | 152 | test "#{inspect(ecto_type_module)}: cast a map with atom keys and values" do 153 | assert unquote(ecto_type_module).cast(%{currency: "USD", amount: "100"}) == 154 | {:ok, Money.new(100, :USD)} 155 | end 156 | 157 | test "#{inspect(ecto_type_module)}: cast a map with atom keys and numeric amount" do 158 | assert unquote(ecto_type_module).cast(%{currency: "USD", amount: 100}) == 159 | {:ok, Money.new(100, :USD)} 160 | end 161 | 162 | test "#{inspect(ecto_type_module)}: cast a map with atom keys, atom currency, and numeric amount" do 163 | assert unquote(ecto_type_module).cast(%{currency: :USD, amount: 100}) == 164 | {:ok, Money.new(100, :USD)} 165 | end 166 | 167 | test "#{inspect(ecto_type_module)}: cast a map with atom keys, atom currency, and string amount" do 168 | assert unquote(ecto_type_module).cast(%{currency: :USD, amount: "100"}) == 169 | {:ok, Money.new(100, :USD)} 170 | end 171 | 172 | test "#{inspect(ecto_type_module)}: cast a map with atom keys and invalid currency" do 173 | assert unquote(ecto_type_module).cast(%{currency: "AAA", amount: 100}) == 174 | {:error, 175 | exception: Money.UnknownCurrencyError, message: "The currency \"AAA\" is invalid"} 176 | end 177 | 178 | test "#{inspect(ecto_type_module)}: cast a string that includes currency code and amount" do 179 | assert unquote(ecto_type_module).cast("100 USD") == {:ok, Money.new(100, :USD)} 180 | assert unquote(ecto_type_module).cast("USD 100") == {:ok, Money.new(100, :USD)} 181 | end 182 | 183 | test "#{inspect(ecto_type_module)}: cast a string that includes currency code and localised amount" do 184 | # "de" 185 | locale = Test.Cldr.get_locale() 186 | Test.Cldr.put_locale("de") 187 | assert unquote(ecto_type_module).cast("100,00 USD") == {:ok, Money.new("100,00", :USD)} 188 | Test.Cldr.put_locale(locale) 189 | end 190 | 191 | test "#{inspect(ecto_type_module)}: cast an invalid string is an error" do 192 | assert unquote(ecto_type_module).cast("100 USD and other stuff") == 193 | {:error, 194 | exception: Money.UnknownCurrencyError, 195 | message: "The currency \"USD and other stuff\" is unknown or not supported"} 196 | end 197 | 198 | test "#{inspect(ecto_type_module)}: A nil currency amount returns an error on casting" do 199 | assert unquote(ecto_type_module).cast(%{amount: "10", currency: nil}) == 200 | {:error, 201 | exception: Money.UnknownCurrencyError, message: "Currency must not be `nil`"} 202 | end 203 | 204 | test "#{inspect(ecto_type_module)}: cast anything else is an error" do 205 | assert unquote(ecto_type_module).cast(:atom) == :error 206 | end 207 | 208 | test "#{inspect(ecto_type_module)}: cast amount error does not raise" do 209 | assert unquote(ecto_type_module).cast(%{"currency" => "USD", "amount" => "yes"}) 210 | end 211 | 212 | test "#{inspect(ecto_type_module)}: cast localized amount error does not raise" do 213 | Cldr.put_locale(Money.Cldr, "de") 214 | 215 | assert unquote(ecto_type_module).cast(%{currency: :NOK, amount: "218,75"}) == 216 | {:ok, Money.from_float(:NOK, 218.75)} 217 | 218 | Cldr.put_locale(Money.Cldr, "en") 219 | end 220 | 221 | test "#{inspect(ecto_type_module)}: cast nil returns nil" do 222 | assert unquote(ecto_type_module).cast(nil) == {:ok, nil} 223 | end 224 | 225 | test "#{inspect(ecto_type_module)}: dumo nil returns nil" do 226 | assert unquote(ecto_type_module).dump(nil) == {:ok, nil} 227 | end 228 | 229 | test "#{inspect(ecto_type_module)}: load nil returns nil" do 230 | assert unquote(ecto_type_module).load(nil) == {:ok, nil} 231 | end 232 | 233 | test "#{inspect(ecto_type_module)}: equal? two equal money struct returns true" do 234 | assert unquote(ecto_type_module).equal?( 235 | Money.new(:USD, 100), 236 | Money.new(:USD, Decimal.new("100.0")) 237 | ) == true 238 | end 239 | 240 | test "#{inspect(ecto_type_module)}: equal? two unequal money struct returns false" do 241 | assert unquote(ecto_type_module).equal?( 242 | Money.new(:USD, 100), 243 | Money.new(:USD, Decimal.new("200.0")) 244 | ) == false 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /test/money_sql_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MoneySqlTest do 2 | end 3 | -------------------------------------------------------------------------------- /test/query_api/composite_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Query.API.Test do 2 | use Money.SQL.RepoCase 3 | use Money.Ecto.Query.API, adapter: Money.Ecto.Query.API.Composite 4 | 5 | describe "Query.API helpers" do 6 | setup do 7 | [m_usd: Money.new(:USD, 100), m_aud: Money.new(:AUD, 50), m_eur: Money.new(:EUR, 100)] 8 | |> tap(fn [m_usd: m_usd, m_aud: m_aud, m_eur: m_eur] -> 9 | {:ok, _} = Repo.insert(%Organization{payroll: m_eur, name: "EU"}) 10 | {:ok, _} = Repo.insert(%Organization{payroll: m_usd, name: "UE"}) 11 | {:ok, _} = Repo.insert(%Organization{payroll: m_usd, name: "UE"}) 12 | {:ok, _} = Repo.insert(%Organization{payroll: m_aud, name: "EU"}) 13 | {:ok, _} = Repo.insert(%Organization{payroll: m_aud, name: "EU"}) 14 | {:ok, _} = Repo.insert(%Organization{payroll: m_aud, name: "AU"}) 15 | {:ok, _} = Repo.insert(%Organization{payroll: nil, name: "EU"}) 16 | end) 17 | end 18 | 19 | test "select by currency(-ies)", %{m_aud: m_aud, m_usd: m_usd, m_eur: m_eur} do 20 | same_currency = 21 | Organization 22 | |> where([o], currency_eq(o.payroll, :AUD)) 23 | |> select([o], o.payroll) 24 | |> Repo.all() 25 | 26 | assert [^m_aud, ^m_aud, ^m_aud] = same_currency 27 | 28 | two_currencies = 29 | from(o in Organization, where: currency_in(o.payroll, [:USD, :EUR]), select: o.payroll) 30 | |> Repo.all() 31 | 32 | assert [^m_eur, ^m_usd, ^m_usd] = Enum.sort(two_currencies) 33 | end 34 | 35 | test "sum by currencies", %{m_eur: m_eur} do 36 | eu_standard = 37 | Organization 38 | |> where([o], o.name == ^"EU") 39 | |> group_by([o], [currency_code(o.payroll)]) 40 | |> select([o], sum(o.payroll, true)) 41 | 42 | eu_helpers = 43 | Organization 44 | |> total_by([o], o.payroll) 45 | |> where([o], o.name == ^"EU") 46 | 47 | assert eu_standard.group_bys |> Enum.map(& &1.expr) == 48 | eu_helpers.group_bys |> Enum.map(& &1.expr) 49 | 50 | eu = Repo.all(eu_helpers) 51 | auds_eurs = [Money.new(:AUD, 100), m_eur] 52 | assert ^auds_eurs = Enum.sort(eu) 53 | end 54 | 55 | test "sum by currency", %{m_aud: m_aud} do 56 | aud_eu_standard = 57 | Organization 58 | |> where([o], o.name == ^"EU" or o.name == ^"AU") 59 | |> group_by([o], [currency_code(o.payroll)]) 60 | |> select([o], sum(o.payroll, true)) 61 | 62 | aud_eu_helpers = 63 | Organization 64 | |> total_by([o], o.payroll, :AUD) 65 | |> where([o], o.name == ^"EU" or o.name == ^"AU") 66 | 67 | assert aud_eu_standard.group_bys |> Enum.map(& &1.expr) == 68 | aud_eu_helpers.group_bys |> Enum.map(& &1.expr) 69 | 70 | aud_eu = Repo.one(aud_eu_helpers) 71 | {:ok, auds} = Money.sum([m_aud, m_aud, m_aud]) 72 | assert ^auds = aud_eu 73 | end 74 | 75 | test "select by amount", %{m_usd: m_usd, m_eur: m_eur} do 76 | no_currency_filter = 77 | Organization 78 | |> where([o], amount_eq(o.payroll, 100)) 79 | |> select([o], o.payroll) 80 | |> Repo.all() 81 | 82 | assert [^m_eur, ^m_usd, ^m_usd] = no_currency_filter 83 | 84 | currency_filter = 85 | Organization 86 | |> where([o], currency_eq(o.payroll, "USD")) 87 | |> where([o], amount_eq(o.payroll, 100)) 88 | |> select([o], o.payroll) 89 | |> Repo.all() 90 | 91 | assert [^m_usd, ^m_usd] = currency_filter 92 | 93 | currency_filter = 94 | Organization 95 | |> where([o], money_eq(o.payroll, Money.new!(100, :USD))) 96 | |> select([o], o.payroll) 97 | |> Repo.all() 98 | 99 | assert [^m_usd, ^m_usd] = currency_filter 100 | end 101 | 102 | test "select by amounts", %{m_usd: m_usd, m_eur: m_eur} do 103 | no_currency_filter = 104 | Organization 105 | |> where([o], amount_in(o.payroll, 90..110)) 106 | |> select([o], o.payroll) 107 | |> Repo.all() 108 | 109 | assert [^m_eur, ^m_usd, ^m_usd] = no_currency_filter 110 | 111 | currency_filter = 112 | Organization 113 | |> where([o], currency_eq(o.payroll, "USD")) 114 | |> where([o], amount_in(o.payroll, 100..90//-1)) 115 | |> select([o], o.payroll) 116 | |> Repo.all() 117 | 118 | assert [^m_usd, ^m_usd] = currency_filter 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/query_api/map_mysql_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Query.API.Map.MySQL.Test do 2 | use Money.SQL.RepoCase 3 | use Money.Ecto.Query.API, adapter: Money.Ecto.Query.API.Map.MySQL 4 | 5 | @moduletag skip: true 6 | describe "Query.API helpers (MySQL)" do 7 | setup do 8 | [m_usd: Money.new(:USD, 100), m_aud: Money.new(:AUD, 50), m_eur: Money.new(:EUR, 100)] 9 | |> tap(fn [m_usd: m_usd, m_aud: m_aud, m_eur: m_eur] -> 10 | {:ok, _} = Repo.insert(%Organization{payroll: m_eur, name: "EU"}) 11 | {:ok, _} = Repo.insert(%Organization{payroll: m_usd, name: "UE"}) 12 | {:ok, _} = Repo.insert(%Organization{payroll: m_usd, name: "UE"}) 13 | {:ok, _} = Repo.insert(%Organization{payroll: m_aud, name: "EU"}) 14 | {:ok, _} = Repo.insert(%Organization{payroll: m_aud, name: "EU"}) 15 | {:ok, _} = Repo.insert(%Organization{payroll: m_aud, name: "AU"}) 16 | {:ok, _} = Repo.insert(%Organization{payroll: nil, name: "EU"}) 17 | end) 18 | end 19 | 20 | test "select by currency(-ies)", %{m_aud: m_aud, m_usd: m_usd, m_eur: m_eur} do 21 | same_currency = 22 | Organization 23 | |> where([o], currency_eq(o.payroll, :AUD)) 24 | |> select([o], o.payroll) 25 | |> Repo.all() 26 | 27 | assert [^m_aud, ^m_aud, ^m_aud] = same_currency 28 | 29 | two_currencies = 30 | from(o in Organization, where: currency_in(o.payroll, [:USD, :EUR]), select: o.payroll) 31 | |> Repo.all() 32 | 33 | assert [^m_eur, ^m_usd, ^m_usd] = Enum.sort(two_currencies) 34 | end 35 | 36 | test "sum by currencies", %{m_eur: m_eur} do 37 | eu_standard = 38 | Organization 39 | |> where([o], o.name == ^"EU") 40 | |> group_by([o], [currency_code(o.payroll)]) 41 | |> select([o], sum(o.payroll, true)) 42 | 43 | eu_helpers = 44 | Organization 45 | |> total_by([o], o.payroll) 46 | |> where([o], o.name == ^"EU") 47 | 48 | assert eu_standard.group_bys |> Enum.map(& &1.expr) == 49 | eu_helpers.group_bys |> Enum.map(& &1.expr) 50 | 51 | eu = Repo.all(eu_helpers) 52 | auds_eurs = [Money.new(:AUD, 100), m_eur] 53 | assert ^auds_eurs = Enum.sort(eu) 54 | end 55 | 56 | test "sum by currency", %{m_aud: m_aud} do 57 | aud_eu_standard = 58 | Organization 59 | |> where([o], o.name == ^"EU" or o.name == ^"AU") 60 | |> group_by([o], [currency_code(o.payroll)]) 61 | |> select([o], sum(o.payroll, true)) 62 | 63 | aud_eu_helpers = 64 | Organization 65 | |> total_by([o], o.payroll, :AUD) 66 | |> where([o], o.name == ^"EU" or o.name == ^"AU") 67 | 68 | assert aud_eu_standard.group_bys |> Enum.map(& &1.expr) == 69 | aud_eu_helpers.group_bys |> Enum.map(& &1.expr) 70 | 71 | aud_eu = Repo.one(aud_eu_helpers) 72 | {:ok, auds} = Money.sum([m_aud, m_aud, m_aud]) 73 | assert ^auds = aud_eu 74 | end 75 | 76 | test "select by amount", %{m_usd: m_usd, m_eur: m_eur} do 77 | no_currency_filter = 78 | Organization 79 | |> where([o], amount_eq(o.payroll, 100)) 80 | |> select([o], o.payroll) 81 | |> Repo.all() 82 | 83 | assert [^m_eur, ^m_usd, ^m_usd] = no_currency_filter 84 | 85 | currency_filter = 86 | Organization 87 | |> where([o], currency_eq(o.payroll, "USD")) 88 | |> where([o], amount_eq(o.payroll, 100)) 89 | |> select([o], o.payroll) 90 | |> Repo.all() 91 | 92 | assert [^m_usd, ^m_usd] = currency_filter 93 | 94 | currency_filter = 95 | Organization 96 | |> where([o], money_eq(o.payroll, Money.new!(100, :USD))) 97 | |> select([o], o.payroll) 98 | |> Repo.all() 99 | 100 | assert [^m_usd, ^m_usd] = currency_filter 101 | end 102 | 103 | test "select by amounts", %{m_usd: m_usd, m_eur: m_eur} do 104 | no_currency_filter = 105 | Organization 106 | |> where([o], amount_in(o.payroll, 90..110)) 107 | |> select([o], o.payroll) 108 | |> Repo.all() 109 | 110 | assert [^m_eur, ^m_usd, ^m_usd] = no_currency_filter 111 | 112 | currency_filter = 113 | Organization 114 | |> where([o], currency_eq(o.payroll, "USD")) 115 | |> where([o], amount_in(o.payroll, 100..90//-1)) 116 | |> select([o], o.payroll) 117 | |> Repo.all() 118 | 119 | assert [^m_usd, ^m_usd] = currency_filter 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/query_api/map_postgres_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Money.Query.API.Map.Postgres.Test do 2 | use Money.SQL.RepoCase 3 | use Money.Ecto.Query.API, adapter: Money.Ecto.Query.API.Map.Postgres 4 | 5 | describe "Query.API helpers (MySQL)" do 6 | setup do 7 | [m_usd: Money.new(:USD, 100), m_aud: Money.new(:AUD, 50), m_eur: Money.new(:EUR, 100)] 8 | |> tap(fn [m_usd: m_usd, m_aud: m_aud, m_eur: m_eur] -> 9 | {:ok, _} = Repo.insert(%Organization{revenue: m_eur, name: "EU"}) 10 | {:ok, _} = Repo.insert(%Organization{revenue: m_usd, name: "UE"}) 11 | {:ok, _} = Repo.insert(%Organization{revenue: m_usd, name: "UE"}) 12 | {:ok, _} = Repo.insert(%Organization{revenue: m_aud, name: "EU"}) 13 | {:ok, _} = Repo.insert(%Organization{revenue: m_aud, name: "EU"}) 14 | {:ok, _} = Repo.insert(%Organization{revenue: m_aud, name: "AU"}) 15 | {:ok, _} = Repo.insert(%Organization{revenue: nil, name: "EU"}) 16 | end) 17 | end 18 | 19 | test "select by currency(-ies)", %{m_aud: m_aud, m_usd: m_usd, m_eur: m_eur} do 20 | same_currency = 21 | Organization 22 | |> where([o], currency_eq(o.revenue, :AUD)) 23 | |> select([o], o.revenue) 24 | |> Repo.all() 25 | 26 | assert [^m_aud, ^m_aud, ^m_aud] = same_currency 27 | 28 | two_currencies = 29 | from(o in Organization, where: currency_in(o.revenue, [:USD, :EUR]), select: o.revenue) 30 | |> Repo.all() 31 | 32 | assert [^m_eur, ^m_usd, ^m_usd] = Enum.sort(two_currencies) 33 | end 34 | 35 | test "sum by currencies", %{m_eur: m_eur} do 36 | eu_standard = 37 | Organization 38 | |> where([o], o.name == ^"EU") 39 | |> group_by([o], [currency_code(o.revenue)]) 40 | |> select([o], sum(o.revenue, true)) 41 | 42 | eu_helpers = 43 | Organization 44 | |> total_by([o], o.revenue) 45 | |> where([o], o.name == ^"EU") 46 | 47 | assert eu_standard.group_bys |> Enum.map(& &1.expr) == 48 | eu_helpers.group_bys |> Enum.map(& &1.expr) 49 | 50 | eu = Repo.all(eu_helpers) 51 | auds_eurs = [Money.new(:AUD, 100), m_eur] 52 | assert ^auds_eurs = Enum.sort(eu) 53 | end 54 | 55 | test "sum by currency", %{m_aud: m_aud} do 56 | aud_eu_standard = 57 | Organization 58 | |> where([o], o.name == ^"EU" or o.name == ^"AU") 59 | |> group_by([o], [currency_code(o.revenue)]) 60 | |> select([o], sum(o.revenue, true)) 61 | 62 | aud_eu_helpers = 63 | Organization 64 | |> total_by([o], o.revenue, :AUD) 65 | |> where([o], o.name == ^"EU" or o.name == ^"AU") 66 | 67 | assert aud_eu_standard.group_bys |> Enum.map(& &1.expr) == 68 | aud_eu_helpers.group_bys |> Enum.map(& &1.expr) 69 | 70 | aud_eu = Repo.one(aud_eu_helpers) 71 | {:ok, auds} = Money.sum([m_aud, m_aud, m_aud]) 72 | assert ^auds = aud_eu 73 | end 74 | 75 | test "select by amount", %{m_usd: m_usd, m_eur: m_eur} do 76 | no_currency_filter = 77 | Organization 78 | |> where([o], amount_eq(o.revenue, 100)) 79 | |> select([o], o.revenue) 80 | |> Repo.all() 81 | 82 | assert [^m_eur, ^m_usd, ^m_usd] = no_currency_filter 83 | 84 | currency_filter = 85 | Organization 86 | |> where([o], currency_eq(o.revenue, "USD")) 87 | |> where([o], amount_eq(o.revenue, 100)) 88 | |> select([o], o.revenue) 89 | |> Repo.all() 90 | 91 | assert [^m_usd, ^m_usd] = currency_filter 92 | 93 | currency_filter = 94 | Organization 95 | |> where([o], money_eq(o.revenue, Money.new!(100, :USD))) 96 | |> select([o], o.revenue) 97 | |> Repo.all() 98 | 99 | assert [^m_usd, ^m_usd] = currency_filter 100 | end 101 | 102 | test "select by amounts", %{m_usd: m_usd, m_eur: m_eur} do 103 | no_currency_filter = 104 | Organization 105 | |> where([o], amount_in(o.revenue, 90..110)) 106 | |> select([o], o.revenue) 107 | |> Repo.all() 108 | 109 | assert [^m_eur, ^m_usd, ^m_usd] = no_currency_filter 110 | 111 | currency_filter = 112 | Organization 113 | |> where([o], currency_eq(o.revenue, "USD")) 114 | |> where([o], amount_in(o.revenue, 100..90//-1)) 115 | |> select([o], o.revenue) 116 | |> Repo.all() 117 | 118 | assert [^m_usd, ^m_usd] = currency_filter 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/support/changeset.ex: -------------------------------------------------------------------------------- 1 | defmodule Money.ValidationSupport do 2 | import Ecto.Changeset 3 | 4 | def test_changeset do 5 | params = %{"value" => "100"} 6 | 7 | %Organization{} 8 | |> cast(params, [:value]) 9 | end 10 | 11 | def non_money_changeset do 12 | params = %{"employee_count" => "100"} 13 | 14 | %Organization{} 15 | |> cast(params, [:employee_count]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/test_cldr.ex: -------------------------------------------------------------------------------- 1 | defmodule Test.Cldr do 2 | use Cldr, 3 | default_locale: "en", 4 | locales: ["en", "und", "de"], 5 | providers: [Cldr.Number] 6 | end 7 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | {:ok, _pid} = Money.SQL.Repo.start_link() 3 | :ok = Ecto.Adapters.SQL.Sandbox.mode(Money.SQL.Repo, :manual) 4 | 5 | defmodule Money.SQL.RepoCase do 6 | use ExUnit.CaseTemplate 7 | 8 | using do 9 | quote do 10 | alias Money.SQL.Repo 11 | 12 | import Ecto 13 | import Ecto.Query 14 | import Money.SQL.RepoCase 15 | 16 | # and any other stuff 17 | end 18 | end 19 | 20 | setup tags do 21 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Money.SQL.Repo) 22 | 23 | unless tags[:async] do 24 | Ecto.Adapters.SQL.Sandbox.mode(Money.SQL.Repo, {:shared, self()}) 25 | end 26 | 27 | :ok 28 | end 29 | end 30 | --------------------------------------------------------------------------------