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.
- checksums.yaml +4 -4
- data/README.md +29 -26
- data/Rakefile +19 -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 +33 -5
- data/lib/money_attribute/column_type_validations.rb +47 -0
- data/lib/money_attribute/converter.rb +17 -2
- data/lib/money_attribute/core_ext/numeric.rb +10 -1
- data/lib/money_attribute/core_ext/string.rb +11 -1
- data/lib/money_attribute/form_builder_extension.rb +48 -3
- data/lib/money_attribute/macro.rb +63 -6
- 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 +161 -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 +1 -1
- data/lib/money_attribute/version.rb +2 -1
- data/lib/money_attribute.rb +1 -0
- metadata +5 -4
|
@@ -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
|
|
@@ -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,12 +1,38 @@
|
|
|
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
|
+
|
|
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,
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
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.
|
|
22
|
+
order(spec.currency_column => :asc, spec.amount_column => direction)
|
|
17
23
|
else
|
|
18
|
-
order(spec.
|
|
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
|
-
#
|
|
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.
|
|
26
|
+
where(spec.currency_column => code)
|
|
21
27
|
end
|
|
22
28
|
end
|
|
23
29
|
end
|