money_attribute 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +166 -72
- data/Rakefile +31 -6
- data/lib/generators/money_attribute/initializer_generator.rb +10 -0
- data/lib/money_attribute/attribute_spec.rb +9 -3
- data/lib/money_attribute/attribute_spec_registry.rb +63 -5
- data/lib/money_attribute/column_type_validations.rb +47 -0
- data/lib/money_attribute/converter.rb +18 -3
- data/lib/money_attribute/core_ext/numeric.rb +10 -1
- data/lib/money_attribute/core_ext/string.rb +12 -2
- data/lib/money_attribute/form_builder_extension.rb +48 -3
- data/lib/money_attribute/macro.rb +96 -7
- data/lib/money_attribute/migration_extensions/helper.rb +61 -1
- 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 +30 -2
- data/lib/money_attribute/query/amount_condition.rb +139 -2
- data/lib/money_attribute/query/amount_order.rb +9 -3
- data/lib/money_attribute/query/currency_condition.rb +8 -2
- data/lib/money_attribute/query/helpers.rb +28 -5
- data/lib/money_attribute/query/pick.rb +11 -8
- data/lib/money_attribute/query/pluck.rb +11 -15
- data/lib/money_attribute/query/sum.rb +15 -3
- data/lib/money_attribute/query.rb +16 -11
- data/lib/money_attribute/railtie.rb +40 -0
- data/lib/money_attribute/type.rb +6 -2
- data/lib/money_attribute/version.rb +2 -1
- data/lib/money_attribute.rb +1 -0
- metadata +5 -4
|
@@ -4,30 +4,41 @@ require 'concurrent/map'
|
|
|
4
4
|
|
|
5
5
|
module MoneyAttribute
|
|
6
6
|
# Stores money attribute metadata on the model class.
|
|
7
|
+
#
|
|
8
|
+
# Holds a per-model registry of {AttributeSpec} objects plus derived caches
|
|
9
|
+
# (name set, regex patterns) populated lazily on first access.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
7
12
|
module AttributeSpecRegistry
|
|
8
13
|
extend ActiveSupport::Concern
|
|
9
14
|
|
|
10
15
|
REGISTRY = Concurrent::Map.new
|
|
16
|
+
PATTERNS = Concurrent::Map.new
|
|
17
|
+
QUERY_PLANS = Concurrent::Map.new
|
|
11
18
|
|
|
12
19
|
class_methods do
|
|
13
20
|
# Registers a money attribute spec for the current model class.
|
|
14
21
|
#
|
|
15
22
|
# @param name [Symbol, String] the attribute name
|
|
16
23
|
# @param kind [Symbol] +:composite+ or +:single+
|
|
17
|
-
# @param
|
|
18
|
-
# @param
|
|
24
|
+
# @param amount_column [Symbol, String] the amount column name
|
|
25
|
+
# @param currency_column [Symbol, String, nil] the currency column name (composite only)
|
|
19
26
|
# @param amount_type [Symbol, nil] +:integer+ or +:decimal+
|
|
20
27
|
# @return [AttributeSpec]
|
|
21
|
-
|
|
28
|
+
# @api private
|
|
29
|
+
def register_money_attribute_spec(name, kind:, amount_column:, currency_column: nil, amount_type: nil)
|
|
22
30
|
spec = MoneyAttribute::AttributeSpec.new(
|
|
23
31
|
name: name.to_s,
|
|
24
32
|
kind: kind,
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
amount_column: amount_column.to_s,
|
|
34
|
+
currency_column: currency_column&.to_s,
|
|
27
35
|
amount_type: amount_type
|
|
28
36
|
)
|
|
29
37
|
|
|
30
38
|
money_attribute_specs[spec.name] = spec
|
|
39
|
+
PATTERNS.delete(:"#{self}_name_set")
|
|
40
|
+
PATTERNS.delete(:"#{self}_name_pattern")
|
|
41
|
+
QUERY_PLANS.delete(self)
|
|
31
42
|
spec
|
|
32
43
|
end
|
|
33
44
|
|
|
@@ -35,6 +46,7 @@ module MoneyAttribute
|
|
|
35
46
|
#
|
|
36
47
|
# @param name [Symbol, String] the attribute name
|
|
37
48
|
# @return [AttributeSpec, nil]
|
|
49
|
+
# @api private
|
|
38
50
|
def money_attribute_spec(name)
|
|
39
51
|
REGISTRY[self]&.fetch(name.to_s, nil)
|
|
40
52
|
end
|
|
@@ -42,9 +54,55 @@ module MoneyAttribute
|
|
|
42
54
|
# Returns the registry hash for the current model class.
|
|
43
55
|
#
|
|
44
56
|
# @return [Hash{String => AttributeSpec}]
|
|
57
|
+
# @api private
|
|
45
58
|
def money_attribute_specs
|
|
46
59
|
REGISTRY.fetch_or_store(self) { {} }
|
|
47
60
|
end
|
|
61
|
+
|
|
62
|
+
# Returns a frozen Set of registered money attribute names.
|
|
63
|
+
#
|
|
64
|
+
# @return [Set<String>]
|
|
65
|
+
# @api private
|
|
66
|
+
def money_attribute_names_set
|
|
67
|
+
PATTERNS.fetch_or_store(:"#{self}_name_set") { money_attribute_specs.keys.to_set.freeze }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns a pre-compiled regex matching any registered money attribute name.
|
|
71
|
+
#
|
|
72
|
+
# @return [Regexp]
|
|
73
|
+
# @api private
|
|
74
|
+
def money_attribute_name_pattern
|
|
75
|
+
PATTERNS.fetch_or_store(:"#{self}_name_pattern") do
|
|
76
|
+
names = money_attribute_specs.keys.map { |n| Regexp.escape(n) }
|
|
77
|
+
/\b(#{names.join('|')})\b/i
|
|
78
|
+
end
|
|
79
|
+
end
|
|
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
|
|
48
106
|
end
|
|
49
107
|
end
|
|
50
108
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MoneyAttribute
|
|
4
|
+
# Shared column-type validation for the +money_attribute+ and +money_amount+
|
|
5
|
+
# macros.
|
|
6
|
+
#
|
|
7
|
+
# Included by {Macro::CompositeClassMethods} and {MoneyAmount}. Raises when a
|
|
8
|
+
# backing column uses a type that cannot store a money amount or currency code.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module ColumnTypeValidations
|
|
12
|
+
VALID_AMOUNT_TYPES = %i[integer bigint decimal].freeze
|
|
13
|
+
VALID_CURRENCY_TYPES = %i[string text].freeze
|
|
14
|
+
|
|
15
|
+
# Validates that a column can store a money amount.
|
|
16
|
+
#
|
|
17
|
+
# @param attr_name [Symbol, String] the money attribute accessor name
|
|
18
|
+
# @param column_name [Symbol, String] the amount column name
|
|
19
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] the column metadata
|
|
20
|
+
# @return [void]
|
|
21
|
+
# @raise [ArgumentError] if the column type is not numeric
|
|
22
|
+
# @api private
|
|
23
|
+
def assert_valid_amount_column!(attr_name, column_name, column)
|
|
24
|
+
return if VALID_AMOUNT_TYPES.include?(column.type)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"`:#{attr_name}` amount column `#{column_name}` must be a numeric type " \
|
|
28
|
+
"(integer, bigint, or decimal), got `#{column.type}`"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Validates that a column can store a currency code.
|
|
32
|
+
#
|
|
33
|
+
# @param attr_name [Symbol, String] the money attribute accessor name
|
|
34
|
+
# @param column_name [Symbol, String] the currency column name
|
|
35
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] the column metadata
|
|
36
|
+
# @return [void]
|
|
37
|
+
# @raise [ArgumentError] if the column type is not string-like
|
|
38
|
+
# @api private
|
|
39
|
+
def assert_valid_currency_column!(attr_name, column_name, column)
|
|
40
|
+
return if VALID_CURRENCY_TYPES.include?(column.type)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"`:#{attr_name}` currency column `#{column_name}` must be a string type " \
|
|
44
|
+
"(string or text), got `#{column.type}`"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -1,27 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
-
#
|
|
4
|
+
# Converts raw attribute input into +Mint::Money+ values.
|
|
5
|
+
#
|
|
6
|
+
# Used in two roles: as the +:converter+ option of +composed_of+ (composite
|
|
7
|
+
# attributes) and as the normalizer block for +money_amount+ (single-column
|
|
8
|
+
# attributes). Accepts +Mint::Money+, numeric, string, and +nil+ input.
|
|
5
9
|
class Converter
|
|
6
10
|
DEFAULT = new.freeze
|
|
7
11
|
|
|
8
12
|
# Initializes a converter with an optional fixed currency.
|
|
13
|
+
#
|
|
14
|
+
# @param currency [String, Symbol, Mint::Currency, nil] the currency to use
|
|
15
|
+
# for parsed values; falls back to {MoneyAttribute.default_currency} when nil
|
|
16
|
+
# @return [Converter]
|
|
9
17
|
def initialize(currency = nil)
|
|
10
18
|
@static_currency = currency
|
|
11
19
|
end
|
|
12
20
|
|
|
13
21
|
# Returns the shared default converter instance.
|
|
22
|
+
#
|
|
23
|
+
# @return [Converter] the frozen, process-wide converter
|
|
14
24
|
def self.default
|
|
15
25
|
DEFAULT
|
|
16
26
|
end
|
|
17
27
|
|
|
18
|
-
# Converts raw input into a
|
|
28
|
+
# Converts raw input into a +Mint::Money+ value.
|
|
29
|
+
#
|
|
30
|
+
# @param amount [Mint::Money, Numeric, String, nil] the input value
|
|
31
|
+
# @return [Mint::Money, nil] +Mint::Money+ for numeric and string input,
|
|
32
|
+
# the input itself for +Mint::Money+ and +nil+
|
|
33
|
+
# @raise [ArgumentError] for unsupported input types
|
|
19
34
|
def parse(amount)
|
|
20
35
|
currency = @static_currency || MoneyAttribute.default_currency
|
|
21
36
|
case amount
|
|
22
37
|
when Money, NilClass then amount
|
|
23
38
|
when Numeric then Money.from(amount, currency)
|
|
24
|
-
when String then Money.parse(amount, currency)
|
|
39
|
+
when String then Money.parse(amount, default_currency: currency)
|
|
25
40
|
else raise ArgumentError, "Cannot convert #{amount.inspect} (#{amount.class}) to Money"
|
|
26
41
|
end
|
|
27
42
|
end
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Convenience method for converting numeric values to +Mint::Money+.
|
|
4
|
+
#
|
|
5
|
+
# @api private
|
|
4
6
|
class Numeric
|
|
5
7
|
remove_method :to_money if method_defined?(:to_money)
|
|
6
8
|
|
|
9
|
+
# Converts the numeric value to a +Mint::Money+ value.
|
|
10
|
+
#
|
|
11
|
+
# @param currency [String, Symbol, Mint::Currency, nil] the currency to use;
|
|
12
|
+
# falls back to {MoneyAttribute.default_currency}
|
|
13
|
+
# @return [Mint::Money]
|
|
14
|
+
# @example
|
|
15
|
+
# 42.5.to_money('USD') # => Mint::Money(42.5, 'USD')
|
|
7
16
|
def to_money(currency = MoneyAttribute.default_currency) = Money.from(self, currency)
|
|
8
17
|
end
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Convenience method for parsing +Mint::Money+ from strings.
|
|
4
|
+
#
|
|
5
|
+
# @api private
|
|
4
6
|
class String
|
|
5
7
|
remove_method :to_money if method_defined?(:to_money)
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
# Parses the string into a +Mint::Money+ value.
|
|
10
|
+
#
|
|
11
|
+
# @param currency [String, Symbol, Mint::Currency, nil] the currency to use;
|
|
12
|
+
# falls back to {MoneyAttribute.default_currency}
|
|
13
|
+
# @return [Mint::Money]
|
|
14
|
+
# @raise [ArgumentError] if the string cannot be parsed
|
|
15
|
+
# @example
|
|
16
|
+
# '12.34'.to_money('USD') # => Mint::Money(12.34, 'USD')
|
|
17
|
+
def to_money(currency = MoneyAttribute.default_currency) = Money.parse(self, default_currency: currency)
|
|
8
18
|
end
|
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
-
#
|
|
4
|
+
# Form builder methods for money attributes.
|
|
5
|
+
#
|
|
6
|
+
# Included into +ActionView::Helpers::FormBuilder+ by the railtie,
|
|
7
|
+
# providing two helper methods that mirror Rails' +text_field+ and
|
|
8
|
+
# +number_field+ but work with +Mint::Money+ attribute values.
|
|
9
|
+
#
|
|
10
|
+
# Both helpers render unbound <tt><input></tt> tags (not scoped to
|
|
11
|
+
# the form builder's object name), so the submitted value is accessible
|
|
12
|
+
# via +params+ directly rather than through +params[object_name]+.
|
|
13
|
+
#
|
|
14
|
+
# @example In a view
|
|
15
|
+
# <%= form_with model: @product do |f| %>
|
|
16
|
+
# <%= f.money_field :price %>
|
|
17
|
+
# <%= f.money_amount_field :discount %>
|
|
18
|
+
# <% end %>
|
|
5
19
|
module FormBuilderExtension
|
|
6
|
-
# Renders a text input for a composed money attribute.
|
|
20
|
+
# Renders a text input for a composed (amount + currency) money attribute.
|
|
21
|
+
#
|
|
22
|
+
# Displays the formatted money string (e.g. +"R$ 1.234,56"+) via
|
|
23
|
+
# {Mint::Money#to_fs}. The raw value is submitted as a string; the
|
|
24
|
+
# application should parse it on the receiving end, typically using
|
|
25
|
+
# {MoneyAttribute::Converter#parse}.
|
|
26
|
+
#
|
|
27
|
+
# @param method [Symbol] the money attribute accessor name
|
|
28
|
+
# @param options [Hash] HTML attributes passed through to the input tag
|
|
29
|
+
# @return [String] an HTML <tt><input type="text"></tt> tag
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# f.money_field :price
|
|
33
|
+
# # => <input type="text" id="product_price" name="product_price" value="R$ 1.234,56">
|
|
34
|
+
#
|
|
35
|
+
# @example With CSS class
|
|
36
|
+
# f.money_field :price, class: "form-control"
|
|
7
37
|
def money_field(method, options = {})
|
|
8
38
|
money = object.public_send(method)
|
|
9
39
|
value = money&.to_fs
|
|
@@ -12,7 +42,22 @@ module MoneyAttribute
|
|
|
12
42
|
{ id: field_id(method) }.merge(options))
|
|
13
43
|
end
|
|
14
44
|
|
|
15
|
-
# Renders a number input for a single-column money attribute.
|
|
45
|
+
# Renders a number input for a single-column (fixed-currency) money attribute.
|
|
46
|
+
#
|
|
47
|
+
# Displays the raw decimal value (e.g. +"1234.56"+) via
|
|
48
|
+
# {Mint::Money#to_d}. This is suitable for attributes backed by a single
|
|
49
|
+
# column where the currency is fixed per application config.
|
|
50
|
+
#
|
|
51
|
+
# @param method [Symbol] the money attribute accessor name
|
|
52
|
+
# @param options [Hash] HTML attributes passed through to the input tag
|
|
53
|
+
# @return [String] an HTML <tt><input type="number"></tt> tag
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# f.money_amount_field :discount
|
|
57
|
+
# # => <input type="number" id="product_discount" name="product_discount" value="1234.56">
|
|
58
|
+
#
|
|
59
|
+
# @example With step and min
|
|
60
|
+
# f.money_amount_field :discount, step: 0.01, min: 0
|
|
16
61
|
def money_amount_field(method, options = {})
|
|
17
62
|
money_from_column = object.public_send(method)
|
|
18
63
|
value = money_from_column&.to_d
|
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
|
-
#
|
|
4
|
+
# Declares composite money attributes on Active Record models.
|
|
5
|
+
#
|
|
6
|
+
# Provides the +money_attribute+ class method which wires a two-column
|
|
7
|
+
# (amount + currency) backing store to a +Mint::Money+ value object via
|
|
8
|
+
# +composed_of+.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class Product < ApplicationRecord
|
|
12
|
+
# money_attribute :price
|
|
13
|
+
# end
|
|
5
14
|
module Macro
|
|
6
15
|
extend ActiveSupport::Concern
|
|
7
16
|
|
|
8
|
-
#
|
|
17
|
+
# Class methods backing the +money_attribute+ macro.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
9
20
|
module CompositeClassMethods
|
|
21
|
+
include ColumnTypeValidations
|
|
22
|
+
|
|
10
23
|
# Normalizes the requested mapping by applying conventions and overrides.
|
|
24
|
+
#
|
|
25
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
26
|
+
# @param mapping_override [Hash] the user-supplied +mapping:+ option
|
|
27
|
+
# @return [Hash{Symbol => String}] resolved +:amount+ and +:currency+ column names
|
|
28
|
+
# @raise [ArgumentError] if the resolved columns do not exist on the model
|
|
29
|
+
# @api private
|
|
11
30
|
def resolve_mapping(name, mapping_override)
|
|
12
31
|
override = mapping_override.compact
|
|
13
32
|
override.slice!(:amount, :currency)
|
|
@@ -20,6 +39,13 @@ module MoneyAttribute
|
|
|
20
39
|
end
|
|
21
40
|
|
|
22
41
|
# Returns the default amount/currency mapping for the attribute name.
|
|
42
|
+
#
|
|
43
|
+
# Resolution order: +name_currency+ + +name+ columns, +amount+ + +currency+
|
|
44
|
+
# for +:amount+, then the +name_amount+ + +name_currency+ convention.
|
|
45
|
+
#
|
|
46
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
47
|
+
# @return [Hash{Symbol => String}] +:amount+ and +:currency+ column names
|
|
48
|
+
# @api private
|
|
23
49
|
def default_mapping(name)
|
|
24
50
|
name = name.to_s
|
|
25
51
|
names = column_names
|
|
@@ -34,18 +60,35 @@ module MoneyAttribute
|
|
|
34
60
|
end
|
|
35
61
|
|
|
36
62
|
# Registers the composite money attribute spec for the model.
|
|
63
|
+
#
|
|
64
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
65
|
+
# @param mapping [Hash{Symbol => String}] +:amount+ and +:currency+ column names
|
|
66
|
+
# @return [AttributeSpec] the registered spec
|
|
67
|
+
# @raise [ArgumentError] if either column has an unsupported type
|
|
68
|
+
# @api private
|
|
37
69
|
def register_composite_spec(name, mapping)
|
|
38
|
-
|
|
70
|
+
amount_column = column_for_attribute(mapping[:amount])
|
|
71
|
+
currency_column = column_for_attribute(mapping[:currency])
|
|
72
|
+
|
|
73
|
+
assert_valid_amount_column!(name, mapping[:amount], amount_column)
|
|
74
|
+
assert_valid_currency_column!(name, mapping[:currency], currency_column)
|
|
75
|
+
|
|
39
76
|
register_money_attribute_spec(
|
|
40
77
|
name,
|
|
41
78
|
kind: :composite,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
amount_type: %i[integer bigint].include?(
|
|
79
|
+
amount_column: mapping[:amount],
|
|
80
|
+
currency_column: mapping[:currency],
|
|
81
|
+
amount_type: %i[integer bigint].include?(amount_column.type) ? :integer : :decimal
|
|
45
82
|
)
|
|
46
83
|
end
|
|
47
84
|
|
|
48
85
|
# Raises when the resolved columns are not present on the model.
|
|
86
|
+
#
|
|
87
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
88
|
+
# @param mapping [Hash{Symbol => String}] +:amount+ and +:currency+ column names
|
|
89
|
+
# @return [void]
|
|
90
|
+
# @raise [ArgumentError] listing expected vs found columns
|
|
91
|
+
# @api private
|
|
49
92
|
def assert_columns_exist!(name, mapping)
|
|
50
93
|
missing = mapping.values - column_names
|
|
51
94
|
return if missing.empty?
|
|
@@ -55,11 +98,57 @@ module MoneyAttribute
|
|
|
55
98
|
"Expected: #{mapping.values.join(', ')}, " \
|
|
56
99
|
"Found: #{attribute_names.join(', ')}"
|
|
57
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
|
|
58
130
|
end
|
|
59
131
|
|
|
60
132
|
class_methods do
|
|
61
133
|
# Declares a composite money attribute on the model.
|
|
62
|
-
|
|
134
|
+
#
|
|
135
|
+
# Stores the attribute across two columns (amount + currency). The amount
|
|
136
|
+
# column type determines the storage unit: integer/bigint stores subunits,
|
|
137
|
+
# decimal stores the unit value. Currency is resolved per row.
|
|
138
|
+
#
|
|
139
|
+
# @param name [Symbol, String] the money attribute accessor name
|
|
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)
|
|
143
|
+
# @return [void]
|
|
144
|
+
#
|
|
145
|
+
# @example
|
|
146
|
+
# class Product < ApplicationRecord
|
|
147
|
+
# money_attribute :price
|
|
148
|
+
# money_attribute :price, mapping: { amount: :base_price, currency: :base_currency }
|
|
149
|
+
# end
|
|
150
|
+
def money_attribute(name, mapping: {}, amount: nil, currency: nil)
|
|
151
|
+
mapping = normalize_mapping(mapping, amount, currency)
|
|
63
152
|
mapping = resolve_mapping(name, mapping)
|
|
64
153
|
spec = register_composite_spec(name, mapping)
|
|
65
154
|
|
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module MoneyAttribute
|
|
4
4
|
module MigrationExtensions
|
|
5
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
16
|
-
add_column(table_name,
|
|
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
|
-
|
|
72
|
+
amount_column, currency_column, = parse_money_args(accessor, options)
|
|
21
73
|
|
|
22
|
-
remove_column(table_name,
|
|
23
|
-
remove_column(table_name,
|
|
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
|
-
|
|
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,
|
|
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
|