money_attribute 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +143 -41
- data/Rakefile +43 -2
- data/lib/generators/money_attribute/initializer_generator.rb +10 -0
- data/lib/generators/templates/money_attribute.rb +20 -12
- data/lib/money_attribute/attribute_spec.rb +84 -0
- data/lib/money_attribute/attribute_spec_registry.rb +78 -0
- data/lib/money_attribute/column_type_validations.rb +47 -0
- data/lib/money_attribute/configuration.rb +43 -15
- data/lib/money_attribute/converter.rb +30 -5
- data/lib/money_attribute/core_ext/numeric.rb +17 -0
- data/lib/money_attribute/core_ext/string.rb +11 -1
- data/lib/money_attribute/current.rb +13 -0
- data/lib/money_attribute/form_builder_extension.rb +48 -1
- data/lib/money_attribute/macro.rb +106 -72
- data/lib/money_attribute/migration_extensions/helper.rb +62 -3
- data/lib/money_attribute/migration_extensions/schema_statements.rb +89 -9
- data/lib/money_attribute/migration_extensions/table_definition.rb +68 -11
- data/lib/money_attribute/money_amount.rb +49 -25
- data/lib/money_attribute/query/amount_condition.rb +207 -0
- data/lib/money_attribute/query/amount_order.rb +28 -0
- data/lib/money_attribute/query/currency_condition.rb +29 -0
- data/lib/money_attribute/query/helpers.rb +53 -0
- data/lib/money_attribute/query/pick.rb +41 -0
- data/lib/money_attribute/query/pluck.rb +39 -0
- data/lib/money_attribute/query/sum.rb +52 -0
- data/lib/money_attribute/query.rb +125 -0
- data/lib/money_attribute/railtie.rb +47 -1
- data/lib/money_attribute/type.rb +41 -33
- data/lib/money_attribute/version.rb +2 -1
- data/lib/money_attribute.rb +16 -1
- metadata +17 -5
- data/lib/money_attribute/core_ext.rb +0 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a26cda78a2361b592ca6f90c1afb5f8637792b432038d45b543aa562624d6349
|
|
4
|
+
data.tar.gz: 0e9d78eaf7c8118942676f81f4dbebe55614663469d0d8f3095622fc0def3dca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ba8368b5c3c11be2375368399113b1526b1e2d9f347b2e5d16499a64577dffb5b846a876ba4d04967e8ac8a76fccc717aafb8caaefd25c83bb7dc61ce22839e
|
|
7
|
+
data.tar.gz: 52dd36ef69c80695f8e22802e5486332d94d5afcc4e48cf671847705b3120de5ecfb636b191fe629077d8f6ebb35a2dcbd5b6a716f031bdb918a35abe32d23fa
|
data/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://github.com/gferraz/money-attribute/actions/workflows/ci.yml)
|
|
4
4
|
[](https://badge.fury.io/rb/money_attribute)
|
|
5
5
|
|
|
6
|
-
Store and read Active Record attributes as `
|
|
6
|
+
Store and read Active Record attributes as `Money` objects with no manual serialization.
|
|
7
7
|
|
|
8
|
-
`money_attribute` uses two DB columns (amount + currency) for per-row multi-currency data. A simpler `money_amount` variant is also available for fixed-currency models (see [note](#single-column-mode
|
|
8
|
+
`money_attribute` uses two DB columns (amount + currency) for per-row multi-currency data. A simpler `money_amount` variant is also available for fixed-currency models (see [note](#single-column-mode-money_amount-fixed-currency)).
|
|
9
9
|
|
|
10
10
|
```ruby
|
|
11
11
|
class Product < ApplicationRecord
|
|
@@ -32,8 +32,7 @@ p.price * 2 # => [USD 24.88]
|
|
|
32
32
|
- [Convenience methods](#convenience-methods)
|
|
33
33
|
- [Form helpers](#form-helpers)
|
|
34
34
|
- [Roadmap](#roadmap)
|
|
35
|
-
- [Development
|
|
36
|
-
- [License](#license)
|
|
35
|
+
- [Development](#development)
|
|
37
36
|
|
|
38
37
|
## Quick start
|
|
39
38
|
|
|
@@ -62,13 +61,13 @@ class Product < ApplicationRecord
|
|
|
62
61
|
end
|
|
63
62
|
```
|
|
64
63
|
|
|
65
|
-
That's it. `Product.new(price: 12.dollars).price` is a `
|
|
64
|
+
That's it. `Product.new(price: 12.dollars).price` is a `Money`.
|
|
66
65
|
|
|
67
66
|
## Why MoneyAttribute?
|
|
68
67
|
|
|
69
|
-
- **No serialization boilerplate** — declare once, read/write `
|
|
68
|
+
- **No serialization boilerplate** — declare once, read/write `Money` everywhere.
|
|
70
69
|
- **Integer or decimal columns** — auto-detects the column type and adjusts serialization (e.g. integer stores cents, decimal stores unit value).
|
|
71
|
-
- **Normalizes everything** — pass a number, string, or `
|
|
70
|
+
- **Normalizes everything** — pass a number, string, or `Money`; always get a `Money` back.
|
|
72
71
|
- **Currency enforcement** — fixed-currency attributes reject wrong currencies at assignment time.
|
|
73
72
|
- **Built on Rails primitives** — uses `ActiveRecord::Type`, `composed_of`, and `normalizes` under the hood. No monkey-patching of core classes.
|
|
74
73
|
|
|
@@ -93,7 +92,7 @@ For a detailed side-by-side comparison, see [COMPARISON.md](COMPARISON.md).
|
|
|
93
92
|
|
|
94
93
|
- Ruby 3.3+
|
|
95
94
|
- Rails 7.1.3.2+
|
|
96
|
-
- [Minting](https://github.com/gferraz/minting) 2.
|
|
95
|
+
- [Minting](https://github.com/gferraz/minting) 2.1+
|
|
97
96
|
|
|
98
97
|
## Installation
|
|
99
98
|
|
|
@@ -188,16 +187,16 @@ MoneyAttribute integrates with Rails I18n to automatically format money amounts
|
|
|
188
187
|
|
|
189
188
|
With `I18n.locale` set to `:en`:
|
|
190
189
|
```ruby
|
|
191
|
-
|
|
190
|
+
Money.from(1234.56, 'USD').to_s # => "$1,234.56"
|
|
192
191
|
```
|
|
193
192
|
|
|
194
193
|
Switch to `:'pt-BR'` and the separators change automatically (requires [`rails-i18n`](https://github.com/svenfuchs/rails-i18n) or your own locale file):
|
|
195
194
|
```ruby
|
|
196
195
|
I18n.locale = :'pt-BR'
|
|
197
|
-
|
|
196
|
+
Money.from(1234.56, 'USD').to_s # => "$1.234,56"
|
|
198
197
|
```
|
|
199
198
|
|
|
200
|
-
The locale backend reads `number.currency.format` from your I18n translations and maps Rails format syntax (`%n` for amount, `%u` for unit) to `
|
|
199
|
+
The locale backend reads `number.currency.format` from your I18n translations and maps Rails format syntax (`%n` for amount, `%u` for unit) to `Money#to_s`. If the translation key is missing (no locale file for that language), it falls back to hardcoded defaults (`.` decimal, `,` thousand, `%<symbol>s%<amount>f` format).
|
|
201
200
|
|
|
202
201
|
You can configure per-sign formatting by adding `positive`, `negative`, and `zero` keys to your locale:
|
|
203
202
|
|
|
@@ -218,9 +217,9 @@ en:
|
|
|
218
217
|
When any of `positive`, `negative`, or `zero` is present, a Hash format is built. Missing keys fall back to `format`:
|
|
219
218
|
|
|
220
219
|
```ruby
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
220
|
+
Money.from(1234.56, 'USD').to_s # => "$1,234.56"
|
|
221
|
+
Money.from(-1234.56, 'USD').to_s # => "($1,234.56)"
|
|
222
|
+
Money.from(0, 'USD').to_s # => "--"
|
|
224
223
|
```
|
|
225
224
|
|
|
226
225
|
If none of those keys are set, `format` is used as a plain string (simple formatting).
|
|
@@ -304,16 +303,24 @@ end
|
|
|
304
303
|
|
|
305
304
|
## Column resolution
|
|
306
305
|
|
|
307
|
-
`money_attribute :name` is always composite.
|
|
306
|
+
`money_attribute :name` is always composite. Columns are resolved in two phases:
|
|
308
307
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
|
312
|
-
|
|
313
|
-
|
|
|
314
|
-
|
|
|
308
|
+
**Phase 1 — Default mapping** is determined by which columns exist:
|
|
309
|
+
|
|
310
|
+
| Condition | Default columns |
|
|
311
|
+
|---|---|
|
|
312
|
+
| `name_currency` exists AND `name` exists | `name` + `name_currency` |
|
|
313
|
+
| `name == 'amount'` AND `currency` exists | `amount` + `currency` |
|
|
314
|
+
| Otherwise | `<name>_amount` + `<name>_currency` |
|
|
315
|
+
|
|
316
|
+
**Phase 2 — Override** via `mapping:` is merged on top of the default. Missing keys inherit from the default mapping:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
money_attribute :total, mapping: { amount: :total_amount }
|
|
320
|
+
# currency falls back to default => :total_currency
|
|
321
|
+
```
|
|
315
322
|
|
|
316
|
-
|
|
323
|
+
Raises `ArgumentError` if the resolved columns don't exist. For single-column fixed-currency attributes, see [`money_amount`](#single-column-mode-money_amount-fixed-currency).
|
|
317
324
|
|
|
318
325
|
**Example**
|
|
319
326
|
|
|
@@ -343,6 +350,8 @@ end
|
|
|
343
350
|
|
|
344
351
|
## Querying
|
|
345
352
|
|
|
353
|
+
### Rails-native queries
|
|
354
|
+
|
|
346
355
|
Multi-currency (`money_attribute`) attributes support equality queries via `composed_of`:
|
|
347
356
|
|
|
348
357
|
```ruby
|
|
@@ -356,7 +365,110 @@ Offer.where(price_amount: 10..20, price_currency: 'EUR')
|
|
|
356
365
|
Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
|
|
357
366
|
```
|
|
358
367
|
|
|
359
|
-
|
|
368
|
+
Fixed-currency (`money_amount`) attributes support full Rails-native querying through the custom type — equality, IN, BETWEEN, ordering, and aggregation all work:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
Product.where(price: 10.to_money('USD')) # equality
|
|
372
|
+
Product.where(price: [10.to_money('USD'), 20.to_money('USD')]) # IN
|
|
373
|
+
Product.where(price: 10.to_money('USD')..20.to_money('USD')) # BETWEEN
|
|
374
|
+
Product.order(price: :desc) # ordering
|
|
375
|
+
Product.where(price: 10.to_money('USD')).sum(:price) # aggregation
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Money-aware query helpers
|
|
379
|
+
|
|
380
|
+
For multi-currency attributes, manually decomposing columns is tedious. The query helpers handle this automatically — just pass Money objects:
|
|
381
|
+
|
|
382
|
+
#### `where_amount`
|
|
383
|
+
|
|
384
|
+
Filters by amount value. Accepts a scalar, Range, or Array.
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
Offer.where_amount(price: 10) # equality (any currency)
|
|
388
|
+
Offer.where_amount(price: [10, 30]) # IN — matches EUR 10, USD 30
|
|
389
|
+
Offer.where_amount(price: 10..100) # BETWEEN (inclusive)
|
|
390
|
+
Offer.where_amount(price: 10...100) # BETWEEN (exclusive upper bound)
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Ranges work across currencies — `10..50` matches EUR 10 and USD 50:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
Offer.create!(price: 10.euros)
|
|
397
|
+
Offer.create!(price: 50.dollars)
|
|
398
|
+
|
|
399
|
+
Offer.where_amount(price: 10..50) # => both records
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
For integer (subunit) columns, pass `Money` objects directly — subunit conversion is handled automatically:
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
FinancialTransaction.where_amount(amount: [10.dollars, 10.yens])
|
|
406
|
+
FinancialTransaction.where_amount(amount: 10.dollars..100.dollars)
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
For decimal columns, raw numbers work:
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
SimpleOffer.where_amount(price: 50)
|
|
413
|
+
SimpleOffer.where_amount(price: 10..100)
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
#### `where_currency`
|
|
417
|
+
|
|
418
|
+
Filters by currency code. Composite attributes only — raises `ArgumentError` for single-column attributes.
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
Offer.where_currency(price: 'EUR')
|
|
422
|
+
Offer.where_currency(price: 10.euros.currency) # also accepts Currency object
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
#### `order_by_amount`
|
|
426
|
+
|
|
427
|
+
Orders by amount. Composite attributes sort by currency ASC first, then amount. Single-column attributes sort by amount only.
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
Offer.order_by_amount(price: :asc) # EUR 10, EUR 100, USD 50
|
|
431
|
+
Offer.order_by_amount(price: :desc) # EUR 100, EUR 10, USD 50
|
|
432
|
+
Offer.order_by_amount(price: nil) # defaults to :asc
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
#### `pluck_amount`
|
|
436
|
+
|
|
437
|
+
Returns money-aware amounts. Follows Rails' `pluck` arity — one attribute returns flat values, multiple attributes return row arrays.
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
Offer.pluck_amount(:price) # => [EUR 10.00, USD 20.00]
|
|
441
|
+
Offer.pluck_amount(:amount, :discount) # => [[USD 100.00, EUR 20.00], ...]
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
#### `pick_amount`
|
|
445
|
+
|
|
446
|
+
Returns a single money-aware value. Follows Rails' `pick` arity.
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
Offer.pick_amount(:price) # => EUR 10.00
|
|
450
|
+
Offer.pick_amount(:amount, :discount) # => [USD 100.00, EUR 20.00]
|
|
451
|
+
Offer.none.pick_amount(:price) # => nil
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
#### `sum_amount`
|
|
455
|
+
|
|
456
|
+
Sums amounts grouped by currency for composite attributes. Accepts a single attribute name only.
|
|
457
|
+
|
|
458
|
+
```ruby
|
|
459
|
+
Offer.sum_amount(:price)
|
|
460
|
+
# => [EUR 30.00, USD 70.00] (one Money per currency, sorted by code)
|
|
461
|
+
|
|
462
|
+
SimpleOffer.sum_amount(:price)
|
|
463
|
+
# => [BRL 60.00] (single-column always returns one Money)
|
|
464
|
+
|
|
465
|
+
Offer.none.sum_amount(:price)
|
|
466
|
+
# => [BRL 0.00] (empty result returns zero Money)
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Notes
|
|
470
|
+
|
|
471
|
+
All query helpers raise `ArgumentError` for non-money attributes. Internally, money attribute metadata is registered per model class. The same attribute name can be used safely in different models, but subclasses do not automatically inherit a parent model's registered money attributes.
|
|
360
472
|
|
|
361
473
|
## Convenience methods
|
|
362
474
|
|
|
@@ -368,7 +480,7 @@ MoneyAttribute adds small helpers on `Numeric` and `String`:
|
|
|
368
480
|
12.euros # => [EUR 12.00]
|
|
369
481
|
```
|
|
370
482
|
|
|
371
|
-
> If you prefer not to extend core classes, use `
|
|
483
|
+
> If you prefer not to extend core classes, use `Money.from(12, 'USD')` instead.
|
|
372
484
|
|
|
373
485
|
## Form helpers
|
|
374
486
|
|
|
@@ -386,7 +498,9 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
|
|
|
386
498
|
|
|
387
499
|
### Single-column mode — `money_amount` (fixed-currency)
|
|
388
500
|
|
|
389
|
-
`money_amount` wraps a numeric column as `
|
|
501
|
+
`money_amount` wraps a numeric column as `Money` using the application's default currency. No per-row currency. A lighter alternative when you don't need multi-currency support.
|
|
502
|
+
|
|
503
|
+
The accessor name must match the column name. `money_amount` does not support custom column mapping.
|
|
390
504
|
|
|
391
505
|
#### Migration helpers
|
|
392
506
|
|
|
@@ -398,9 +512,9 @@ MoneyAttribute adds `money_field` and `money_amount_field` to Rails form builder
|
|
|
398
512
|
Default column: `decimal(20,4)`. The top-level `type:` shortcut selects the column type:
|
|
399
513
|
|
|
400
514
|
```ruby
|
|
401
|
-
t.money_amount :price
|
|
515
|
+
t.money_amount :price # decimal(20,4)
|
|
402
516
|
t.money_amount :btc_balance, type: :crypto_decimal # decimal(36,18)
|
|
403
|
-
t.money_amount :qty,
|
|
517
|
+
t.money_amount :qty, type: :fiat_integer # bigint
|
|
404
518
|
```
|
|
405
519
|
|
|
406
520
|
#### Naming
|
|
@@ -438,15 +552,7 @@ money_amount :price
|
|
|
438
552
|
|
|
439
553
|
#### Querying
|
|
440
554
|
|
|
441
|
-
Fixed-currency attributes support Rails-native querying
|
|
442
|
-
|
|
443
|
-
```ruby
|
|
444
|
-
Product.where(price: 10.to_money('USD')) # equality
|
|
445
|
-
Product.where(price: [10.to_money('USD'), 20.to_money('USD')]) # IN
|
|
446
|
-
Product.where(price: 10.to_money('USD')..20.to_money('USD')) # BETWEEN
|
|
447
|
-
Product.order(price: :desc) # ordering
|
|
448
|
-
Product.where(price: 10.to_money('USD')).sum(:price) # aggregation
|
|
449
|
-
```
|
|
555
|
+
Fixed-currency attributes support full Rails-native querying — see [Querying](#querying) for examples.
|
|
450
556
|
|
|
451
557
|
## Roadmap
|
|
452
558
|
|
|
@@ -465,8 +571,4 @@ The dummy Rails app under `test/dummy` exercises the engine in a full Rails envi
|
|
|
465
571
|
|
|
466
572
|
## Contributing
|
|
467
573
|
|
|
468
|
-
Bug reports
|
|
469
|
-
|
|
470
|
-
## License
|
|
471
|
-
|
|
472
|
-
[MIT](MIT-LICENSE)
|
|
574
|
+
Bug reports welcome at [gferraz/money-attribute](https://github.com/gferraz/money-attribute).
|
data/Rakefile
CHANGED
|
@@ -16,13 +16,41 @@ end
|
|
|
16
16
|
|
|
17
17
|
desc 'Migrate test database'
|
|
18
18
|
task :test_db_migrate do
|
|
19
|
-
sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy')
|
|
19
|
+
sh({ 'RAILS_ENV' => 'test' }, 'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
desc 'Run tests (migrates test DB first)'
|
|
23
23
|
task test: %i[test_db_migrate test_run]
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# --- Adapter-specific tasks ---
|
|
26
|
+
|
|
27
|
+
%w[postgresql mysql2].each do |adapter|
|
|
28
|
+
namespace :test do
|
|
29
|
+
desc "Migrate test database (#{adapter})"
|
|
30
|
+
task "db_migrate:#{adapter}" do
|
|
31
|
+
sh({ 'RAILS_ENV' => 'test', 'DATABASE_ADAPTER' => adapter },
|
|
32
|
+
'bin/rails', 'db:migrate', chdir: 'test/dummy', %i[out err] => File::NULL)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc "Run tests against #{adapter}"
|
|
36
|
+
task adapter do
|
|
37
|
+
Rake::Task[:"test:db_migrate:#{adapter}"].invoke
|
|
38
|
+
sh({ 'DATABASE_ADAPTER' => adapter }, 'bundle', 'exec', 'rake', 'test_run')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc 'Run tests against all adapters (sqlite3, postgresql, mysql2)'
|
|
44
|
+
task 'test:all' do
|
|
45
|
+
Rake::Task[:test].invoke
|
|
46
|
+
%w[postgresql mysql2].each do |adapter|
|
|
47
|
+
Rake::Task[:"test:#{adapter}"].invoke
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# --- Benchmark ---
|
|
52
|
+
|
|
53
|
+
desc 'Run money_attribute vs plain Rails vs money-rails benchmark'
|
|
26
54
|
task bench: :test_db_migrate do
|
|
27
55
|
puts
|
|
28
56
|
puts '=' * 80
|
|
@@ -31,6 +59,13 @@ task bench: :test_db_migrate do
|
|
|
31
59
|
sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'minting' },
|
|
32
60
|
'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
|
|
33
61
|
|
|
62
|
+
puts
|
|
63
|
+
puts '=' * 80
|
|
64
|
+
puts 'plain Rails (raw columns, no monetization)'
|
|
65
|
+
puts '=' * 80
|
|
66
|
+
sh({ 'RAILS_ENV' => 'test', 'BENCH_SIDE' => 'plain' },
|
|
67
|
+
'bundle', 'exec', 'ruby', 'benchmark/comparison.rb')
|
|
68
|
+
|
|
34
69
|
puts
|
|
35
70
|
puts '=' * 80
|
|
36
71
|
puts 'money-rails (money gem)'
|
|
@@ -44,3 +79,9 @@ desc 'Generate consolidated benchmark report (markdown)'
|
|
|
44
79
|
task 'bench:report' do
|
|
45
80
|
ruby 'benchmark/report.rb'
|
|
46
81
|
end
|
|
82
|
+
|
|
83
|
+
desc 'Profile hot paths with stackprof (MODE=string_query|pluck|read_cached|multi_record|arithmetic|all)'
|
|
84
|
+
task 'bench:profile' do
|
|
85
|
+
mode = ENV['MODE'] || 'all'
|
|
86
|
+
sh({ 'RAILS_ENV' => 'test' }, 'bundle', 'exec', 'ruby', 'benchmark/profile.rb', mode)
|
|
87
|
+
end
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
+
# Generator namespace for money_attribute.
|
|
4
5
|
module Generators
|
|
6
|
+
# Rails generator that installs the money_attribute initializer.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# rails generate money_attribute:initializer
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
5
12
|
class InitializerGenerator < ::Rails::Generators::Base
|
|
6
13
|
source_root File.expand_path('../templates', __dir__)
|
|
7
14
|
|
|
8
15
|
desc 'Creates MoneyAttribute initializer.'
|
|
9
16
|
|
|
17
|
+
# Copies the initializer template into the application.
|
|
18
|
+
#
|
|
19
|
+
# @return [void]
|
|
10
20
|
def copy_initializer
|
|
11
21
|
copy_file 'money_attribute.rb', 'config/initializers/money_attribute.rb'
|
|
12
22
|
end
|
|
@@ -2,21 +2,29 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
MoneyAttribute.configure do |config|
|
|
5
|
+
# To set the default currency
|
|
6
|
+
#
|
|
7
|
+
# It must be a registered currency
|
|
8
|
+
#
|
|
9
|
+
config.default_currency = 'USD'
|
|
10
|
+
|
|
11
|
+
# Register built-in crypto currencies
|
|
12
|
+
#
|
|
13
|
+
# 1. Register specific crypto currencies:
|
|
14
|
+
# Mint::Currency.register_crypto('BTC', 'ETH')
|
|
15
|
+
#
|
|
16
|
+
# 2. Register all built-in crypto currencies at once:
|
|
17
|
+
# Mint::Currency.register_all_crypto
|
|
18
|
+
#
|
|
19
|
+
# See available crypto currencies:
|
|
20
|
+
# Mint::Currency.crypto_currencies
|
|
21
|
+
|
|
5
22
|
# Register a custom currency
|
|
6
23
|
#
|
|
7
24
|
# Example:
|
|
8
25
|
# config.added_currencies = [
|
|
9
|
-
#
|
|
10
|
-
# {currency: '
|
|
26
|
+
# {currency: 'ZCRC', subunit: 2, symbol: '₡'},
|
|
27
|
+
# {currency: 'ZNGN', subunit: 3, symbol: '₦'}
|
|
11
28
|
# ]
|
|
12
|
-
config.added_currencies = [
|
|
13
|
-
{ currency: 'XCRC', subunit: 2, symbol: '₡' },
|
|
14
|
-
{ currency: 'XNGN', subunit: 3, symbol: '₦' }
|
|
15
|
-
]
|
|
16
|
-
|
|
17
|
-
# To set the default currency
|
|
18
|
-
#
|
|
19
|
-
# It must be a registered currency
|
|
20
|
-
#
|
|
21
|
-
config.default_currency = 'BRL'
|
|
29
|
+
config.added_currencies = []
|
|
22
30
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# @private Constructor for integer (subunit) columns used by +composed_of+.
|
|
5
|
+
INTEGER_CONSTRUCTOR = lambda do |amount, currency|
|
|
6
|
+
next nil if amount.nil?
|
|
7
|
+
|
|
8
|
+
resolved = Money::Currency.resolve(currency.presence || MoneyAttribute.default_currency) || 'XXX'
|
|
9
|
+
Mint::Money.from_subunits(amount, resolved)
|
|
10
|
+
end.freeze
|
|
11
|
+
|
|
12
|
+
# @private Constructor for decimal (unit value) columns used by +composed_of+.
|
|
13
|
+
DECIMAL_CONSTRUCTOR = lambda do |amount, currency|
|
|
14
|
+
next nil if amount.nil?
|
|
15
|
+
|
|
16
|
+
resolved = Money::Currency.resolve(currency.presence || MoneyAttribute.default_currency) || 'XXX'
|
|
17
|
+
Mint::Money.from(amount, resolved)
|
|
18
|
+
end.freeze
|
|
19
|
+
|
|
20
|
+
# Value object holding metadata for a registered money attribute.
|
|
21
|
+
#
|
|
22
|
+
# Created by +money_attribute+ or +money_amount+ and stored in the class-level registry.
|
|
23
|
+
# Used by query helpers to resolve column names, build Money values, and generate SQL.
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
AttributeSpec = Struct.new(:name, :kind, :amount_column, :currency_column, :amount_type, keyword_init: true) do
|
|
27
|
+
# @return [Boolean] +true+ when the spec describes a two-column (amount + currency) attribute.
|
|
28
|
+
def composite? = kind == :composite
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] +true+ when the spec describes a single-column (fixed currency) attribute.
|
|
31
|
+
def single? = kind == :single
|
|
32
|
+
|
|
33
|
+
# Returns the backing database columns for the attribute.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<String>] two-element array for composite, one-element for single.
|
|
36
|
+
def columns
|
|
37
|
+
@columns ||= (composite? ? [amount_column, currency_column] : [amount_column]).freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Boolean] +true+ when the amount column stores subunits (bigint).
|
|
41
|
+
def integer_amount? = amount_type == :integer
|
|
42
|
+
|
|
43
|
+
# @return [Symbol] +:subunits+ for integer columns, +:to_d+ for decimal columns.
|
|
44
|
+
def amount_extractor = integer_amount? ? :subunits : :to_d
|
|
45
|
+
|
|
46
|
+
# @return [Hash{String => Symbol}] mapping suitable for +composed_of+.
|
|
47
|
+
def composed_of_mapping
|
|
48
|
+
@composed_of_mapping ||= { amount_column => amount_extractor, currency_column => :currency_code }.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Proc] the constructor lambda used by +composed_of+ to instantiate Money values.
|
|
52
|
+
def constructor
|
|
53
|
+
integer_amount? ? INTEGER_CONSTRUCTOR : DECIMAL_CONSTRUCTOR
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Converts a raw amount and currency into a +Mint::Money+ value.
|
|
57
|
+
#
|
|
58
|
+
# @param amount [Integer, BigDecimal, nil] the raw column value
|
|
59
|
+
# @param currency [String, Mint::Currency, nil] the currency code or object
|
|
60
|
+
# @return [Mint::Money, nil] the resolved money value, or nil when amount is nil
|
|
61
|
+
# @api private
|
|
62
|
+
def build_money(amount, currency)
|
|
63
|
+
return unless amount
|
|
64
|
+
return amount if amount.is_a?(Mint::Money)
|
|
65
|
+
|
|
66
|
+
constructor.call(amount, currency)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Normalizes a query value to the column's storage format.
|
|
70
|
+
#
|
|
71
|
+
# Composite attributes: decomposes +Mint::Money+ to subunits via +.subunits+.
|
|
72
|
+
# Single-column attributes: passes through (the registered Type handles serialization).
|
|
73
|
+
#
|
|
74
|
+
# @param value [Mint::Money, Numeric] the query value
|
|
75
|
+
# @return [Integer, BigDecimal, Mint::Money] the normalized value
|
|
76
|
+
# @api private
|
|
77
|
+
def normalize_query_value(value)
|
|
78
|
+
return value unless integer_amount?
|
|
79
|
+
return value.subunits if value.is_a?(Mint::Money)
|
|
80
|
+
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent/map'
|
|
4
|
+
|
|
5
|
+
module MoneyAttribute
|
|
6
|
+
# Stores money attribute metadata on the model class.
|
|
7
|
+
#
|
|
8
|
+
# Holds a per-model registry of {AttributeSpec} objects plus derived caches
|
|
9
|
+
# (name set, regex patterns) populated lazily on first access.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module AttributeSpecRegistry
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
REGISTRY = Concurrent::Map.new
|
|
16
|
+
PATTERNS = Concurrent::Map.new
|
|
17
|
+
|
|
18
|
+
class_methods do
|
|
19
|
+
# Registers a money attribute spec for the current model class.
|
|
20
|
+
#
|
|
21
|
+
# @param name [Symbol, String] the attribute name
|
|
22
|
+
# @param kind [Symbol] +:composite+ or +:single+
|
|
23
|
+
# @param amount_column [Symbol, String] the amount column name
|
|
24
|
+
# @param currency_column [Symbol, String, nil] the currency column name (composite only)
|
|
25
|
+
# @param amount_type [Symbol, nil] +:integer+ or +:decimal+
|
|
26
|
+
# @return [AttributeSpec]
|
|
27
|
+
# @api private
|
|
28
|
+
def register_money_attribute_spec(name, kind:, amount_column:, currency_column: nil, amount_type: nil)
|
|
29
|
+
spec = MoneyAttribute::AttributeSpec.new(
|
|
30
|
+
name: name.to_s,
|
|
31
|
+
kind: kind,
|
|
32
|
+
amount_column: amount_column.to_s,
|
|
33
|
+
currency_column: currency_column&.to_s,
|
|
34
|
+
amount_type: amount_type
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
money_attribute_specs[spec.name] = spec
|
|
38
|
+
spec
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the registered money attribute spec for the given name.
|
|
42
|
+
#
|
|
43
|
+
# @param name [Symbol, String] the attribute name
|
|
44
|
+
# @return [AttributeSpec, nil]
|
|
45
|
+
# @api private
|
|
46
|
+
def money_attribute_spec(name)
|
|
47
|
+
REGISTRY[self]&.fetch(name.to_s, nil)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the registry hash for the current model class.
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash{String => AttributeSpec}]
|
|
53
|
+
# @api private
|
|
54
|
+
def money_attribute_specs
|
|
55
|
+
REGISTRY.fetch_or_store(self) { {} }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns a frozen Set of registered money attribute names.
|
|
59
|
+
#
|
|
60
|
+
# @return [Set<String>]
|
|
61
|
+
# @api private
|
|
62
|
+
def money_attribute_names_set
|
|
63
|
+
PATTERNS.fetch_or_store(:"#{self}_name_set") { money_attribute_specs.keys.to_set.freeze }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns a pre-compiled regex matching any registered money attribute name.
|
|
67
|
+
#
|
|
68
|
+
# @return [Regexp]
|
|
69
|
+
# @api private
|
|
70
|
+
def money_attribute_name_pattern
|
|
71
|
+
PATTERNS.fetch_or_store(:"#{self}_name_pattern") do
|
|
72
|
+
names = money_attribute_specs.keys.map { |n| Regexp.escape(n) }
|
|
73
|
+
/\b(#{names.join('|')})\b/i
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# Shared column-type validation for the +money_attribute+ and +money_amount+
|
|
5
|
+
# macros.
|
|
6
|
+
#
|
|
7
|
+
# Included by {Macro::CompositeClassMethods} and {MoneyAmount}. Raises when a
|
|
8
|
+
# backing column uses a type that cannot store a money amount or currency code.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module ColumnTypeValidations
|
|
12
|
+
VALID_AMOUNT_TYPES = %i[integer bigint decimal].freeze
|
|
13
|
+
VALID_CURRENCY_TYPES = %i[string text].freeze
|
|
14
|
+
|
|
15
|
+
# Validates that a column can store a money amount.
|
|
16
|
+
#
|
|
17
|
+
# @param attr_name [Symbol, String] the money attribute accessor name
|
|
18
|
+
# @param column_name [Symbol, String] the amount column name
|
|
19
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] the column metadata
|
|
20
|
+
# @return [void]
|
|
21
|
+
# @raise [ArgumentError] if the column type is not numeric
|
|
22
|
+
# @api private
|
|
23
|
+
def assert_valid_amount_column!(attr_name, column_name, column)
|
|
24
|
+
return if VALID_AMOUNT_TYPES.include?(column.type)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"`:#{attr_name}` amount column `#{column_name}` must be a numeric type " \
|
|
28
|
+
"(integer, bigint, or decimal), got `#{column.type}`"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Validates that a column can store a currency code.
|
|
32
|
+
#
|
|
33
|
+
# @param attr_name [Symbol, String] the money attribute accessor name
|
|
34
|
+
# @param column_name [Symbol, String] the currency column name
|
|
35
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] the column metadata
|
|
36
|
+
# @return [void]
|
|
37
|
+
# @raise [ArgumentError] if the column type is not string-like
|
|
38
|
+
# @api private
|
|
39
|
+
def assert_valid_currency_column!(attr_name, column_name, column)
|
|
40
|
+
return if VALID_CURRENCY_TYPES.include?(column.type)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"`:#{attr_name}` currency column `#{column_name}` must be a string type " \
|
|
44
|
+
"(string or text), got `#{column.type}`"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|