money_attribute 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +143 -41
- data/Rakefile +43 -2
- data/lib/generators/money_attribute/initializer_generator.rb +10 -0
- data/lib/generators/templates/money_attribute.rb +20 -12
- data/lib/money_attribute/attribute_spec.rb +84 -0
- data/lib/money_attribute/attribute_spec_registry.rb +78 -0
- data/lib/money_attribute/column_type_validations.rb +47 -0
- data/lib/money_attribute/configuration.rb +43 -15
- data/lib/money_attribute/converter.rb +30 -5
- data/lib/money_attribute/core_ext/numeric.rb +17 -0
- data/lib/money_attribute/core_ext/string.rb +11 -1
- data/lib/money_attribute/current.rb +13 -0
- data/lib/money_attribute/form_builder_extension.rb +48 -1
- data/lib/money_attribute/macro.rb +106 -72
- data/lib/money_attribute/migration_extensions/helper.rb +62 -3
- data/lib/money_attribute/migration_extensions/schema_statements.rb +89 -9
- data/lib/money_attribute/migration_extensions/table_definition.rb +68 -11
- data/lib/money_attribute/money_amount.rb +49 -25
- data/lib/money_attribute/query/amount_condition.rb +207 -0
- data/lib/money_attribute/query/amount_order.rb +28 -0
- data/lib/money_attribute/query/currency_condition.rb +29 -0
- data/lib/money_attribute/query/helpers.rb +53 -0
- data/lib/money_attribute/query/pick.rb +41 -0
- data/lib/money_attribute/query/pluck.rb +39 -0
- data/lib/money_attribute/query/sum.rb +52 -0
- data/lib/money_attribute/query.rb +125 -0
- data/lib/money_attribute/railtie.rb +47 -1
- data/lib/money_attribute/type.rb +41 -33
- data/lib/money_attribute/version.rb +2 -1
- data/lib/money_attribute.rb +16 -1
- metadata +17 -5
- data/lib/money_attribute/core_ext.rb +0 -8
|
@@ -4,34 +4,91 @@ require_relative 'helper'
|
|
|
4
4
|
|
|
5
5
|
module MoneyAttribute
|
|
6
6
|
module MigrationExtensions
|
|
7
|
-
#
|
|
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
|
-
|
|
42
|
+
amount_column, currency_column, amount_opts, currency_opts = parse_money_args(accessor, options)
|
|
13
43
|
|
|
14
|
-
column(
|
|
15
|
-
column(
|
|
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
|
-
|
|
56
|
+
amount_column, currency_column, = parse_money_args(accessor, options)
|
|
20
57
|
|
|
21
|
-
remove_column(
|
|
22
|
-
remove_column(
|
|
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
|
-
|
|
77
|
+
amount_column, amount_opts = parse_money_amount_args(accessor, options)
|
|
27
78
|
|
|
28
|
-
column(
|
|
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
|
-
|
|
89
|
+
amount_column, = parse_money_amount_args(accessor, options)
|
|
33
90
|
|
|
34
|
-
remove_column(
|
|
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
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|