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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a26cda78a2361b592ca6f90c1afb5f8637792b432038d45b543aa562624d6349
4
- data.tar.gz: 0e9d78eaf7c8118942676f81f4dbebe55614663469d0d8f3095622fc0def3dca
3
+ metadata.gz: 4101988f3c8acc6d5a4810519fa74d1fd27feaba2114ee047ecd755da3229c91
4
+ data.tar.gz: 2a7071882d41791e33c3d189bd5db42ea39b434c597f64a3c5b6ae88facd7e82
5
5
  SHA512:
6
- metadata.gz: 4ba8368b5c3c11be2375368399113b1526b1e2d9f347b2e5d16499a64577dffb5b846a876ba4d04967e8ac8a76fccc717aafb8caaefd25c83bb7dc61ce22839e
7
- data.tar.gz: 52dd36ef69c80695f8e22802e5486332d94d5afcc4e48cf671847705b3120de5ecfb636b191fe629077d8f6ebb35a2dcbd5b6a716f031bdb918a35abe32d23fa
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. A simpler `money_amount` variant is also available for fixed-currency models (see [note](#single-column-mode-money_amount-fixed-currency)).
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 enforcement** — fixed-currency attributes reject wrong currencies at assignment time.
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` or `t.money_amount :price` / `money_amount :price` | `monetize :price_cents` |
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 modes** | Composite (amount+currency), single column | Single cents column, composite (cents+currency) |
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
- ### Naming
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, mapping: { amount: :a, currency: :c }` |
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 don't follow the `<name>_amount` / `<name>_currency` convention:
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, mapping: {
289
- amount: :total_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
- The mapping keys are `:amount` and `:currency`; values are your database column names. You can provide only one key the other falls back to the `<name>_amount` / `<name>_currency` convention:
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, mapping: { amount: :total_amount }
300
- # currency column inferred as `total_currency`
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-mode-money_amount-fixed-currency).
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.to_money('EUR'))
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
- Fixed-currency (`money_amount`) attributes support full Rails-native querying through the custom type — equality, IN, BETWEEN, ordering, and aggregation all work:
401
+ ### Composite query matrix
369
402
 
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
- ```
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) # 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)
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
- Ranges work across currencies `10..50` matches EUR 10 and USD 50:
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.dollars)
440
+ Offer.create!(price: 50.euros)
398
441
 
399
- Offer.where_amount(price: 10..50) # => both records
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, 10.yens])
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` and `money_amount_field` to Rails form builders. `money_field` renders a text input with the locale-formatted money string; `money_amount_field` renders a number input with the raw decimal value.
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
- ### Single-column mode `money_amount` (fixed-currency)
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` 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.
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 see [Querying](#querying) for examples.
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
- specs = klass.money_attribute_specs
41
- attr_names = klass.money_attribute_names_set
42
-
43
- validate_sql_identifiers!(sql, attr_names)
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(substituted, *decomposed)
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 that every word in the SQL is a registered attribute name or an
92
- # allowed keyword.
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 +?+ placeholder
113
- # @raise [ArgumentError] if a placeholder has no preceding attribute name
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 placeholder_positions(sql)
127
- positions = []
128
- offset = 0
112
+ def parse_sql_value_specs(sql, specs)
113
+ current_spec = nil
114
+ value_specs = []
129
115
 
130
- while (idx = sql.index('?', offset))
131
- positions << idx
132
- offset = idx + 1
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
- positions
136
- end
120
+ value_specs << current_spec
121
+ next
122
+ end
137
123
 
138
- # Returns the spec for the +?+ at the given position.
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
- raise ArgumentError, "No money attribute found before '?' in: #{sql.inspect}" if matched.empty?
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
- specs[matched.last.downcase]
131
+ value_specs
154
132
  end
155
133
 
156
134
  # Decomposes +Mint::Money+ bind values to raw storage values using their
@@ -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) = value.is_a?(String) ? Money.parse(value, MoneyAttribute.default_currency) : super
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
  #
@@ -2,5 +2,5 @@
2
2
 
3
3
  module MoneyAttribute
4
4
  # The current gem version, following semantic versioning.
5
- VERSION = '1.2.1'
5
+ VERSION = '1.3.0'
6
6
  end
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.2.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.16
103
+ rubygems_version: 4.0.18
104
104
  specification_version: 4
105
105
  summary: Money attributes for ActiveRecord
106
106
  test_files: []