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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +143 -41
  3. data/Rakefile +43 -2
  4. data/lib/generators/money_attribute/initializer_generator.rb +10 -0
  5. data/lib/generators/templates/money_attribute.rb +20 -12
  6. data/lib/money_attribute/attribute_spec.rb +84 -0
  7. data/lib/money_attribute/attribute_spec_registry.rb +78 -0
  8. data/lib/money_attribute/column_type_validations.rb +47 -0
  9. data/lib/money_attribute/configuration.rb +43 -15
  10. data/lib/money_attribute/converter.rb +30 -5
  11. data/lib/money_attribute/core_ext/numeric.rb +17 -0
  12. data/lib/money_attribute/core_ext/string.rb +11 -1
  13. data/lib/money_attribute/current.rb +13 -0
  14. data/lib/money_attribute/form_builder_extension.rb +48 -1
  15. data/lib/money_attribute/macro.rb +106 -72
  16. data/lib/money_attribute/migration_extensions/helper.rb +62 -3
  17. data/lib/money_attribute/migration_extensions/schema_statements.rb +89 -9
  18. data/lib/money_attribute/migration_extensions/table_definition.rb +68 -11
  19. data/lib/money_attribute/money_amount.rb +49 -25
  20. data/lib/money_attribute/query/amount_condition.rb +207 -0
  21. data/lib/money_attribute/query/amount_order.rb +28 -0
  22. data/lib/money_attribute/query/currency_condition.rb +29 -0
  23. data/lib/money_attribute/query/helpers.rb +53 -0
  24. data/lib/money_attribute/query/pick.rb +41 -0
  25. data/lib/money_attribute/query/pluck.rb +39 -0
  26. data/lib/money_attribute/query/sum.rb +52 -0
  27. data/lib/money_attribute/query.rb +125 -0
  28. data/lib/money_attribute/railtie.rb +47 -1
  29. data/lib/money_attribute/type.rb +41 -33
  30. data/lib/money_attribute/version.rb +2 -1
  31. data/lib/money_attribute.rb +16 -1
  32. metadata +17 -5
  33. data/lib/money_attribute/core_ext.rb +0 -8
@@ -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,36 +1,60 @@
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
+
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
9
36
  def money_amount(name)
10
- name = name.to_s
11
-
12
- assert_column_exists!(name)
13
-
14
- currency = ::Mint::Currency.resolve!(MoneyAttribute.default_currency)
15
- column_type = detect_column_type(name)
16
-
17
- attribute(name.to_sym, MoneyAttribute::Type.new(currency:, column_type:))
18
- normalizes(name.to_sym, with: Converter.new)
19
- end
20
-
21
- private
22
-
23
- def assert_column_exists!(name)
24
- return if attribute_names.include?(name)
25
-
26
- raise ArgumentError,
27
- "Column '#{name}' does not exist on this table. " \
28
- "Add a column named '#{name}' or use a different accessor name."
29
- end
30
-
31
- def detect_column_type(name)
32
- col = columns.find { |c| c.name == name }
33
- %i[integer bigint].include?(col&.type) ? ActiveRecord::Type::Integer.new : ActiveRecord::Type::Decimal.new
37
+ column = column_for_attribute(name)
38
+
39
+ unless column
40
+ raise ArgumentError,
41
+ "Column '#{name}' does not exist on this table. " \
42
+ "Add a column named '#{name}' or use a different accessor name."
43
+ end
44
+
45
+ assert_valid_amount_column!(name, name, column)
46
+
47
+ if %i[integer bigint].include?(column.type)
48
+ amount_type = :integer
49
+ type_class = IntegerAmountType
50
+ else
51
+ amount_type = :decimal
52
+ type_class = DecimalAmountType
53
+ end
54
+
55
+ attribute(name, type_class.new)
56
+ normalizes(name, with: Converter.default)
57
+ register_money_attribute_spec(name, kind: :single, amount_column: name, amount_type: amount_type)
34
58
  end
35
59
  end
