money_attribute 1.2.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.
@@ -2,7 +2,19 @@
2
2
 
3
3
  module MoneyAttribute
4
4
  module MigrationExtensions
5
- # :nodoc:
5
+ # Shared argument-parsing logic for migration helpers.
6
+ #
7
+ # Resolves accessor names, column overrides, and amount type configuration
8
+ # into concrete column definitions. Included by both {SchemaStatements} and
9
+ # {TableDefinition}.
10
+ #
11
+ # @!attribute [rw] AMOUNT_CONFIG
12
+ # @return [Hash{Symbol => Hash}] mapping of symbolic type names to
13
+ # column type/hash pairs
14
+ # @!attribute [rw] CURRENCY_MIN_LIMIT
15
+ # @return [Integer] minimum allowed currency column string limit (8)
16
+ # @!attribute [rw] CURRENCY_DEFAULT_LIMIT
17
+ # @return [Integer] default currency column string limit (20)
6
18
  module Helper
7
19
  AMOUNT_CONFIG = {
8
20
  crypto_decimal: { type: :decimal, precision: 36, scale: 18 },
@@ -15,6 +27,19 @@ module MoneyAttribute
15
27
 
16
28
  private
17
29
 
30
+ # Parses arguments for a single-column (amount-only) migration.
31
+ #
32
+ # @param accessor [Symbol, String] the money attribute name
33
+ # @param options [Hash] column options
34
+ # @option options [Symbol] :column explicit column name override
35
+ # @option options [Symbol] :type amount type (+:fiat_decimal+,
36
+ # +:crypto_decimal+, +:fiat_integer+)
37
+ # @option options [Boolean] :null whether the column allows NULL
38
+ # @option options [Object] :default default value for the column
39
+ # @return [Array(String, Hash)] column name and merged options hash
40
+ # @raise [ArgumentError] if +precision:+ or +scale:+ are given, or
41
+ # type is unrecognized
42
+ # @api private
18
43
  def parse_money_amount_args(accessor, options)
19
44
  options ||= {}
20
45
  if options.key?(:precision) || options.key?(:scale)
@@ -35,6 +60,17 @@ module MoneyAttribute
35
60
  [column, config.merge(options)]
36
61
  end
37
62
 
63
+ # Parses arguments for the currency column in a composite migration.
64
+ #
65
+ # @param accessor [Symbol, String] the money attribute name
66
+ # @param options [Hash] currency column options
67
+ # @option options [Symbol] :column explicit currency column name override
68
+ # @option options [Integer] :limit string limit for the column
69
+ # @option options [Boolean] :null whether the column allows NULL
70
+ # @option options [Object] :default default value for the column
71
+ # @return [Array(String, Hash)] column name and merged options hash
72
+ # @raise [ArgumentError] if limit is below {CURRENCY_MIN_LIMIT}
73
+ # @api private
38
74
  def parse_currency_args(accessor, options)
39
75
  options ||= {}
40
76
  limit = (options[:limit] || CURRENCY_DEFAULT_LIMIT).to_i
@@ -49,6 +85,18 @@ module MoneyAttribute
49
85
  [column, { limit:, null: options[:null], default: options[:default] }.compact]
50
86
  end
51
87
 
88
+ # Resolves the currency column name for the given accessor.
89
+ #
90
+ # Resolution order:
91
+ # 1. Explicit +column_override+ → returned as-is
92
+ # 2. Accessor is +:amount+ → +currency+
93
+ # 3. Accessor ends with +_amount+ → strips suffix and appends +_currency+
94
+ # 4. Otherwise → +<accessor>_currency+
95
+ #
96
+ # @param accessor [Symbol, String] the money attribute name
97
+ # @param column_override [Symbol, String, nil] explicit column name
98
+ # @return [String] the resolved currency column name
99
+ # @api private
52
100
  def currency_column_name(accessor, column_override)
53
101
  return column_override.to_s if column_override
54
102
 
@@ -59,6 +107,18 @@ module MoneyAttribute
59
107
  "#{radical}_currency"
60
108
  end
61
109
 
110
+ # Parses arguments for a composite (amount + currency) migration.
111
+ #
112
+ # Delegates to {#parse_money_amount_args} and {#parse_currency_args}
113
+ # using the nested +:amount+ and +:currency+ option keys.
114
+ #
115
+ # @param accessor [Symbol, String] the money attribute name
116
+ # @param options [Hash] migration options
117
+ # @option options [Hash] :amount amount column options
118
+ # @option options [Hash] :currency currency column options
119
+ # @return [Array(String, String, Hash, Hash)] amount column name,
120
+ # currency column name, amount options, currency options
121
+ # @api private
62
122
  def parse_money_args(accessor, options = {})
63
123
  amount_column, amount_options = parse_money_amount_args(accessor, options[:amount])
64
124
  currency_column, currency_options = parse_currency_args(accessor, options[:currency])
@@ -4,32 +4,112 @@ require_relative 'helper'
4
4
 
5
5
  module MoneyAttribute
6
6
  module MigrationExtensions
7
- # :nodoc:
7
+ # Migration helper methods for +ActiveRecord::Migration+.
8
+ #
9
+ # Provides reversible methods to add and remove money attribute columns
10
+ # from within a +change+ migration block.
11
+ #
12
+ # @example Adding a composite money attribute
13
+ # class AddPriceToProducts < ActiveRecord::Migration[8.0]
14
+ # def change
15
+ # add_money_attribute :products, :price
16
+ # end
17
+ # end
18
+ #
19
+ # @example Adding a single-column money amount
20
+ # class AddDiscountToProducts < ActiveRecord::Migration[8.0]
21
+ # def change
22
+ # add_money_amount :products, :discount, type: :fiat_integer
23
+ # end
24
+ # end
8
25
  module SchemaStatements
9
26
  include Helper
10
27
 
28
+ # Adds an amount column and a currency column for a composite money attribute.
29
+ #
30
+ # The amount column type is determined by the +:type+ option inside
31
+ # +amount: { type: }+ (defaults to +:fiat_decimal+). The currency column
32
+ # is a string with a configurable limit.
33
+ #
34
+ # @param table_name [Symbol, String] the table to alter
35
+ # @param accessor [Symbol, String] the money attribute accessor name
36
+ # @param options [Hash] migration options
37
+ # @option options [Hash] :amount amount column options
38
+ # (+:column+, +:type+, +:null+, +:default+)
39
+ # @option options [Hash] :currency currency column options
40
+ # (+:column+, +:limit+, +:null+, +:default+)
41
+ # @return [void]
42
+ #
43
+ # @example Default naming and type
44
+ # add_money_attribute :products, :price
45
+ # # => add_column :products, :price, :decimal, precision: 20, scale: 4
46
+ # # => add_column :products, :price_currency, :string, limit: 20
47
+ #
48
+ # @example Custom columns and integer type
49
+ # add_money_attribute :products, :price,
50
+ # amount: { column: :base_price, type: :fiat_integer },
51
+ # currency: { column: :base_currency, limit: 3 }
11
52
  def add_money_attribute(table_name, accessor, options = {})
12
- amount_col, currency_col, amount_opts, currency_opts = parse_money_args(accessor, options)
53
+ amount_column, currency_column, amount_opts, currency_opts = parse_money_args(accessor, options)
13
54
 
14
55
  type = amount_opts.delete(:type)
15
- add_column(table_name, amount_col, type, **amount_opts)
16
- add_column(table_name, currency_col, :string, **currency_opts)
56
+ add_column(table_name, amount_column, type, **amount_opts)
57
+ add_column(table_name, currency_column, :string, **currency_opts)
17
58
  end
18
59
 
60
+ # Removes the amount and currency columns for a composite money attribute.
61
+ #
62
+ # Accepts the same +:amount+ and +:currency+ options as
63
+ # {#add_money_attribute} to identify the columns.
64
+ #
65
+ # @param table_name [Symbol, String] the table to alter
66
+ # @param accessor [Symbol, String] the money attribute accessor name
67
+ # @param options [Hash] migration options
68
+ # @option options [Hash] :amount amount column options (+:column+)
69
+ # @option options [Hash] :currency currency column options (+:column+)
70
+ # @return [void]
19
71
  def remove_money_attribute(table_name, accessor, options = {})
20
- amount_col, currency_col, = parse_money_args(accessor, options)
72
+ amount_column, currency_column, = parse_money_args(accessor, options)
21
73
 
22
- remove_column(table_name, amount_col)
23
- remove_column(table_name, currency_col)
74
+ remove_column(table_name, amount_column)
75
+ remove_column(table_name, currency_column)
24
76
  end
25
77
 
78
+ # Adds a single amount column for a fixed-currency money attribute.
79
+ #
80
+ # No currency column is created — the application default currency is
81
+ # used for all rows.
82
+ #
83
+ # @param table_name [Symbol, String] the table to alter
84
+ # @param accessor [Symbol, String] the money attribute accessor name
85
+ # @param options [Hash] column options
86
+ # @option options [Symbol] :column explicit column name override
87
+ # @option options [Symbol] :type amount type (+:fiat_decimal+,
88
+ # +:crypto_decimal+, +:fiat_integer+)
89
+ # @option options [Boolean] :null whether the column allows NULL
90
+ # @option options [Object] :default default value for the column
91
+ # @return [void]
92
+ #
93
+ # @example Default naming and type
94
+ # add_money_amount :products, :discount
95
+ # # => add_column :products, :discount, :decimal, precision: 20, scale: 4
96
+ #
97
+ # @example Integer column with explicit name
98
+ # add_money_amount :products, :bonus, column: :bonus_cents, type: :fiat_integer
26
99
  def add_money_amount(table_name, accessor, options = {})
27
- amount_col, amount_opts = parse_money_amount_args(accessor, options)
100
+ amount_column, amount_opts = parse_money_amount_args(accessor, options)
28
101
 
29
102
  type = amount_opts.delete(:type)
30
- add_column(table_name, amount_col, type, **amount_opts)
103
+ add_column(table_name, amount_column, type, **amount_opts)
31
104
  end
32
105
 
106
+ # Removes the amount column for a fixed-currency money attribute.
107
+ #
108
+ # @param table_name [Symbol, String] the table to alter
109
+ # @param accessor [Symbol, String] the money attribute accessor name
110
+ # @param options [Hash] column options
111
+ # @option options [Symbol] :column explicit column name override
112
+ # @return [void]
33
113
  def remove_money_amount(table_name, accessor, options = {})
34
114
  remove_column(table_name, (options[:column] || accessor).to_s)
35
115
  end
@@ -4,34 +4,91 @@ require_relative 'helper'
4
4
 
5
5
  module MoneyAttribute
6
6
  module MigrationExtensions
7
- # :nodoc:
7
+ # Migration DSL methods for use inside +create_table+ and +change_table+
8
+ # blocks.
9
+ #
10
+ # Included into both +ActiveRecord::ConnectionAdapters::TableDefinition+
11
+ # and +ActiveRecord::ConnectionAdapters::Table+.
12
+ #
13
+ # @example Inside a +create_table+ block
14
+ # create_table :products do |t|
15
+ # t.string :name
16
+ # t.money_attribute :price
17
+ # t.money_amount :discount, type: :fiat_integer
18
+ # end
19
+ #
20
+ # @example Inside a +change_table+ block
21
+ # change_table :products do |t|
22
+ # t.money_attribute :price, amount: { type: :fiat_integer }
23
+ # t.remove_money_attribute :old_price
24
+ # end
8
25
  module TableDefinition
9
26
  include Helper
10
27
 
28
+ # Adds amount and currency columns within a table definition.
29
+ #
30
+ # @param accessor [Symbol, String] the money attribute accessor name
31
+ # @param options [Hash] migration options
32
+ # @option options [Hash] :amount amount column options
33
+ # (+:column+, +:type+, +:null+, +:default+)
34
+ # @option options [Hash] :currency currency column options
35
+ # (+:column+, +:limit+, +:null+, +:default+)
36
+ # @return [void]
37
+ #
38
+ # @example
39
+ # t.money_attribute :price
40
+ # t.money_attribute :price, amount: { type: :fiat_integer }
11
41
  def money_attribute(accessor, options = {})
12
- amount_col, currency_col, amount_opts, currency_opts = parse_money_args(accessor, options)
42
+ amount_column, currency_column, amount_opts, currency_opts = parse_money_args(accessor, options)
13
43
 
14
- column(amount_col, amount_opts[:type], **amount_opts.except(:type))
15
- column(currency_col, :string, **currency_opts)
44
+ column(amount_column, amount_opts[:type], **amount_opts.except(:type))
45
+ column(currency_column, :string, **currency_opts)
16
46
  end
17
47
 
48
+ # Removes amount and currency columns within a table definition.
49
+ #
50
+ # @param accessor [Symbol, String] the money attribute accessor name
51
+ # @param options [Hash] migration options
52
+ # @option options [Hash] :amount amount column options (+:column+)
53
+ # @option options [Hash] :currency currency column options (+:column+)
54
+ # @return [void]
18
55
  def remove_money_attribute(accessor, options = {})
19
- amount_col, currency_col, = parse_money_args(accessor, options)
56
+ amount_column, currency_column, = parse_money_args(accessor, options)
20
57
 
21
- remove_column(amount_col)
22
- remove_column(currency_col)
58
+ remove_column(amount_column)
59
+ remove_column(currency_column)
23
60
  end
24
61
 
62
+ # Adds a single amount column within a table definition.
63
+ #
64
+ # @param accessor [Symbol, String] the money attribute accessor name
65
+ # @param options [Hash] column options
66
+ # @option options [Symbol] :column explicit column name override
67
+ # @option options [Symbol] :type amount type (+:fiat_decimal+,
68
+ # +:crypto_decimal+, +:fiat_integer+)
69
+ # @option options [Boolean] :null whether the column allows NULL
70
+ # @option options [Object] :default default value for the column
71
+ # @return [void]
72
+ #
73
+ # @example
74
+ # t.money_amount :discount
75
+ # t.money_amount :bonus, column: :bonus_cents, type: :fiat_integer
25
76
  def money_amount(accessor, options = {})
26
- amount_col, amount_opts = parse_money_amount_args(accessor, options)
77
+ amount_column, amount_opts = parse_money_amount_args(accessor, options)
27
78
 
28
- column(amount_col, amount_opts[:type], **amount_opts.except(:type))
79
+ column(amount_column, amount_opts[:type], **amount_opts.except(:type))
29
80
  end
30
81
 
82
+ # Removes a single amount column within a table definition.
83
+ #
84
+ # @param accessor [Symbol, String] the money attribute accessor name
85
+ # @param options [Hash] column options
86
+ # @option options [Symbol] :column explicit column name override
87
+ # @return [void]
31
88
  def remove_money_amount(accessor, options = {})
32
- amount_col, = parse_money_amount_args(accessor, options)
89
+ amount_column, = parse_money_amount_args(accessor, options)
33
90
 
34
- remove_column(amount_col)
91
+ remove_column(amount_column)
35
92
  end
36
93
  end
37
94
  end
@@ -1,12 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
- # :nodoc:
4
+ # Declares fixed-currency money attributes on Active Record models.
5
+ #
6
+ # Provides the +money_amount+ class method which wires a single backing
7
+ # column to a +Mint::Money+ value object using a custom attribute type and a
8
+ # normalizer. The application default currency (or {Current} per-request
9
+ # override) applies to all rows.
10
+ #
11
+ # @example
12
+ # class SimpleOffer < ApplicationRecord
13
+ # money_amount :price
14
+ # end
5
15
  module MoneyAmount
6
16
  extend ActiveSupport::Concern
7
17
 
8
18
  class_methods do
19
+ include ColumnTypeValidations
20
+
9
21
  # Declares a fixed-currency money attribute backed by a single column.
22
+ #
23
+ # The column type determines the storage unit: integer/bigint stores
24
+ # subunits, decimal stores the unit value. No currency column is created —
25
+ # the application default currency applies to every row.
26
+ #
27
+ # @param name [Symbol, String] the money attribute accessor name
28
+ # @return [void]
29
+ # @raise [ArgumentError] if the column does not exist or has an
30
+ # unsupported type
31
+ #
32
+ # @example
33
+ # class SimpleOffer < ApplicationRecord
34
+ # money_amount :price
35
+ # end
10
36
  def money_amount(name)
11
37
  column = column_for_attribute(name)
12
38
 
@@ -16,6 +42,8 @@ module MoneyAttribute
16
42
  "Add a column named '#{name}' or use a different accessor name."
17
43
  end
18
44
 
45
+ assert_valid_amount_column!(name, name, column)
46
+
19
47
  if %i[integer bigint].include?(column.type)
20
48
  amount_type = :integer
21
49
  type_class = IntegerAmountType
@@ -26,7 +54,7 @@ module MoneyAttribute
26
54
 
27
55
  attribute(name, type_class.new)
28
56
  normalizes(name, with: Converter.default)
29
- register_money_attribute_spec(name, kind: :single, amount_col: name, amount_type: amount_type)
57
+ register_money_attribute_spec(name, kind: :single, amount_column: name, amount_type: amount_type)
30
58
  end
31
59
  end
32
60
  end
@@ -1,24 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
- # :nodoc:
4
+ # Internal amount-filter resolution for the +where_amount+ query helper.
5
+ #
6
+ # Supports two input forms: a hash of attribute/value pairs resolved to Arel
7
+ # predicates, and a SQL string with +?+ placeholders where attribute names
8
+ # are substituted for backing columns and +Mint::Money+ binds are decomposed.
9
+ #
10
+ # @api private
5
11
  module AmountCondition
12
+ ALLOWED_KEYWORDS = %w[and or not is null].to_set.freeze
13
+
6
14
  # Builds an amount filter for the registered money attribute.
7
15
  #
8
16
  # @param attr [Symbol] the money attribute name
9
17
  # @param value [Mint::Money, Numeric, Range, Array] the filter value
10
18
  # @return [ActiveRecord::Relation]
11
19
  # @raise [ArgumentError] if the attribute is not a registered money attribute
20
+ # @api private
12
21
  def resolve_amount_condition(attr, value)
13
22
  spec = money_attribute_spec!(attr)
14
- col = arel_table[spec.amount_col]
23
+ col = arel_table[spec.amount_column]
15
24
 
16
25
  where(build_amount_predicate(col, spec, value))
17
26
  end
18
27
 
28
+ # Builds an amount filter using a SQL string with +?+ placeholders.
29
+ #
30
+ # Only money attribute names, +and+, +or+, +not+, +is+, and +null+ are
31
+ # allowed as identifiers. +Mint::Money+ bind values are decomposed to
32
+ # raw storage values automatically.
33
+ #
34
+ # @param sql [String] SQL fragment using attribute names and +?+ placeholders
35
+ # @param values [Array] bind values
36
+ # @return [ActiveRecord::Relation]
37
+ # @raise [ArgumentError] on unknown identifiers or placeholder mismatch
38
+ # @api private
39
+ 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)
47
+
48
+ where(substituted, *decomposed)
49
+ end
50
+
19
51
  private
