Class: TypedEAV::Field::Currency
- Inherits:
-
Base
- Object
- ActiveRecord::Base
- ApplicationRecord
- Base
- TypedEAV::Field::Currency
- Defined in:
- app/models/typed_eav/field/currency.rb
Overview
Two-cell field type: stores ‘BigDecimal, currency: String` across decimal_value (amount) + string_value (currency ISO 4217 code).
Multi-cell contract:
-
‘value_columns :decimal_value, :string_value` — both cells propagate through versioning’s snapshot loop and the Value ‘_dispatch_value_change_update` filter so a change to either cell correctly fires the :update event.
-
‘operator_column` routes `:currency_eq` → `:string_value` and every other supported op → `:decimal_value`. QueryBuilder reads this.
-
‘read_value` / `write_value` / `apply_default` are the three overrides paired with the multi-cell declaration. Without all three, single-cell defaults would write a Hash to decimal_value and raise TypeMismatch at save time.
-
‘cast` requires a Hash input. Bare Numeric/String is invalid —explicit currency dimension is required at write time. Silently defaulting to default_currency would invite bugs where users forget the currency dimension entirely.
Operators (explicit narrowing — does NOT inherit string-search ops like :contains/:starts_with from decimal_value’s default since those don’t apply to amount-numeric or currency-code searches):
-
:eq, :gt, :lt, :gteq, :lteq, :between target the amount.
-
:currency_eq targets the currency code (registered ONLY on this class — QueryBuilder’s operator-validation gate rejects it on any non-Currency field).
-
:is_null / :is_not_null target the amount column (a Currency value is considered null when its amount is null).
Options:
-
default_currency: String ISO 4217 code (e.g., “USD”). Used as the currency fallback when cast input has amount but no currency. Never applies as a global silent default — only when cast input already has an amount and no explicit currency.
-
allowed_currencies: Array<String> of ISO codes. When set, validate_typed_value enforces inclusion.
Constant Summary collapse
- AMOUNT_COLUMN =
:decimal_value- CURRENCY_COLUMN =
:string_value
Constants inherited from Base
Constants included from TypedStorage
TypedStorage::DEFAULT_OPERATORS_BY_COLUMN, TypedStorage::FALLBACK_OPERATORS
Class Method Summary collapse
-
.operator_column(operator) ⇒ Object
Route ‘:currency_eq` to the currency-code cell; every other supported operator targets the amount cell.
Instance Method Summary collapse
-
#apply_default(value_record) ⇒ Object
Populate both cells from the field’s configured default.
-
#cast(raw) ⇒ Object
Cast Hash input → [BigDecimal, currency: String, false] or [nil, false] for nil/blank, or [nil, true] for unparseable input.
-
#read_value(value_record) ⇒ Object
Compose the logical Hash from the two cells.
-
#validate_typed_value(record, val) ⇒ Object
Co-population validation + allowed_currencies inclusion.
-
#write_value(value_record, casted) ⇒ Object
Unpack the casted Hash across the two cells.
Methods inherited from Base
#allowed_option_values, #array_field?, #backfill_default!, #clear_option_cache!, #default_value, #default_value=, export_schema, #field_type_name, import_schema, #insert_at, #move_higher, #move_lower, #move_to_bottom, #move_to_top, #optionable?
Methods included from TypedStorage
#after_snapshot, #before_snapshot, #value_changed?
Class Method Details
.operator_column(operator) ⇒ Object
Route ‘:currency_eq` to the currency-code cell; every other supported operator targets the amount cell. The operator-validation gate in QueryBuilder.filter has already narrowed `operator` to the set declared above by the time this runs.
57 58 59 |
# File 'app/models/typed_eav/field/currency.rb', line 57 def self.operator_column(operator) operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN end |
Instance Method Details
#apply_default(value_record) ⇒ Object
Populate both cells from the field’s configured default. Mirrors ‘write_value`’s Hash decomposition; tolerates string-keyed defaults for jsonb round-trip (‘default_value_meta` stores raw config).
86 87 88 89 90 91 92 |
# File 'app/models/typed_eav/field/currency.rb', line 86 def apply_default(value_record) default = default_value return unless default.is_a?(Hash) value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"] value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"] end |
#cast(raw) ⇒ Object
Cast Hash input → [BigDecimal, currency: String, false] or [nil, false] for nil/blank, or [nil, true] for unparseable input. Bare Numeric or String input is [nil, true] — users MUST pass a hash to make the currency dimension explicit (locked plan-time decision; preventing silent default_currency reliance in the ergonomic-but-error-prone scalar-cast case). rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity – one cast method with linear branches for nil/non-hash/amount-parse/currency-coercion/currency-shape; splitting hides the cast contract from a single read.
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'app/models/typed_eav/field/currency.rb', line 101 def cast(raw) return [nil, false] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?) return [nil, true] unless raw.is_a?(Hash) amount_raw = raw[:amount] || raw["amount"] currency_raw = raw[:currency] || raw["currency"] amount_bd = nil if amount_raw.present? amount_bd = BigDecimal(amount_raw.to_s, exception: false) return [nil, true] if amount_bd.nil? end currency_str = currency_raw.is_a?(String) ? currency_raw.upcase : nil # default_currency fallback applies ONLY when the hash has an # amount but no currency. When the hash has neither, the result is # {amount: nil, currency: nil} — falsy enough that read_value # returns nil. When the hash has only a currency, the fallback # does NOT trigger (amount stays nil); validation will catch the # co-population requirement at save time. currency_str ||= default_currency if amount_bd && default_currency.present? # Reject non-3-letter currency codes (validation also catches this # on save; cast catches it earlier so :invalid is set on the cast # result and Value#validate_value surfaces :invalid promptly). return [nil, true] if currency_str && currency_str !~ /\A[A-Z]{3}\z/ [{ amount: amount_bd, currency: currency_str }, false] end |
#read_value(value_record) ⇒ Object
Compose the logical Hash from the two cells. Returns ‘nil` only when BOTH cells are nil — a half-populated row still round-trips as the partial Hash so validation can surface the missing dimension.
64 65 66 67 68 69 70 |
# File 'app/models/typed_eav/field/currency.rb', line 64 def read_value(value_record) amount = value_record[AMOUNT_COLUMN] currency = value_record[CURRENCY_COLUMN] return nil if amount.nil? && currency.nil? { amount: amount, currency: currency } end |
#validate_typed_value(record, val) ⇒ Object
Co-population validation + allowed_currencies inclusion. When val is a Hash, requires both :amount and :currency populated. When allowed_currencies is set, val must be in the list. Without the co-population check, a half-populated row (amount-only or currency-only) would silently round-trip.
136 137 138 139 140 141 142 143 144 145 146 |
# File 'app/models/typed_eav/field/currency.rb', line 136 def validate_typed_value(record, val) return if val.nil? unless val.is_a?(Hash) && val[:amount].present? && val[:currency].present? record.errors.add(:value, "must have both amount and currency") return end allowed = [:allowed_currencies] record.errors.add(:value, :inclusion) if allowed.present? && Array(allowed).exclude?(val[:currency]) end |
#write_value(value_record, casted) ⇒ Object
Unpack the casted Hash across the two cells. ‘nil` clears both.
73 74 75 76 77 78 79 80 81 |
# File 'app/models/typed_eav/field/currency.rb', line 73 def write_value(value_record, casted) if casted.nil? value_record[AMOUNT_COLUMN] = nil value_record[CURRENCY_COLUMN] = nil else value_record[AMOUNT_COLUMN] = casted[:amount] value_record[CURRENCY_COLUMN] = casted[:currency] end end |