36
60
  end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
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
11
+ module AmountCondition
12
+ ALLOWED_KEYWORDS = %w[and or not is null].to_set.freeze
13
+
14
+ # Builds an amount filter for the registered money attribute.
15
+ #
16
+ # @param attr [Symbol] the money attribute name
17
+ # @param value [Mint::Money, Numeric, Range, Array] the filter value
18
+ # @return [ActiveRecord::Relation]
19
+ # @raise [ArgumentError] if the attribute is not a registered money attribute
20
+ # @api private
21
+ def resolve_amount_condition(attr, value)
22
+ spec = money_attribute_spec!(attr)
23
+ col = arel_table[spec.amount_column]
24
+
25
+ where(build_amount_predicate(col, spec, value))
26
+ end
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
+
51
+ private
52
+
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
60
+ def build_amount_predicate(col, spec, value)
61
+ case value
62
+ when Range
63
+ low = normalize_amount_value(spec, value.begin)
64
+ high = normalize_amount_value(spec, value.end)
65
+ pred = col.gteq(low)
66
+ value.exclude_end? ? pred.and(col.lt(high)) : pred.and(col.lteq(high))
67
+ when Array
68
+ col.in(value.map { |v| normalize_amount_value(spec, v) })
69
+ else
70
+ col.eq(normalize_amount_value(spec, value))
71
+ end
72
+ end
73
+
74
+ # Normalizes a scalar value for Arel comparison.
75
+ #
76
+ # Composite attributes: the amount column is a plain column with no custom Type,
77
+ # so we must pre-normalize Money to the raw storage value (subunits or decimal).
78
+ # Single-column attributes: the column has a registered Type that handles
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
85
+ def normalize_amount_value(spec, value)
86
+ return value unless spec.composite?
87
+
88
+ spec.normalize_query_value(value)
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
206
+ end
207
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Internal ordering resolution for the +order_by_amount+ query helper.
5
+ #
6
+ # @api private
7
+ module AmountOrder
8
+ # Builds an amount ordering for the registered money attribute.
9
+ #
10
+ # Composite attributes order by currency ASC first, then amount in the
11
+ # requested direction. Single-column attributes order by amount only.
12
+ #
13
+ # @param attr [Symbol] the money attribute name
14
+ # @param direction [Symbol] +:asc+ or +:desc+
15
+ # @return [ActiveRecord::Relation]
16
+ # @raise [ArgumentError] if the attribute is not a registered money attribute
17
+ # @api private
18
+ def resolve_amount_order(attr, direction)
19
+ spec = money_attribute_spec!(attr)
20
+
21
+ if spec.composite?
22
+ order(spec.currency_column => :asc, spec.amount_column => direction)
23
+ else
24
+ order(spec.amount_column => direction)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Internal currency-filter resolution for the +where_currency+ query helper.
5
+ #
6
+ # @api private
7
+ module CurrencyCondition
8
+ # Builds a currency filter for the registered money attribute.
9
+ #
10
+ # Only composite attributes have a currency column, so single-column
11
+ # attributes raise.
12
+ #
13
+ # @param attr [Symbol] the money attribute name
14
+ # @param currency [String, Mint::Currency] the currency code or object
15
+ # @return [ActiveRecord::Relation]
16
+ # @raise [ArgumentError] if the attribute is not a composite money attribute
17
+ # @api private
18
+ def resolve_currency_condition(attr, currency)
19
+ spec = money_attribute_spec!(attr)
20
+
21
+ unless spec.composite?
22
+ raise ArgumentError, "#{klass.name}.#{attr} is a money_amount attribute with no currency column"
23
+ end
24
+
25
+ code = currency.is_a?(Mint::Currency) ? currency.code : currency.to_s
26
+ where(spec.currency_column => code)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Internal helpers shared by the query sub-modules.
5
+ #
6
+ # @api private
7
+ module QueryHelpers
8
+ # Returns the registered money attribute spec or raises when missing.
9
+ #
10
+ # @param attr [Symbol, String] the money attribute name
11
+ # @return [AttributeSpec]
12
+ # @raise [ArgumentError] if the attribute is not registered
13
+ # @api private
14
+ def money_attribute_spec!(attr)
15
+ spec = klass.money_attribute_spec(attr)
16
+ raise ArgumentError, "#{attr} is not a money attribute on #{klass.name}" unless spec
17
+
18
+ spec
19
+ end
20
+
21
+ # Extracts money values for all specs from a single result row.
22
+ #
23
+ # @param row [Array] the flat row from +pluck+ or +pick+
24
+ # @param specs [Array<AttributeSpec>] the money attribute specs
25
+ # @return [Array] the extracted money values
26
+ # @api private
27
+ def extract_money_row(row, specs)
28
+ cursor = 0
29
+
30
+ specs.map do |spec|
31
+ value, cursor = extract_attribute_value(row, spec, cursor)
32
+ value
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # Extracts a single value from a flat row at the given cursor position.
39
+ #
40
+ # @param row [Array] the flat row from +pluck+ or +pick+
41
+ # @param spec [AttributeSpec] the money attribute spec
42
+ # @param cursor [Integer] current position in the row array
43
+ # @return [Array(Object, Integer)] the extracted value and updated cursor
44
+ # @api private
45
+ def extract_attribute_value(row, spec, cursor)
46
+ if spec.single?
47
+ [row[cursor], cursor + 1]
48
+ else
49
+ [spec.build_money(row[cursor], row[cursor + 1]), cursor + 2]
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Internal pick resolution for the +pick_amount+ query helper.
5
+ #
6
+ # @api private
7
+ module PickAmount
8
+ # Picks money-aware amounts for one or more attributes.
9
+ #
10
+ # @param attrs [Array<Symbol>] one or more registered money attribute names
11
+ # @return [Mint::Money, Array, nil] Money for a single attribute, row array for multiple, nil if empty
12
+ # @raise [ArgumentError] if any attribute is not a registered money attribute
13
+ # @api private
14
+ def pick_amount(*attrs)
15
+ raise ArgumentError, 'No attribute specified' if attrs.empty?
16
+
17
+ specs = attrs.map { |attr| money_attribute_spec!(attr) }
18
+ return pick_single_amount(specs.first) if specs.length == 1
19
+
20
+ raw = pick(*specs.flat_map(&:columns))
21
+ return unless raw
22
+
23
+ extract_money_row(raw, specs)
24
+ end
25
+
26
+ private
27
+
28
+ # Picks a single money-aware attribute and returns a single value.
29
+ #
30
+ # @param spec [AttributeSpec] the money attribute spec
31
+ # @return [Mint::Money, Object, nil] the composed Money, the raw value for
32
+ # single-column attributes, or nil when the relation is empty
33
+ # @api private
34
+ def pick_single_amount(spec)
35
+ raw = pick(*spec.columns)
36
+ return unless raw
37
+
38
+ spec.single? ? raw : spec.build_money(raw[0], raw[1])
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Internal pluck resolution for the +pluck_amount+ query helper.
5
+ #
6
+ # @api private
7
+ module PluckAmount
8
+ # Plucks money-aware amounts for one or more attributes.
9
+ #
10
+ # @param attrs [Array<Symbol>] one or more registered money attribute names
11
+ # @return [Array<Mint::Money>] for a single attribute
12
+ # @return [Array<Array>] for multiple attributes, one row array per attribute
13
+ # @raise [ArgumentError] if any attribute is not a registered money attribute
14
+ # @api private
15
+ def pluck_amount(*attrs)
16
+ raise ArgumentError, 'No attribute specified' if attrs.empty?
17
+
18
+ specs = attrs.map { |attr| money_attribute_spec!(attr) }
19
+ return pluck_single_amount(specs.first) if specs.length == 1
20
+
21
+ raw = pluck(*specs.flat_map(&:columns))
22
+ raw.map { |row| extract_money_row(row, specs) }
23
+ end
24
+
25
+ private
26
+
27
+ # Plucks a single money-aware attribute and returns money values.
28
+ #
29
+ # @param spec [AttributeSpec] the money attribute spec
30
+ # @return [Array] raw values for single-column attributes, composed
31
+ # +Mint::Money+ values for composite attributes
32
+ # @api private
33
+ def pluck_single_amount(spec)
34
+ return pluck(spec.amount_column) if spec.single?
35
+
36
+ pluck(spec.amount_column, spec.currency_column).map { |amount, currency| spec.build_money(amount, currency) }
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MoneyAttribute
4
+ # Internal sum resolution for the +sum_amount+ query helper.
5
+ #
6
+ # @api private
7
+ module SumAmount
8
+ # Sums money-aware amounts for a single attribute.
9
+ #
10
+ # @param attr [Symbol] a registered money attribute name
11
+ # @return [Array<Mint::Money>] one Money per currency (or one for single-column attributes)
12
+ # @raise [ArgumentError] if the attribute is not a registered money attribute
13
+ # @api private
14
+ def sum_amount(attr)
15
+ raise ArgumentError, 'No attribute specified' if attr.nil?
16
+
17
+ spec = money_attribute_spec!(attr)
18
+ if spec.composite?
19
+ resolve_composite_sum(spec)
20
+ else
21
+ resolve_single_sum(spec)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # Sums a composite attribute grouped by currency.
28
+ #
29
+ # @param spec [AttributeSpec] the money attribute spec
30
+ # @return [Array<Mint::Money>] one +Mint::Money+ per currency, sorted by
31
+ # currency code, or a single zero-value Money when no rows match
32
+ # @api private
33
+ def resolve_composite_sum(spec)
34
+ totals = group(spec.currency_column).sum(spec.amount_column)
35
+ return [spec.build_money(0, MoneyAttribute.default_currency)] if totals.empty?
36
+
37
+ totals.map { |code, amount| spec.build_money(amount, code) }
38
+ .sort_by(&:currency_code)
39
+ end
40
+
41
+ # Sums a fixed-currency single-column attribute.
42
+ #
43
+ # @param spec [AttributeSpec] the money attribute spec
44
+ # @return [Array<Mint::Money>] a single +Mint::Money+ in the default currency
45
+ # @api private
46
+ def resolve_single_sum(spec)
47
+ total = sum(spec.amount_column)
48
+
49
+ [spec.build_money(total, MoneyAttribute.default_currency)]
50
+ end
51
+ end
52
+ end