Typed keys in `store_attribute` not casted correctly unless `default:` is specified
Summary
When using store_attribute to define typed accessors (e.g. :decimal, :datetime, etc.), the type casting works only if the default: option is passed. Without it, the value remains uncasted (e.g. stays a string after creation or DB load).
This leads to unexpected behavior where typed store attributes silently lose their type unless a default is provided.
🔁 Reproduction Steps
Given a model:
# billing_method.rb
class BillingMethod < ApplicationRecord
store_attribute :settings, :amount, :decimal, precision: 15, scale: 2
end
Try this in Rails console:
bm = BillingMethod.create(settings: { amount: "12345.67" })
bm.reload
bm.amount
# => "12345.67" (String) ❌ expected BigDecimal
Now, change the definition:
store_attribute :settings, :amount, :decimal, precision: 15, scale: 2, default: 0
Then:
bm = BillingMethod.create(settings: { amount: "12345.67" })
bm.reload
bm.amount
# => #<BigDecimal:...,'0.1234567E5'> ✅ correct!
💡 Analysis Looking at the store_attribute implementation, _define_store_attribute is only called if:
_define_store_attribute(store_name) if
!_local_typed_stored_attributes? ||
_local_typed_stored_attributes[store_name][:types].empty? ||
(options.key?(:default) && _local_typed_stored_attributes[store_name][:owner] != self)
This means that if the store is already initialized and no default: is present, _define_store_attribute is skipped. As a result, the TypedStore is not properly set up, and no type casting occurs.
I just tried to delete this part with options.key?(:default):
_define_store_attribute(store_name) if
!_local_typed_stored_attributes? ||
_local_typed_stored_attributes[store_name][:types].empty? ||
_local_typed_stored_attributes[store_name][:owner] != self
Removing the condition on options.key?(:default) so that _define_store_attribute is always run when needed.
Right now I use workaround. Explicitly provide a default: when declaring the typed store attribute:
store_attribute :settings, :amount, :decimal, precision: 15, scale: 2, default: 0
BTW here is the PR where the changes were made.