money_attribute 1.2.1 → 1.3.0
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 +139 -48
- data/Rakefile +12 -0
- data/lib/money_attribute/attribute_spec_registry.rb +30 -0
- data/lib/money_attribute/converter.rb +1 -1
- data/lib/money_attribute/core_ext/string.rb +1 -1
- data/lib/money_attribute/macro.rb +33 -1
- data/lib/money_attribute/query/amount_condition.rb +40 -62
- data/lib/money_attribute/type.rb +5 -1
- data/lib/money_attribute/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4101988f3c8acc6d5a4810519fa74d1fd27feaba2114ee047ecd755da3229c91
|
|
4
|
+
data.tar.gz: 2a7071882d41791e33c3d189bd5db42ea39b434c597f64a3c5b6ae88facd7e82
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eac6fb9ca6ef50c717fc51b8c4aac7977e425787ea98032ae02e66091f74a66158b1f19f401b5b15df3cf8dc7a402aba942e9c7b55f1dc054ec2c617114d2c87
|
|
7
|
+
data.tar.gz: 2216348ddfa7df7d97ddacfe12d15a895d2e1c9ac28392719c14add69741a177ab5064b5a8da5befde3ec9b28a028e37924b4f3db433a2a70af626af5e619ddc
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
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.
|
|
8
|
+
`money_attribute` uses two DB columns (amount + currency) for per-row multi-currency data.
|
|
9
9
|
|
|
10
10
|
```ruby
|
|
11
11
|
class Product < ApplicationRecord
|
|
@@ -68,16 +68,16 @@ That's it. `Product.new(price: 12.dollars).price` is a `Money`.
|
|
|
68
68
|
- **No serialization boilerplate** — declare once, read/write `Money` everywhere.
|
|
69
69
|
- **Integer or decimal columns** — auto-detects the column type and adjusts serialization (e.g. integer stores cents, decimal stores unit value).
|
|
70
70
|
- **Normalizes everything** — pass a number, string, or `Money`; always get a `Money` back.
|
|
71
|
-
- **Currency
|
|
71
|
+
- **Currency handling** — each record stores its own currency and accepts registered currencies at assignment time.
|
|
72
72
|
- **Built on Rails primitives** — uses `ActiveRecord::Type`, `composed_of`, and `normalizes` under the hood. No monkey-patching of core classes.
|
|
73
73
|
|
|
74
74
|
### At a glance — vs money-rails
|
|
75
75
|
|
|
76
76
|
| Feature | MoneyAttribute | money-rails |
|
|
77
77
|
|---|---|---|
|
|
78
|
-
| **Declare** | `t.money_attribute :price` / `money_attribute :price`
|
|
78
|
+
| **Declare** | `t.money_attribute :price` / `money_attribute :price` | `monetize :price_cents` |
|
|
79
79
|
| **Column types** | `integer`, `decimal`, `bigint` — auto-detected | `integer` cents only |
|
|
80
|
-
| **Storage
|
|
80
|
+
| **Storage mode** | Composite (amount+currency) | Single cents column, composite (cents+currency) |
|
|
81
81
|
| **Decimal columns** | Native — `t.decimal :price` | Not supported — must convert to cents manually |
|
|
82
82
|
| **Multi-currency** | `money_attribute :price` (convention: `<name>_amount` + `<name>_currency`) | `monetize :price_cents, with_currency: :price_currency` |
|
|
83
83
|
| **Rails integration** | `ActiveRecord::Type` + `composed_of` — no monkey-patches | `monetize` overrides reader/writer methods |
|
|
@@ -149,7 +149,33 @@ class AddPriceToProducts < ActiveRecord::Migration[8.1]
|
|
|
149
149
|
end
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
-
###
|
|
152
|
+
### Default column names
|
|
153
|
+
|
|
154
|
+
For `money_attribute :price`, the default columns are `price` for the amount
|
|
155
|
+
and `price_currency` for the currency. The migration helper and model macro use
|
|
156
|
+
the same mapping:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
create_table :products do |t|
|
|
160
|
+
t.money_attribute :price
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class Product < ApplicationRecord
|
|
164
|
+
money_attribute :price
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This creates and maps to:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
price
|
|
172
|
+
price_currency
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The amount column defaults to `decimal(20,4)` and the currency column defaults
|
|
176
|
+
to `string(16)`.
|
|
177
|
+
|
|
178
|
+
### Naming options
|
|
153
179
|
|
|
154
180
|
**`money_attribute` (composite):**
|
|
155
181
|
|
|
@@ -158,7 +184,7 @@ end
|
|
|
158
184
|
| `t.money_attribute :price` | `price` decimal(20,4) + `price_currency` string(16) | `money_attribute :price` |
|
|
159
185
|
| `t.money_attribute :price_amount` | `price_amount` decimal(20,4) + `price_currency` string(16) | `money_attribute :price` |
|
|
160
186
|
| `t.money_attribute :price, amount: { type: :fiat_integer }` | `price` bigint + `price_currency` string(16) | `money_attribute :price` |
|
|
161
|
-
| `t.money_attribute :price, amount: { column: :a }, currency: { column: :c }` | `a` + `c` | `money_attribute :price,
|
|
187
|
+
| `t.money_attribute :price, amount: { column: :a }, currency: { column: :c }` | `a` + `c` | `money_attribute :price, amount: { column: :a }, currency: { column: :c }` |
|
|
162
188
|
| `t.money_attribute :price, currency: { limit: 5 }` | `price` decimal(20,4) + `price_currency` string(5) | `money_attribute :price` |
|
|
163
189
|
| `t.remove_money_attribute :price` | Removes `price` + `price_currency` | `money_attribute :price` |
|
|
164
190
|
|
|
@@ -281,26 +307,30 @@ Order.new(total: 19.99.to_money('USD')).total_amount # => 1999
|
|
|
281
307
|
|
|
282
308
|
## Custom column names
|
|
283
309
|
|
|
284
|
-
If your columns
|
|
310
|
+
If your columns do not follow the default mapping, use the same nested column
|
|
311
|
+
options as the migration helper:
|
|
285
312
|
|
|
286
313
|
```ruby
|
|
287
314
|
class Invoice < ApplicationRecord
|
|
288
|
-
money_attribute :total,
|
|
289
|
-
amount:
|
|
290
|
-
currency: :currency_code
|
|
291
|
-
}
|
|
315
|
+
money_attribute :total,
|
|
316
|
+
amount: { column: :total_amount },
|
|
317
|
+
currency: { column: :currency_code }
|
|
292
318
|
end
|
|
293
319
|
```
|
|
294
320
|
|
|
295
|
-
|
|
321
|
+
You can provide only one nested option; the other falls back to the default
|
|
322
|
+
mapping. The older `mapping:` syntax remains supported (but it will eventually be deprecated):
|
|
296
323
|
|
|
297
324
|
```ruby
|
|
298
325
|
class Invoice < ApplicationRecord
|
|
299
|
-
money_attribute :total,
|
|
300
|
-
|
|
326
|
+
money_attribute :total, amount: { column: :total_amount }
|
|
327
|
+
money_attribute :subtotal, mapping: { amount: :subtotal }
|
|
301
328
|
end
|
|
302
329
|
```
|
|
303
330
|
|
|
331
|
+
Model declarations accept only the nested `:column` option. Migration-only
|
|
332
|
+
options such as `:type`, `:null`, and `:default` belong in the migration.
|
|
333
|
+
|
|
304
334
|
## Column resolution
|
|
305
335
|
|
|
306
336
|
`money_attribute :name` is always composite. Columns are resolved in two phases:
|
|
@@ -313,6 +343,11 @@ end
|
|
|
313
343
|
| `name == 'amount'` AND `currency` exists | `amount` + `currency` |
|
|
314
344
|
| Otherwise | `<name>_amount` + `<name>_currency` |
|
|
315
345
|
|
|
346
|
+
The first row is the canonical mapping used by the migration helpers. The
|
|
347
|
+
final row supports existing schemas that use the `<name>_amount` /
|
|
348
|
+
`<name>_currency` convention. For example, `money_attribute :price` also maps
|
|
349
|
+
to `price_amount` + `price_currency` when those are the available columns.
|
|
350
|
+
|
|
316
351
|
**Phase 2 — Override** via `mapping:` is merged on top of the default. Missing keys inherit from the default mapping:
|
|
317
352
|
|
|
318
353
|
```ruby
|
|
@@ -320,7 +355,7 @@ money_attribute :total, mapping: { amount: :total_amount }
|
|
|
320
355
|
# currency falls back to default => :total_currency
|
|
321
356
|
```
|
|
322
357
|
|
|
323
|
-
Raises `ArgumentError` if the resolved columns don't exist. For single-column fixed-currency attributes, see [`money_amount`](#single-column-
|
|
358
|
+
Raises `ArgumentError` if the resolved columns don't exist. For single-column fixed-currency attributes, see the [advanced `money_amount` section](#advanced-money_amount-single-column-fixed-currency).
|
|
324
359
|
|
|
325
360
|
**Example**
|
|
326
361
|
|
|
@@ -332,7 +367,6 @@ create_table :financial_transactions do |t|
|
|
|
332
367
|
t.string :discount_currency, limit: 3
|
|
333
368
|
t.decimal :price_amount
|
|
334
369
|
t.string :price_currency, limit: 3
|
|
335
|
-
t.bigint :tax
|
|
336
370
|
t.decimal :total_amount
|
|
337
371
|
t.string :currency_code, limit: 3
|
|
338
372
|
end
|
|
@@ -344,7 +378,6 @@ class FinancialTransaction < ApplicationRecord
|
|
|
344
378
|
money_attribute :discount # step 2: discount(int) + discount_currency
|
|
345
379
|
money_attribute :price # step 4: price_amount + price_currency
|
|
346
380
|
money_attribute :total, mapping: { amount: :total_amount, currency: :currency_code } # step 1: explicit
|
|
347
|
-
money_amount :tax # single-column, fixed-currency (uses default currency)
|
|
348
381
|
end
|
|
349
382
|
```
|
|
350
383
|
|
|
@@ -355,7 +388,7 @@ end
|
|
|
355
388
|
Multi-currency (`money_attribute`) attributes support equality queries via `composed_of`:
|
|
356
389
|
|
|
357
390
|
```ruby
|
|
358
|
-
Offer.where(price: 10.
|
|
391
|
+
Offer.where(price: 10.euros)
|
|
359
392
|
```
|
|
360
393
|
|
|
361
394
|
For comparisons, use the backing columns directly:
|
|
@@ -365,15 +398,25 @@ Offer.where(price_amount: 10..20, price_currency: 'EUR')
|
|
|
365
398
|
Offer.where('price_amount > ? AND price_currency = ?', 10, 'EUR')
|
|
366
399
|
```
|
|
367
400
|
|
|
368
|
-
|
|
401
|
+
### Composite query matrix
|
|
369
402
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
403
|
+
For `money_attribute`, use native Rails queries for simple equality. Use the
|
|
404
|
+
money-aware helpers when working with `Money` objects or when you need an
|
|
405
|
+
explicit currency filter alongside an amount query:
|
|
406
|
+
|
|
407
|
+
| Operation | Native Rails | Money-aware helper |
|
|
408
|
+
|---|---|---|
|
|
409
|
+
| Equality | `where(price: 10.euros)` | `where_amount(price: 10.euros)` |
|
|
410
|
+
| `IN` | Query backing columns directly | `where_amount(price: [10.euros, 20.euros])` |
|
|
411
|
+
| Range/comparison | Query backing columns directly | `where_currency(price: 'EUR').where_amount(price: 10.euros..20.euros)` |
|
|
412
|
+
| Currency filter | `where(price_currency: 'EUR')` | `where_currency(price: 'EUR')` |
|
|
413
|
+
| Ordering | Query backing columns directly | `order_by_amount(price: :desc)` |
|
|
414
|
+
| Pluck | Returns backing column values | `pluck_amount(:price)` returns `Money` |
|
|
415
|
+
| Pick | Returns backing column values | `pick_amount(:price)` returns `Money` |
|
|
416
|
+
| Sum | Group and sum backing columns directly | `sum_amount(:price)` groups by currency |
|
|
417
|
+
|
|
418
|
+
`where_amount` accepts `Money` values and converts them to the backing amount
|
|
419
|
+
unit. Pair it with `where_currency` when the currency must be constrained.
|
|
377
420
|
|
|
378
421
|
### Money-aware query helpers
|
|
379
422
|
|
|
@@ -384,35 +427,28 @@ For multi-currency attributes, manually decomposing columns is tedious. The quer
|
|
|
384
427
|
Filters by amount value. Accepts a scalar, Range, or Array.
|
|
385
428
|
|
|
386
429
|
```ruby
|
|
387
|
-
Offer.where_amount(price: 10)
|
|
388
|
-
Offer.where_amount(price: [10, 30])
|
|
389
|
-
Offer.where_amount(price: 10..100)
|
|
390
|
-
Offer.where_amount(price: 10...100)
|
|
430
|
+
Offer.where_amount(price: 10.euros) # equality
|
|
431
|
+
Offer.where_amount(price: [10.euros, 30.euros]) # IN
|
|
432
|
+
Offer.where_amount(price: 10.euros..100.euros) # BETWEEN (inclusive)
|
|
433
|
+
Offer.where_amount(price: 10.euros...100.euros) # BETWEEN (exclusive upper bound)
|
|
391
434
|
```
|
|
392
435
|
|
|
393
|
-
|
|
436
|
+
For a currency-specific range, constrain the currency explicitly:
|
|
394
437
|
|
|
395
438
|
```ruby
|
|
396
439
|
Offer.create!(price: 10.euros)
|
|
397
|
-
Offer.create!(price: 50.
|
|
440
|
+
Offer.create!(price: 50.euros)
|
|
398
441
|
|
|
399
|
-
Offer.where_amount(price: 10..50)
|
|
442
|
+
Offer.where_currency(price: 'EUR').where_amount(price: 10.euros..50.euros) # => both records
|
|
400
443
|
```
|
|
401
444
|
|
|
402
445
|
For integer (subunit) columns, pass `Money` objects directly — subunit conversion is handled automatically:
|
|
403
446
|
|
|
404
447
|
```ruby
|
|
405
|
-
FinancialTransaction.where_amount(amount: [10.dollars,
|
|
448
|
+
FinancialTransaction.where_amount(amount: [10.dollars, 20.dollars])
|
|
406
449
|
FinancialTransaction.where_amount(amount: 10.dollars..100.dollars)
|
|
407
450
|
```
|
|
408
451
|
|
|
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
452
|
#### `where_currency`
|
|
417
453
|
|
|
418
454
|
Filters by currency code. Composite attributes only — raises `ArgumentError` for single-column attributes.
|
|
@@ -470,6 +506,20 @@ Offer.none.sum_amount(:price)
|
|
|
470
506
|
|
|
471
507
|
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.
|
|
472
508
|
|
|
509
|
+
### Introspection
|
|
510
|
+
|
|
511
|
+
Use `money_attribute?` and `money_attribute_kind` when building generic model
|
|
512
|
+
or form code:
|
|
513
|
+
|
|
514
|
+
```ruby
|
|
515
|
+
Product.money_attribute?(:price) # => true
|
|
516
|
+
Product.money_attribute_kind(:price) # => :composite or :single
|
|
517
|
+
Product.money_attribute?(:unknown) # => false
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
`money_attribute_kind` returns `:composite` for `money_attribute`, `:single`
|
|
521
|
+
for `money_amount`, and `nil` for undeclared attributes.
|
|
522
|
+
|
|
473
523
|
## Convenience methods
|
|
474
524
|
|
|
475
525
|
MoneyAttribute adds small helpers on `Numeric` and `String`:
|
|
@@ -484,24 +534,53 @@ MoneyAttribute adds small helpers on `Numeric` and `String`:
|
|
|
484
534
|
|
|
485
535
|
## Form helpers
|
|
486
536
|
|
|
487
|
-
MoneyAttribute adds `money_field`
|
|
537
|
+
MoneyAttribute adds `money_field` to Rails form builders for composite money attributes. It renders a text input with the locale-formatted money string.
|
|
488
538
|
|
|
489
539
|
```erb
|
|
490
540
|
<%= form_with model: @product do |form| %>
|
|
491
541
|
<%= form.label :price %>
|
|
492
542
|
<%= form.money_field :price %> <!-- text input, e.g. "$1,234.56" -->
|
|
493
|
-
|
|
494
|
-
<%= form.label :tax %>
|
|
495
|
-
<%= form.money_amount_field :tax %> <!-- number input, e.g. "1234.56" -->
|
|
496
543
|
<% end %>
|
|
497
544
|
```
|
|
498
545
|
|
|
499
|
-
|
|
546
|
+
The field submits a locale-formatted string under the normal model parameter:
|
|
547
|
+
|
|
548
|
+
```ruby
|
|
549
|
+
# app/controllers/products_controller.rb
|
|
550
|
+
def create
|
|
551
|
+
@product = Product.new(product_params)
|
|
552
|
+
|
|
553
|
+
if @product.save
|
|
554
|
+
redirect_to @product
|
|
555
|
+
else
|
|
556
|
+
render :new, status: :unprocessable_entity
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
private
|
|
561
|
+
|
|
562
|
+
def product_params
|
|
563
|
+
params.require(:product).permit(:name, :price)
|
|
564
|
+
end
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
The model normalizes the submitted string through `MoneyAttribute::Converter`.
|
|
568
|
+
For a composite attribute, provide a separate currency input or set the
|
|
569
|
+
currency explicitly when the form must accept a currency different from the
|
|
570
|
+
configured default.
|
|
571
|
+
|
|
572
|
+
### Advanced: `money_amount` (single-column, fixed-currency)
|
|
500
573
|
|
|
501
|
-
`money_amount`
|
|
574
|
+
`money_amount` is an advanced compatibility utility for schemas where one
|
|
575
|
+
numeric column stores amounts in one fixed currency. It is not suitable for
|
|
576
|
+
per-row or multi-currency data. New applications should normally use
|
|
577
|
+
`money_attribute`.
|
|
502
578
|
|
|
503
579
|
The accessor name must match the column name. `money_amount` does not support custom column mapping.
|
|
504
580
|
|
|
581
|
+
Use `money_amount_field` when rendering a form for this mode; it renders a
|
|
582
|
+
number input with the raw decimal value.
|
|
583
|
+
|
|
505
584
|
#### Migration helpers
|
|
506
585
|
|
|
507
586
|
| Method | Action |
|
|
@@ -552,7 +631,19 @@ money_amount :price
|
|
|
552
631
|
|
|
553
632
|
#### Querying
|
|
554
633
|
|
|
555
|
-
Fixed-currency attributes support full Rails-native querying
|
|
634
|
+
Fixed-currency attributes support full Rails-native querying. The helper methods
|
|
635
|
+
are also available when a consistent query interface is useful:
|
|
636
|
+
|
|
637
|
+
| Operation | Native Rails | Money-aware helper |
|
|
638
|
+
|---|---|---|
|
|
639
|
+
| Equality | `where(price: 10.reais)` | `where_amount(price: 10.reais)` |
|
|
640
|
+
| `IN` | `where(price: [10.reais, 20.reais])` | `where_amount(price: [10.reais, 20.reais])` |
|
|
641
|
+
| Range/comparison | `where(price: 10.reais..20.reais)` | `where_amount(price: 10.reais..20.reais)` |
|
|
642
|
+
| Currency filter | Not applicable | Raises `ArgumentError` |
|
|
643
|
+
| Ordering | `order(price: :desc)` | `order_by_amount(price: :desc)` |
|
|
644
|
+
| Pluck | `pluck(:price)` | `pluck_amount(:price)` |
|
|
645
|
+
| Pick | `pick(:price)` | `pick_amount(:price)` |
|
|
646
|
+
| Sum | `sum(:price)` | `sum_amount(:price)` |
|
|
556
647
|
|
|
557
648
|
## Roadmap
|
|
558
649
|
|
data/Rakefile
CHANGED
|
@@ -85,3 +85,15 @@ task 'bench:profile' do
|
|
|
85
85
|
mode = ENV['MODE'] || 'all'
|
|
86
86
|
sh({ 'RAILS_ENV' => 'test' }, 'bundle', 'exec', 'ruby', 'benchmark/profile.rb', mode)
|
|
87
87
|
end
|
|
88
|
+
|
|
89
|
+
desc 'Run focused performance roadmap benchmarks'
|
|
90
|
+
task 'bench:performance' do
|
|
91
|
+
sh({ 'RAILS_ENV' => 'test' }, 'bundle', 'exec', 'ruby', 'benchmark/performance.rb')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
desc 'Compare two focused performance benchmark result files'
|
|
95
|
+
task 'bench:performance:compare' do
|
|
96
|
+
baseline = ENV.fetch('BASELINE')
|
|
97
|
+
current = ENV.fetch('CURRENT')
|
|
98
|
+
sh 'bundle', 'exec', 'ruby', 'benchmark/compare_performance.rb', baseline, current
|
|
99
|
+
end
|
|
@@ -14,6 +14,7 @@ module MoneyAttribute
|
|
|
14
14
|
|
|
15
15
|
REGISTRY = Concurrent::Map.new
|
|
16
16
|
PATTERNS = Concurrent::Map.new
|
|
17
|
+
QUERY_PLANS = Concurrent::Map.new
|
|
17
18
|
|
|
18
19
|
class_methods do
|
|
19
20
|
# Registers a money attribute spec for the current model class.
|
|
@@ -35,6 +36,9 @@ module MoneyAttribute
|
|
|
35
36
|
)
|
|
36
37
|
|
|
37
38
|
money_attribute_specs[spec.name] = spec
|
|
39
|
+
PATTERNS.delete(:"#{self}_name_set")
|
|
40
|
+
PATTERNS.delete(:"#{self}_name_pattern")
|
|
41
|
+
QUERY_PLANS.delete(self)
|
|
38
42
|
spec
|
|
39
43
|
end
|
|
40
44
|
|
|
@@ -74,5 +78,31 @@ module MoneyAttribute
|
|
|
74
78
|
end
|
|
75
79
|
end
|
|
76
80
|
end
|
|
81
|
+
|
|
82
|
+
class_methods do
|
|
83
|
+
# Returns whether the model has a registered money attribute.
|
|
84
|
+
#
|
|
85
|
+
# @param name [Symbol, String] the attribute name
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def money_attribute?(name)
|
|
88
|
+
!money_attribute_spec(name).nil?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns the storage mode for a registered money attribute.
|
|
92
|
+
#
|
|
93
|
+
# @param name [Symbol, String] the attribute name
|
|
94
|
+
# @return [Symbol, nil] +:composite+, +:single+, or +nil+
|
|
95
|
+
def money_attribute_kind(name)
|
|
96
|
+
money_attribute_spec(name)&.kind
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the compiled string-query cache for the current model class.
|
|
100
|
+
#
|
|
101
|
+
# @return [Concurrent::Map]
|
|
102
|
+
# @api private
|
|
103
|
+
def money_attribute_query_plan_cache
|
|
104
|
+
QUERY_PLANS.fetch_or_store(self) { Concurrent::Map.new }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
77
107
|
end
|
|
78
108
|
end
|
|
@@ -36,7 +36,7 @@ module MoneyAttribute
|
|
|
36
36
|
case amount
|
|
37
37
|
when Money, NilClass then amount
|
|
38
38
|
when Numeric then Money.from(amount, currency)
|
|
39
|
-
when String then Money.parse(amount, currency)
|
|
39
|
+
when String then Money.parse(amount, default_currency: currency)
|
|
40
40
|
else raise ArgumentError, "Cannot convert #{amount.inspect} (#{amount.class}) to Money"
|
|
41
41
|
end
|
|
42
42
|
end
|
|
@@ -14,5 +14,5 @@ class String
|
|
|
14
14
|
# @raise [ArgumentError] if the string cannot be parsed
|
|
15
15
|
# @example
|
|
16
16
|
# '12.34'.to_money('USD') # => Mint::Money(12.34, 'USD')
|
|
17
|
-
def to_money(currency = MoneyAttribute.default_currency) = Money.parse(self, currency)
|
|
17
|
+
def to_money(currency = MoneyAttribute.default_currency) = Money.parse(self, default_currency: currency)
|
|
18
18
|
end
|
|
@@ -98,6 +98,35 @@ module MoneyAttribute
|
|
|
98
98
|
"Expected: #{mapping.values.join(', ')}, " \
|
|
99
99
|
"Found: #{attribute_names.join(', ')}"
|
|
100
100
|
end
|
|
101
|
+
|
|
102
|
+
# Normalizes migration-style column options to the legacy mapping format.
|
|
103
|
+
#
|
|
104
|
+
# @param mapping [Hash] the user-supplied +mapping:+ option
|
|
105
|
+
# @param amount [Hash, nil] migration-style amount options
|
|
106
|
+
# @param currency [Hash, nil] migration-style currency options
|
|
107
|
+
# @return [Hash] normalized column mapping
|
|
108
|
+
# @raise [ArgumentError] if options are ambiguous or unsupported
|
|
109
|
+
# @api private
|
|
110
|
+
def normalize_mapping(mapping, amount, currency)
|
|
111
|
+
mapping = mapping.compact
|
|
112
|
+
nested = { amount:, currency: }.compact
|
|
113
|
+
conflicts = mapping.keys & nested.keys
|
|
114
|
+
unless conflicts.empty?
|
|
115
|
+
raise ArgumentError, "Specify #{conflicts.join(', ')} using either mapping: or nested options, not both"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
nested.each do |key, options|
|
|
119
|
+
mapping[key] = normalize_nested_column(key, options)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
mapping
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def normalize_nested_column(key, options)
|
|
126
|
+
return options[:column] if options.is_a?(Hash) && options.keys == [:column] && options[:column]
|
|
127
|
+
|
|
128
|
+
raise ArgumentError, "#{key}: must be a hash containing only a non-empty :column option"
|
|
129
|
+
end
|
|
101
130
|
end
|
|
102
131
|
|
|
103
132
|
class_methods do
|
|
@@ -109,6 +138,8 @@ module MoneyAttribute
|
|
|
109
138
|
#
|
|
110
139
|
# @param name [Symbol, String] the money attribute accessor name
|
|
111
140
|
# @param mapping [Hash] custom column mapping (+:amount+, +:currency+)
|
|
141
|
+
# @param amount [Hash, nil] migration-style amount options (+:column+ only)
|
|
142
|
+
# @param currency [Hash, nil] migration-style currency options (+:column+ only)
|
|
112
143
|
# @return [void]
|
|
113
144
|
#
|
|
114
145
|
# @example
|
|
@@ -116,7 +147,8 @@ module MoneyAttribute
|
|
|
116
147
|
# money_attribute :price
|
|
117
148
|
# money_attribute :price, mapping: { amount: :base_price, currency: :base_currency }
|
|
118
149
|
# end
|
|
119
|
-
def money_attribute(name, mapping: {})
|
|
150
|
+
def money_attribute(name, mapping: {}, amount: nil, currency: nil)
|
|
151
|
+
mapping = normalize_mapping(mapping, amount, currency)
|
|
120
152
|
mapping = resolve_mapping(name, mapping)
|
|
121
153
|
spec = register_composite_spec(name, mapping)
|
|
122
154
|
|
|
@@ -10,6 +10,7 @@ module MoneyAttribute
|
|
|
10
10
|
# @api private
|
|
11
11
|
module AmountCondition
|
|
12
12
|
ALLOWED_KEYWORDS = %w[and or not is null].to_set.freeze
|
|
13
|
+
QueryPlan = Struct.new(:sql, :value_specs, keyword_init: true)
|
|
13
14
|
|
|
14
15
|
# Builds an amount filter for the registered money attribute.
|
|
15
16
|
#
|
|
@@ -37,19 +38,31 @@ module MoneyAttribute
|
|
|
37
38
|
# @raise [ArgumentError] on unknown identifiers or placeholder mismatch
|
|
38
39
|
# @api private
|
|
39
40
|
def resolve_amount_condition_from_sql(sql, *values)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
value_specs = map_placeholders_to_specs(sql, specs)
|
|
45
|
-
decomposed = decompose_values(values, value_specs)
|
|
46
|
-
substituted = substitute_attribute_names(sql, specs)
|
|
41
|
+
plan = klass.money_attribute_query_plan_cache.fetch_or_store(sql) do
|
|
42
|
+
compile_query_plan(sql)
|
|
43
|
+
end
|
|
44
|
+
decomposed = decompose_values(values, plan.value_specs)
|
|
47
45
|
|
|
48
|
-
where(
|
|
46
|
+
where(plan.sql, *decomposed)
|
|
49
47
|
end
|
|
50
48
|
|
|
51
49
|
private
|
|
52
50
|
|
|
51
|
+
# Compiles a string query into substituted SQL and bind metadata.
|
|
52
|
+
#
|
|
53
|
+
# @param sql [String] the SQL fragment
|
|
54
|
+
# @return [QueryPlan] the compiled query plan
|
|
55
|
+
# @api private
|
|
56
|
+
def compile_query_plan(sql)
|
|
57
|
+
specs = klass.money_attribute_specs
|
|
58
|
+
value_specs = parse_sql_value_specs(sql, specs)
|
|
59
|
+
|
|
60
|
+
QueryPlan.new(
|
|
61
|
+
sql: substitute_attribute_names(sql, specs),
|
|
62
|
+
value_specs: value_specs.freeze
|
|
63
|
+
).freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
53
66
|
# Builds an Arel predicate for the given amount value.
|
|
54
67
|
#
|
|
55
68
|
# @param col [Arel::Attributes::Attribute] the amount column node
|
|
@@ -88,69 +101,34 @@ module MoneyAttribute
|
|
|
88
101
|
spec.normalize_query_value(value)
|
|
89
102
|
end
|
|
90
103
|
|
|
91
|
-
# Validates
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
# @param sql [String] the SQL fragment
|
|
95
|
-
# @param attr_names [Set<String>] registered money attribute names
|
|
96
|
-
# @return [void]
|
|
97
|
-
# @raise [ArgumentError] on the first unknown identifier
|
|
98
|
-
# @api private
|
|
99
|
-
def validate_sql_identifiers!(sql, attr_names)
|
|
100
|
-
sql.scan(/\b[a-z_]\w*\b/i).each do |word|
|
|
101
|
-
next if attr_names.include?(word.downcase) || ALLOWED_KEYWORDS.include?(word.downcase)
|
|
102
|
-
|
|
103
|
-
raise ArgumentError, "'#{word}' is not a money attribute on #{klass.name}"
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# Matches each +?+ placeholder to the nearest preceding money attribute name
|
|
108
|
-
# and returns the corresponding spec.
|
|
104
|
+
# Validates identifiers and associates placeholders with the nearest
|
|
105
|
+
# preceding money attribute in one left-to-right pass.
|
|
109
106
|
#
|
|
110
107
|
# @param sql [String] the SQL fragment
|
|
111
108
|
# @param specs [Hash{String => AttributeSpec}] the money attribute specs
|
|
112
|
-
# @return [Array<AttributeSpec>] one spec per
|
|
113
|
-
# @raise [ArgumentError]
|
|
114
|
-
# @api private
|
|
115
|
-
def map_placeholders_to_specs(sql, specs)
|
|
116
|
-
ref_pattern = klass.money_attribute_name_pattern
|
|
117
|
-
|
|
118
|
-
placeholder_positions(sql).map { |pos| spec_at_position(sql, pos, ref_pattern, specs) }
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Returns character positions of each +?+ in the SQL.
|
|
122
|
-
#
|
|
123
|
-
# @param sql [String] the SQL fragment
|
|
124
|
-
# @return [Array<Integer>] the positions of each +?+
|
|
109
|
+
# @return [Array<AttributeSpec>] one spec per bind value
|
|
110
|
+
# @raise [ArgumentError] on an unknown identifier or unassociated placeholder
|
|
125
111
|
# @api private
|
|
126
|
-
def
|
|
127
|
-
|
|
128
|
-
|
|
112
|
+
def parse_sql_value_specs(sql, specs)
|
|
113
|
+
current_spec = nil
|
|
114
|
+
value_specs = []
|
|
129
115
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
end
|
|
116
|
+
sql.scan(/[a-z_]\w*|\?/i) do |token|
|
|
117
|
+
if token == '?'
|
|
118
|
+
raise ArgumentError, "No money attribute found before '?' in: #{sql.inspect}" unless current_spec
|
|
134
119
|
|
|
135
|
-
|
|
136
|
-
|
|
120
|
+
value_specs << current_spec
|
|
121
|
+
next
|
|
122
|
+
end
|
|
137
123
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
# @param sql [String] the SQL fragment
|
|
141
|
-
# @param pos [Integer] position of the +?+
|
|
142
|
-
# @param ref_pattern [Regexp] pre-compiled attribute name pattern
|
|
143
|
-
# @param specs [Hash{String => AttributeSpec}] the money attribute specs
|
|
144
|
-
# @return [AttributeSpec] the spec for the nearest preceding attribute name
|
|
145
|
-
# @raise [ArgumentError] if no attribute name precedes the placeholder
|
|
146
|
-
# @api private
|
|
147
|
-
def spec_at_position(sql, pos, ref_pattern, specs)
|
|
148
|
-
preceding = sql[0...pos]
|
|
149
|
-
matched = preceding.scan(ref_pattern).flatten.compact
|
|
124
|
+
word = token.downcase
|
|
125
|
+
next if ALLOWED_KEYWORDS.include?(word)
|
|
150
126
|
|
|
151
|
-
|
|
127
|
+
current_spec = specs[word]
|
|
128
|
+
raise ArgumentError, "'#{token}' is not a money attribute on #{klass.name}" unless current_spec
|
|
129
|
+
end
|
|
152
130
|
|
|
153
|
-
|
|
131
|
+
value_specs
|
|
154
132
|
end
|
|
155
133
|
|
|
156
134
|
# Decomposes +Mint::Money+ bind values to raw storage values using their
|
data/lib/money_attribute/type.rb
CHANGED
|
@@ -7,7 +7,11 @@ module MoneyAttribute
|
|
|
7
7
|
#
|
|
8
8
|
# @param value [String, Numeric, Mint::Money, nil] the input value
|
|
9
9
|
# @return [Mint::Money, Numeric, nil] a Money value for strings, otherwise delegates to super
|
|
10
|
-
def cast(value)
|
|
10
|
+
def cast(value)
|
|
11
|
+
return Money.parse(value, default_currency: MoneyAttribute.default_currency) if value.is_a?(String)
|
|
12
|
+
|
|
13
|
+
super
|
|
14
|
+
end
|
|
11
15
|
|
|
12
16
|
# Validates that the value is compatible with the fixed currency type.
|
|
13
17
|
#
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: money_attribute
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gilson Ferraz
|
|
@@ -100,7 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
100
100
|
- !ruby/object:Gem::Version
|
|
101
101
|
version: '0'
|
|
102
102
|
requirements: []
|
|
103
|
-
rubygems_version: 4.0.
|
|
103
|
+
rubygems_version: 4.0.18
|
|
104
104
|
specification_version: 4
|
|
105
105
|
summary: Money attributes for ActiveRecord
|
|
106
106
|
test_files: []
|