Module: ActiveRecordExtended::JsonbAttributes

Extended by:
ActiveSupport::Concern
Defined in:
lib/active_record_extended/jsonb_attributes.rb

Class Method Summary collapse

Class Method Details

.active_record_default_timezoneSymbol

Resolve AR's configured default timezone. Compatibility shim:
ActiveRecord.default_timezone exists on AR 7+ but older rails
exposed it on ActiveRecord::Base.

Returns:

  • (Symbol)

    :utc or :local



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.

Parameters:

  • value (Object)

    value pulled from the JSONB column (Array,
    String, Numeric, Boolean, nil — JSONB primitives only).

  • attribute_type (Symbol)

    AR attribute type symbol
    (:datetime, :string, etc.).

Returns:

  • (Object)

    coerced value. Datetimes (and arrays of datetimes)
    are zone-parsed via parse_date. All other types pass through —
    Rails' attribute API handles them on read.



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.

Parameters:

  • jsonb_attribute (Symbol)

    name of the JSONB column on the model
    (e.g. :metadata, :options, :rate_data).

  • definitions (Hash{Symbol => Symbol, Array})

    per-key
    declarations. Each key becomes a typed virtual attribute on the
    model; values are either an ActiveModel::Type symbol
    (:integer, :string, :datetime, :decimal, :boolean,
    :json) or a [type_sym, options_hash] pair. Recognised options:
    :store_key (alias the underlying JSONB key — e.g. ik_url ↔ url),
    :default (initial value, Proc supported), :array (typed array).

Raises:

  • (ArgumentError)

    if attribute rejects the type spec.



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.

Parameters:

  • value (String, Object)

    ISO-8601 datetime string from JSONB.

Returns:

  • (ActiveSupport::TimeWithZone, Object)

    parsed time when
    given a String; value unchanged otherwise.



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.

Parameters:

  • hash (Hash)

    hash keyed by named keys (or store keys).

  • store_key_mapping (Hash{String => String})

    named-key → store-key map.

Returns:

  • (Hash)

    hash keyed by store keys; entries whose key is not in
    the map pass through unchanged.



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.

Parameters:

  • hash (Hash)

    hash keyed by store keys.

  • store_key_mapping (Hash{String => String})

    named-key → store-key map.

Returns:

  • (Hash)

    hash keyed by named keys.



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