20
52
 
21
53
  # Builds an Arel predicate for the given amount value.
54
+ #
55
+ # @param col [Arel::Attributes::Attribute] the amount column node
56
+ # @param spec [AttributeSpec] the money attribute spec
57
+ # @param value [Mint::Money, Numeric, Range, Array] the filter value
58
+ # @return [Arel::Nodes::Node] the predicate
59
+ # @api private
22
60
  def build_amount_predicate(col, spec, value)
23
61
  case value
24
62
  when Range
@@ -39,10 +77,131 @@ module MoneyAttribute
39
77
  # so we must pre-normalize Money to the raw storage value (subunits or decimal).
40
78
  # Single-column attributes: the column has a registered Type that handles
41
79
  # serialization, so we pass Money objects through directly to avoid double conversion.
80
+ #
81
+ # @param spec [AttributeSpec] the money attribute spec
82
+ # @param value [Object] the value to normalize
83
+ # @return [Object] the normalized value
84
+ # @api private
42
85
  def normalize_amount_value(spec, value)
43
86
  return value unless spec.composite?
44
87
 
45
88
  spec.normalize_query_value(value)
46
89
  end
90
+
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.
109
+ #
110
+ # @param sql [String] the SQL fragment
111
+ # @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 +?+
125
+ # @api private
126
+ def placeholder_positions(sql)
127
+ positions = []
128
+ offset = 0
129
+
130
+ while (idx = sql.index('?', offset))
131
+ positions << idx
132
+ offset = idx + 1
133
+ end
134
+
135
+ positions
136
+ end
137
+
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
150
+
151
+ raise ArgumentError, "No money attribute found before '?' in: #{sql.inspect}" if matched.empty?
152
+
153
+ specs[matched.last.downcase]
154
+ end
155
+
156
+ # Decomposes +Mint::Money+ bind values to raw storage values using their
157
+ # positional specs. Unlike +normalize_query_value+ (which relies on the
158
+ # custom type for single-column attributes), this always decomposes since
159
+ # raw SQL bind parameters don't resolve custom types.
160
+ #
161
+ # @param values [Array] the bind values
162
+ # @param value_specs [Array<AttributeSpec>] one spec per bind value
163
+ # @return [Array] decomposed bind values
164
+ # @raise [ArgumentError] if the number of values and specs differs
165
+ # @api private
166
+ def decompose_values(values, value_specs)
167
+ if values.size != value_specs.size
168
+ raise ArgumentError, "Expected #{value_specs.size} bind value(s), got #{values.size}"
169
+ end
170
+
171
+ values.zip(value_specs).map do |val, spec|
172
+ if val.is_a?(Mint::Money)
173
+ spec.integer_amount? ? val.subunits : val.to_d
174
+ else
175
+ val
176
+ end
177
+ end
178
+ end
179
+
180
+ # Replaces attribute names with their backing amount column names in the SQL.
181
+ #
182
+ # Only attributes whose name differs from their amount column are
183
+ # substituted; the rest are already valid column references.
184
+ #
185
+ # @param sql [String] the SQL fragment
186
+ # @param specs [Hash{String => AttributeSpec}] the money attribute specs
187
+ # @return [String] the SQL with attribute names replaced by column names
188
+ # @api private
189
+ def substitute_attribute_names(sql, specs)
190
+ to_sub = specs_to_substitute(specs)
191
+ return sql if to_sub.empty?
192
+
193
+ lookup = to_sub.to_h { |s| [s.name.downcase, s.amount_column] }
194
+ pattern = /\b(#{to_sub.map { |s| Regexp.escape(s.name) }.join('|')})\b/i
195
+ sql.gsub(pattern) { |match| lookup[match.downcase] }
196
+ end
197
+
198
+ # Returns the specs whose attribute name differs from their amount column.
199
+ #
200
+ # @param specs [Hash{String => AttributeSpec}] the money attribute specs
201
+ # @return [Array<AttributeSpec>] specs needing SQL substitution
202
+ # @api private
203
+ def specs_to_substitute(specs)
204
+ specs.values.reject { |s| s.name == s.amount_column }
205
+ end
47
206
  end
48
207
  end
@@ -1,21 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
- # :nodoc:
4
+ # Internal ordering resolution for the +order_by_amount+ query helper.
5
+ #
6
+ # @api private
5
7
  module AmountOrder
6
8
  # Builds an amount ordering for the registered money attribute.
7
9
  #
10
+ # Composite attributes order by currency ASC first, then amount in the
11
+ # requested direction. Single-column attributes order by amount only.
12
+ #
8
13
  # @param attr [Symbol] the money attribute name
9
14
  # @param direction [Symbol] +:asc+ or +:desc+
10
15
  # @return [ActiveRecord::Relation]
11
16
  # @raise [ArgumentError] if the attribute is not a registered money attribute
17
+ # @api private
12
18
  def resolve_amount_order(attr, direction)
13
19
  spec = money_attribute_spec!(attr)
14
20
 
15
21
  if spec.composite?
16
- order(spec.currency_col => :asc, spec.amount_col => direction)
22
+ order(spec.currency_column => :asc, spec.amount_column => direction)
17
23
  else
18
- order(spec.amount_col => direction)
24
+ order(spec.amount_column => direction)
19
25
  end
20
26
  end
21
27
  end
@@ -1,14 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MoneyAttribute
4
- # :nodoc:
4
+ # Internal currency-filter resolution for the +where_currency+ query helper.
5
+ #
6
+ # @api private
5
7
  module CurrencyCondition
6
8
  # Builds a currency filter for the registered money attribute.
7
9
  #
10
+ # Only composite attributes have a currency column, so single-column
11
+ # attributes raise.
12
+ #
8
13
  # @param attr [Symbol] the money attribute name
9
14
  # @param currency [String, Mint::Currency] the currency code or object
10
15
  # @return [ActiveRecord::Relation]
11
16
  # @raise [ArgumentError] if the attribute is not a composite money attribute
17
+ # @api private
12
18
  def resolve_currency_condition(attr, currency)
13
19
  spec = money_attribute_spec!(attr)
14
20
 
@@ -17,7 +23,7 @@ module MoneyAttribute
17
23
  end
18
24
 
19
25
  code = currency.is_a?(Mint::Currency) ? currency.code : currency.to_s
20
- where(spec.currency_col => code)
26
+ where(spec.currency_column => code)
21
27
  end
22
28
  end
23
29
  end