Module: ActiveRecordExtended::JsonbAttributes
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/active_record_extended/jsonb_attributes.rb
Class Method Summary collapse
-
.active_record_default_timezone ⇒ Symbol
Resolve AR's configured default timezone.
-
.deserialize_value(value, attribute_type) ⇒ Object
Coerce a JSONB-deserialized value to the model's attribute type.
-
.jsonb_attributes(jsonb_attribute, **definitions) ⇒ void
Declare typed accessors backed by a JSONB column.
-
.parse_date(value) ⇒ ActiveSupport::TimeWithZone, Object
Zone-aware datetime parser used when hydrating
:datetimeJSONB values. -
.translate_keys_to_store_keys(hash, store_key_mapping) ⇒ Hash
Translate named-key → store-key for JSONB column writes.
-
.translate_store_keys_to_keys(hash, store_key_mapping) ⇒ Hash
Translate store-key → named-key for read-side child hydration.
Class Method Details
.active_record_default_timezone ⇒ Symbol
Resolve AR's configured default timezone. Compatibility shim:
ActiveRecord.default_timezone exists on AR 7+ but older rails
exposed it on ActiveRecord::Base.
221 222 223 |
# File 'lib/active_record_extended/jsonb_attributes.rb', line 221 def active_record_default_timezone ActiveRecord.try(:default_timezone) || ActiveRecord::Base.default_timezone end |
.deserialize_value(value, attribute_type) ⇒ Object
Coerce a JSONB-deserialized value to the model's attribute type.
Logic Details
JSONB has no datetime type, so datetimes round-trip as ISO-8601
strings. Coerce them back through Time.zone.parse (or
Time.find_zone('UTC') when AR's default timezone is :utc) so
the model surface returns ActiveSupport::TimeWithZone, matching
what Rails' attribute API returns for non-JSONB datetime columns.
261 262 263 264 265 266 267 268 |
# File 'lib/active_record_extended/jsonb_attributes.rb', line 261 def deserialize_value(value, attribute_type) return value if value.blank? if attribute_type == :datetime value = value.is_a?(Array) ? value.map { |v| parse_date(v) } : parse_date(value) end value end |
.jsonb_attributes(jsonb_attribute, **definitions) ⇒ void
This method returns an undefined value.
Declare typed accessors backed by a JSONB column.
Logic Details
For each declared key: calls Rails' attribute name, type, options
to register a typed virtual attribute (free coercion + dirty
tracking). Generates store-key/named-key mappings inherited through
STI. Defines a setter module that mirrors writes from each child
back into the parent JSONB column (and inversely, splits a wholesale
JSONB hash assignment back into the children, store-key translated).
Hooks after_initialize to hydrate children from the JSONB column on
load, with dirty-tracking cleared on persisted records.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 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 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/active_record_extended/jsonb_attributes.rb', line 71 def jsonb_attributes(jsonb_attribute, **definitions) jsonb_attribute = jsonb_attribute.to_sym names_and_store_keys = {} names_and_defaults = {} definitions.each do |name, type_def| args = Array(type_def) opts = args.last.is_a?(Hash) ? args.pop : {} names_and_store_keys[name.to_s] = (opts.delete(:store_key) || name).to_s names_and_defaults[name.to_s] = opts[:default] unless opts[:default].nil? # Define the typed virtual attribute. Rails handles coercion, defaults, # array typing, dirty tracking. `args` is e.g. [:string], `opts` may # contain :default, :array, etc. attribute name, *args, **opts end # Method names are scoped to the JSONB column so a model can have # multiple jsonb_attributes blocks (Order has 4) without clobbering. store_key_mapping_method_name = "jsonb_store_key_mapping_for_#{jsonb_attribute}" defaults_mapping_method_name = "jsonb_defaults_mapping_for_#{jsonb_attribute}" class_methods_module = Module.new do # store_key map (named_key → underlying JSON key). Walks STI / inherited # chain so subclasses inherit parents' declarations. define_method(store_key_mapping_method_name) do superclass_mapping = superclass.try(store_key_mapping_method_name) || {} superclass_mapping.merge(names_and_store_keys) end end # Compute defaults keyed by store_key (what's actually written to JSON). store_keys_and_defaults = names_and_defaults.transform_keys { |k| names_and_store_keys[k] || k } class_methods_module.instance_eval do define_method(defaults_mapping_method_name) do superclass_mapping = superclass.try(defaults_mapping_method_name) || {} superclass_mapping.merge(store_keys_and_defaults) end end extend class_methods_module # Apply defaults onto the JSONB column itself so a brand-new record # has the expected key/value pairs in the column even before save. # # `deep_dup` each non-Proc default — without it, every new record # gets the SAME `[]` / `{}` reference, so an in-place mutation on # one instance bleeds into the next. Procs are re-invoked per call # so they're already independent. all_defaults_mapping = public_send(defaults_mapping_method_name) if all_defaults_mapping.present? defaults_proc = lambda do all_defaults_mapping.transform_values do |v| v.respond_to?(:call) ? v.call : v.deep_dup end.compact end attribute jsonb_attribute, :jsonb, default: defaults_proc end # Setters: mirror writes from the children into the JSONB column, # and from a JSONB column write back into the children. Defined in # a Module so `super` works for users who want to wrap. setters_module = Module.new do names_and_store_keys.each do |name, store_key| define_method("#{name}=") do |value| super(value) # Re-read through the typed attribute so the value lands in the # column already coerced (e.g. "5" → 5, "true" → true). attribute_value = public_send(name) # Times have to be JSON-serializable strings; AR's #as_json on # Time uses ISO-8601 with timezone offset, which is fine, but # we explicitly format here matching jsonb_accessor's format # for compatibility with existing data. if attribute_value.acts_like?(:time) attribute_value = ( if ActiveRecordExtended::JsonbAttributes.active_record_default_timezone == :utc attribute_value.utc else attribute_value.in_time_zone end ).strftime('%F %R:%S.%L') end new_values = (public_send(jsonb_attribute) || {}).merge(store_key => attribute_value) write_attribute(jsonb_attribute, new_values) end end # Wholesale write to the JSONB column — split incoming hash into # children, rewrite the column with store_key-translated keys. define_method("#{jsonb_attribute}=") do |value| value ||= {} store_key_mapping = self.class.public_send(store_key_mapping_method_name) # Persist the JSONB column with store_key-translated keys. value_with_store_keys = ActiveRecordExtended::JsonbAttributes.translate_keys_to_store_keys(value, store_key_mapping) write_attribute(jsonb_attribute, value_with_store_keys) # Mirror into children. Reset every defined name to nil first, # then set what was provided — this matches jsonb_accessor's # "wholesale assignment clears omitted keys" behavior. value_with_named_keys = ActiveRecordExtended::JsonbAttributes.translate_store_keys_to_keys(value, store_key_mapping) empty_named = store_key_mapping.transform_values { nil } empty_named.merge(value_with_named_keys).each do |name, attribute_value| next unless store_key_mapping.key?(name) write_attribute(name, attribute_value) end end end include setters_module # On load (after_find / after_initialize), hydrate child attributes # from the JSONB column. Clear dirty tracking for the children so a # freshly loaded record doesn't appear dirty. after_initialize do next unless has_attribute?(jsonb_attribute) jsonb_values = public_send(jsonb_attribute) || {} jsonb_values.each do |store_key, value| name = names_and_store_keys.key(store_key) next unless name attr_type = self.class.type_for_attribute(name).type write_attribute(name, ActiveRecordExtended::JsonbAttributes.deserialize_value(value, attr_type)) clear_attribute_change(name) if persisted? end end end |
.parse_date(value) ⇒ ActiveSupport::TimeWithZone, Object
Zone-aware datetime parser used when hydrating :datetime JSONB
values. Non-strings pass through unchanged.
276 277 278 279 280 281 282 283 284 |
# File 'lib/active_record_extended/jsonb_attributes.rb', line 276 def parse_date(value) return value unless value.is_a?(String) if active_record_default_timezone == :utc Time.find_zone('UTC').parse(value).in_time_zone else Time.zone.parse(value) end end |
.translate_keys_to_store_keys(hash, store_key_mapping) ⇒ Hash
Translate named-key → store-key for JSONB column writes.
231 232 233 |
# File 'lib/active_record_extended/jsonb_attributes.rb', line 231 def translate_keys_to_store_keys(hash, store_key_mapping) hash.stringify_keys.transform_keys { |k| store_key_mapping[k] || k } end |
.translate_store_keys_to_keys(hash, store_key_mapping) ⇒ Hash
Translate store-key → named-key for read-side child hydration.
240 241 242 |
# File 'lib/active_record_extended/jsonb_attributes.rb', line 240 def translate_store_keys_to_keys(hash, store_key_mapping) translate_keys_to_store_keys(hash, store_key_mapping.invert) end |