Module: ActiveRecordExtended::JsonbMethods

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

Defined Under Namespace

Classes: InvalidColumn, InvalidOperator, InvalidOrder, JsonbActiveRecordError, NoOrderKey, ReadOnlyAttribute

Constant Summary collapse

OPERATORS_MAP =
{
  :gt => '>', 'gt' => '>', '>' => '>', :> => '>',
  :lt => '<', 'lt' => '<', '<' => '<', :< => '<',
  :gte => '>=', 'gte' => '>=', :>= => '>=', '>=' => '>=',
  :lte => '<=', 'lte' => '<=', :<= => '<=', '<=' => '<=',
  :eq => '=', 'eq' => '=', :'=' => '=', '=' => '=',
  :contains => '@>', 'contains' => '@>', :'@>' => '@>', '@>' => '@>',
  :exists => '?', 'exists' => '?', :'?' => '?', '?' => '?',
  :exists_any => '?|', 'exists_any' => '?|', :'?|' => '?|', '?|' => '?|',
  :exists_all => '?&', 'exists_all' => '?&', :'?&' => '?&', '?&' => '?&'
}.freeze
NUMERIC_OPERATORS =
%w[> < >= <=].freeze
EXISTENCE_OPERATORS =
%w[? ?| ?&].freeze
VALID_DIRECTIONS =
[:asc, :desc, 'asc', 'desc'].freeze
ALLOWED_FORCE_VALUE_TYPES =

force_value_type is interpolated directly into ::#{cast_type} (no
quote() because PG cast names are identifiers, not literals). Pin to a
closed allow-list so a typo or upstream-supplied string can't smuggle SQL
into the cast slot.

%w[text varchar integer float numeric boolean jsonb json].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.assert_persisted_jsonb!(record, column_name) ⇒ Object



477
478
479
480
481
482
483
484
485
# File 'lib/active_record_extended/jsonb_methods.rb', line 477

def assert_persisted_jsonb!(record, column_name)
  raise JsonbActiveRecordError, 'cannot update a new record' if record.new_record?
  raise JsonbActiveRecordError, 'cannot update a destroyed record' if record.destroyed?

  col = record.class.attribute_alias?(column_name) ? record.class.attribute_alias(column_name) : column_name.to_s
  raise InvalidColumn, "#{record.class.table_name}.#{col} is not a jsonb column" unless record.class.column_names.include?(col) && record.class.type_for_attribute(col).type == :jsonb

  col
end

.build_existence_clause(quoted_column, operator, value, json_keys) ⇒ Object



383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/active_record_extended/jsonb_methods.rb', line 383

def build_existence_clause(quoted_column, operator, value, json_keys)
  case operator
  when '?'
    raise TypeError, "value for ? (exists) operator must be String or Symbol, got #{value.class}" unless value.is_a?(String) || value.is_a?(Symbol)
  when '?|', '?&'
    raise TypeError, "value for #{operator} operator must be Array, got #{value.class}" unless value.is_a?(Array)
  end

  lhs = build_lhs_expression(quoted_column, json_keys)
  case operator
  when '?'
    "#{lhs} ? #{quote(value.to_s)}"
  when '?|', '?&'
    keys = Array(value).map { |k| quote(k.to_s) }.join(', ')
    "#{lhs} #{operator} ARRAY[#{keys}]"
  end
end

.build_lhs_expression(quoted_column, json_keys, text_extraction: false) ⇒ Object



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/active_record_extended/jsonb_methods.rb', line 366

def build_lhs_expression(quoted_column, json_keys, text_extraction: false)
  return quoted_column if json_keys.empty?

  if json_keys.length == 1
    op = text_extraction ? '->>' : '->'
    "#{quoted_column} #{op} #{quote(json_keys.first.to_s)}"
  else
    # Use ARRAY[...] with each key passed through quote() so keys
    # containing single quotes, commas, or braces can't break the SQL
    # syntax or alter the path structure (e.g. "tag,priority" would
    # otherwise turn one key into two when joined into a '{...}' literal).
    path = json_keys.map { |k| quote(k.to_s) }.join(', ')
    op = text_extraction ? '#>>' : '#>'
    "#{quoted_column} #{op} ARRAY[#{path}]"
  end
end

.build_merge_sql(record, input, touch:) ⇒ Object



401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'lib/active_record_extended/jsonb_methods.rb', line 401

def build_merge_sql(record, input, touch:)
  validate_atomic_input!(record, input)

  set_clauses = input.map do |column, payload|
    quoted_col = quote_column_name(column)
    "#{quoted_col} = #{deep_merge_jsonb_set(quoted_col, payload)}"
  end
  set_clauses << "#{quote_column_name(:updated_at)} = #{quote(Time.current)}" if touch && record.has_attribute?(:updated_at)

  <<~SQL.squish.chomp
    UPDATE #{quote_table_name(record.class.table_name)}
    SET #{set_clauses.join(', ')}
    WHERE id = #{quote(record.id)};
  SQL
end

.deep_merge_jsonb_set(target, payload) ⇒ Object

Recursively walks a (possibly nested-single-key) hash and emits a
chained jsonb_set(...) expression. When a hash level has multiple keys,
falls back to target || jsonb_value (concat) which merges the leaf set.



420
421
422
423
424
425
426
# File 'lib/active_record_extended/jsonb_methods.rb', line 420

def deep_merge_jsonb_set(target, payload)
  loop do
    keys, leaf_value = traverse_payload(Hash[*payload.shift])
    target = jsonb_set_expression(target, keys, leaf_value)
    break target if payload.empty?
  end
end

.exec_array_op(record, col, key_path, inner_builder) ⇒ Object

Shared shape for jsonb_array_append / jsonb_array_remove / jsonb_increment.
The block builds the inner expression — the value passed as the third arg
to jsonb_set — given the quoted column reference and the path expression
(ARRAY[<quoted...>], suitable to drop into #> / #>> / jsonb_set).
Keys go through quote() individually so a key containing ', ,, {,
}, or \ can't break SQL syntax or alter path structure.



502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
# File 'lib/active_record_extended/jsonb_methods.rb', line 502

def exec_array_op(record, col, key_path, inner_builder)
  quoted_col = quote_column_name(col)
  quoted_tbl = quote_table_name(record.class.table_name)
  path_expr = "ARRAY[#{key_path.map { |k| quote(k.to_s) }.join(', ')}]"
  inner = inner_builder.call(quoted_col, path_expr)
  touch_clause = record.has_attribute?(:updated_at) ? ", #{quote_column_name(:updated_at)} = #{quote(Time.current)}" : ''

  sql = <<~SQL.squish
    UPDATE #{quoted_tbl}
    SET #{quoted_col} = jsonb_set(
      #{quoted_col}::jsonb,
      #{path_expr},
      #{inner}
    )#{touch_clause}
    WHERE id = #{quote(record.id)};
  SQL
  record.class.with_connection { |conn| conn.exec_update(sql) }
end

.exec_delete_key(record, col, key_path, touch:) ⇒ Object



487
488
489
490
491
492
493
494
# File 'lib/active_record_extended/jsonb_methods.rb', line 487

def exec_delete_key(record, col, key_path, touch:)
  quoted_col = quote_column_name(col)
  quoted_tbl = quote_table_name(record.class.table_name)
  path_array = key_path.map { |k| quote(k.to_s) }.join(', ')
  touch_clause = touch && record.has_attribute?(:updated_at) ? ", #{quote_column_name(:updated_at)} = #{quote(Time.current)}" : ''
  sql = "UPDATE #{quoted_tbl} SET #{quoted_col} = #{quoted_col} #- ARRAY[#{path_array}]#{touch_clause} WHERE id = #{quote(record.id)};"
  record.class.with_connection { |conn| conn.exec_update(sql) }
end

.jsonb_batch_update(records_and_payloads) ⇒ Object

Wraps multiple jsonb_update! calls in a single transaction.



201
202
203
204
205
# File 'lib/active_record_extended/jsonb_methods.rb', line 201

def jsonb_batch_update(records_and_payloads)
  transaction do
    records_and_payloads.each { |record, input| record.jsonb_update!(input) }
  end
end

.jsonb_gin_index_sql(column_name:, using: :jsonb_path_ops) ⇒ Object

Migration helper. using: defaults to :jsonb_path_ops (smaller, faster
for @>); pass :jsonb_ops to also support ?, ?|, ?& key-existence ops.



192
193
194
195
196
197
198
# File 'lib/active_record_extended/jsonb_methods.rb', line 192

def jsonb_gin_index_sql(column_name:, using: :jsonb_path_ops)
  with_connection do |conn|
    tbl = conn.quote_table_name(table_name)
    col = conn.quote_column_name(column_name.to_s)
    "CREATE INDEX ON #{tbl} USING GIN (#{col} #{using});"
  end
end

.jsonb_order(column_name:, json_keys:, direction:) ⇒ Object

ORDER BY a JSONB key path. NULLs sort LAST for asc, FIRST for desc.

Raises:



180
181
182
183
184
185
186
187
188
# File 'lib/active_record_extended/jsonb_methods.rb', line 180

def jsonb_order(column_name:, json_keys:, direction:)
  quoted_column = ActiveRecordExtended::JsonbMethods.resolve_column!(self, column_name)
  raise NoOrderKey, 'order json_keys must be a non-empty array' unless json_keys.is_a?(Array) && !json_keys.empty?
  raise InvalidOrder, "only :asc or :desc allowed, got: #{direction.inspect}" unless VALID_DIRECTIONS.include?(direction)

  lhs = ActiveRecordExtended::JsonbMethods.build_lhs_expression(quoted_column, json_keys)
  nulls_clause = direction.to_s.downcase == 'asc' ? 'NULLS LAST' : 'NULLS FIRST'
  order(Arel.sql("(#{lhs}) #{direction} #{nulls_clause}"))
end

.jsonb_pick(column_name:, key:, cast: nil) ⇒ Object?

SELECT a single value from a JSONB key path on the first row of the
current scope. Mirrors AR's .pick — returns the scalar value (or
nil) without instantiating an AR object. Combine with .order(...)
to control which row is "first"; with no ordering, the database is
free to return any row.

By default the value comes out as text (the ->> operator). Pass
cast: to coerce — useful when the JSONB value is numeric/boolean
and you want the Ruby type, not a String. cast is pinned to the
ALLOWED_FORCE_VALUE_TYPES allow-list (text/varchar/integer/float/
numeric/boolean/jsonb/json) so it can't smuggle SQL into the cast slot.

Examples:

Latest visit's gclid

customer.visits.order(id: :desc).jsonb_pick(column_name: :marketing_meta, key: :gclid)

Cast a numeric JSONB value

Visit.jsonb_pick(column_name: :device_meta, key: :screen_width, cast: 'integer')

Parameters:

  • column_name (String, Symbol)

    JSONB column on this model.

  • key (String, Symbol)

    top-level JSONB key to extract.

  • cast (String, nil) (defaults to: nil)

    optional Postgres type to cast the value to.

Returns:

  • (Object, nil)

    the extracted value, or nil if the row is
    missing the key (or the scope is empty).



154
155
156
157
158
159
160
161
# File 'lib/active_record_extended/jsonb_methods.rb', line 154

def jsonb_pick(column_name:, key:, cast: nil)
  quoted_column = ActiveRecordExtended::JsonbMethods.resolve_column!(self, column_name)
  cast_type = ActiveRecordExtended::JsonbMethods.resolve_force_value_type!(cast)
  quoted_key = ActiveRecordExtended::JsonbMethods.quote(key.to_s)
  expr = "#{quoted_column} ->> #{quoted_key}"
  expr = "(#{expr})::#{cast_type}" if cast_type
  pick(Arel.sql(expr))
end

.jsonb_pluck(column_name:, key:, cast: nil) ⇒ Array

pluck counterpart of #jsonb_pick — returns an array of values
extracted from the JSONB key path across every row in the scope.

Parameters:

  • column_name (String, Symbol)

    JSONB column.

  • key (String, Symbol)

    top-level JSONB key to extract.

  • cast (String, nil) (defaults to: nil)

    optional Postgres cast.

Returns:

  • (Array)

    extracted values (nils included for rows missing the key).



170
171
172
173
174
175
176
177
# File 'lib/active_record_extended/jsonb_methods.rb', line 170

def jsonb_pluck(column_name:, key:, cast: nil)
  quoted_column = ActiveRecordExtended::JsonbMethods.resolve_column!(self, column_name)
  cast_type = ActiveRecordExtended::JsonbMethods.resolve_force_value_type!(cast)
  quoted_key = ActiveRecordExtended::JsonbMethods.quote(key.to_s)
  expr = "#{quoted_column} ->> #{quoted_key}"
  expr = "(#{expr})::#{cast_type}" if cast_type
  pluck(Arel.sql(expr))
end

.jsonb_set_expression(target, keys, value) ⇒ Object



438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
# File 'lib/active_record_extended/jsonb_methods.rb', line 438

def jsonb_set_expression(target, keys, value)
  # quote() the JSON payload AND each key — to_json doesn't escape SQL
  # single quotes, and unquoted keys interpolated into a `'{k1,k2}'`
  # path literal can break SQL syntax (single quotes) or alter the path
  # structure (commas, braces). Use ARRAY[...]::text[] for the path so
  # each key is independently quoted, mirroring exec_delete_key above.
  quoted_value = quote(value.to_json)
  path_array = "ARRAY[#{keys.map { |k| quote(k.to_s) }.join(', ')}]"
  if multi_value_hash?(value)
    merge_path = keys.map { |k| quote(k.to_s) }.join('->')
    rhs = "#{target}->#{merge_path} || #{quoted_value}::jsonb"
  else
    rhs = "#{quoted_value}::jsonb"
  end
  "jsonb_set(#{target}::jsonb, #{path_array}, #{rhs})"
end

.jsonb_where(column_name:, operator:, value:, json_keys: [], force_value_type: nil, exclude: false) ⇒ ActiveRecord::Relation

Build a WHERE clause against a JSONB column.

Parameters:

  • column_name (String, Symbol)

    JSONB column on this model.

  • operator (Symbol, String)

    one of OPERATORS_MAP keys (:contains, :eq, :gt, :lt, :gte, :lte, :exists, :exists_any, :exists_all and string/symbol synonyms).

  • value (Object)

    hash for :contains, scalar for comparisons, key string for :exists, key array for :exists_any / :exists_all.

  • json_keys (Array<String, Symbol>) (defaults to: [])

    optional path inside the JSONB value.

  • force_value_type (String, nil) (defaults to: nil)

    override the cast type on the RHS (e.g. 'text', 'numeric'); rare.

  • exclude (Boolean) (defaults to: false)

    flip to WHERE NOT.

Returns:

  • (ActiveRecord::Relation)


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
# File 'lib/active_record_extended/jsonb_methods.rb', line 81

def jsonb_where(column_name:, operator:, value:, json_keys: [], force_value_type: nil, exclude: false)
  quoted_column = ActiveRecordExtended::JsonbMethods.resolve_column!(self, column_name)
  query_operator = ActiveRecordExtended::JsonbMethods.resolve_operator!(operator)
  force_value_type = ActiveRecordExtended::JsonbMethods.resolve_force_value_type!(force_value_type)

  if EXISTENCE_OPERATORS.include?(query_operator)
    clause = ActiveRecordExtended::JsonbMethods.build_existence_clause(quoted_column, query_operator, value, json_keys)
    return exclude ? where.not(clause) : where(clause)
  end

  is_numeric = NUMERIC_OPERATORS.include?(query_operator)
  # For numeric comparison through a key path, use ->> / #>> (text extraction)
  # then cast to float — idiomatic Postgres for "compare a JSON number to a Ruby number"
  # without needing the value to also be cast as jsonb.
  use_text_extraction = is_numeric && force_value_type.nil? && !json_keys.empty?

  lhs = ActiveRecordExtended::JsonbMethods.build_lhs_expression(quoted_column, json_keys, text_extraction: use_text_extraction)

  clause =
    if use_text_extraction
      "(#{lhs})::float #{query_operator} #{ActiveRecordExtended::JsonbMethods.quote(value)}"
    else
      rhs_value = is_numeric ? value : value.to_json
      cast_type = force_value_type || (is_numeric ? 'float' : 'jsonb')
      "(#{lhs})::#{cast_type} #{query_operator} (#{ActiveRecordExtended::JsonbMethods.quote(rhs_value)})::#{cast_type}"
    end

  exclude ? where.not(clause) : where(clause)
end

.jsonb_where_exists(column_name:, key:, json_keys: [], exclude: false) ⇒ Object



116
117
118
119
# File 'lib/active_record_extended/jsonb_methods.rb', line 116

def jsonb_where_exists(column_name:, key:, json_keys: [], exclude: false)
  jsonb_where(column_name: column_name, operator: :exists, value: key,
               json_keys: json_keys, exclude: exclude)
end

.jsonb_where_exists_all(column_name:, keys:, json_keys: [], exclude: false) ⇒ Object



126
127
128
129
# File 'lib/active_record_extended/jsonb_methods.rb', line 126

def jsonb_where_exists_all(column_name:, keys:, json_keys: [], exclude: false)
  jsonb_where(column_name: column_name, operator: :exists_all, value: keys,
               json_keys: json_keys, exclude: exclude)
end

.jsonb_where_exists_any(column_name:, keys:, json_keys: [], exclude: false) ⇒ Object



121
122
123
124
# File 'lib/active_record_extended/jsonb_methods.rb', line 121

def jsonb_where_exists_any(column_name:, keys:, json_keys: [], exclude: false)
  jsonb_where(column_name: column_name, operator: :exists_any, value: keys,
               json_keys: json_keys, exclude: exclude)
end

.jsonb_where_not(column_name:, operator:, value:, json_keys: [], force_value_type: nil) ⇒ Object



111
112
113
114
# File 'lib/active_record_extended/jsonb_methods.rb', line 111

def jsonb_where_not(column_name:, operator:, value:, json_keys: [], force_value_type: nil)
  jsonb_where(column_name: column_name, operator: operator, value: value,
               json_keys: json_keys, force_value_type: force_value_type, exclude: true)
end

.multi_value_hash?(value) ⇒ Boolean

Returns:

  • (Boolean)


455
456
457
# File 'lib/active_record_extended/jsonb_methods.rb', line 455

def multi_value_hash?(value)
  value.is_a?(Hash) && value.keys.many?
end

.quote(value) ⇒ Object



327
328
329
# File 'lib/active_record_extended/jsonb_methods.rb', line 327

def quote(value)
  ActiveRecord::Base.lease_connection.quote(value)
end

.quote_column_name(name) ⇒ Object



335
336
337
# File 'lib/active_record_extended/jsonb_methods.rb', line 335

def quote_column_name(name)
  ActiveRecord::Base.lease_connection.quote_column_name(name)
end

.quote_table_name(name) ⇒ Object



331
332
333
# File 'lib/active_record_extended/jsonb_methods.rb', line 331

def quote_table_name(name)
  ActiveRecord::Base.lease_connection.quote_table_name(name)
end

.resolve_column!(ar_model, column_name) ⇒ Object

Raises:



339
340
341
342
343
344
345
# File 'lib/active_record_extended/jsonb_methods.rb', line 339

def resolve_column!(ar_model, column_name)
  col = ar_model.attribute_alias?(column_name) ? ar_model.attribute_alias(column_name) : column_name.to_s

  raise InvalidColumn, "#{ar_model.table_name}.#{col} is not a jsonb column" unless ar_model.column_names.include?(col) && ar_model.type_for_attribute(col).type == :jsonb

  "#{quote_table_name(ar_model.table_name)}.#{quote_column_name(col)}"
end

.resolve_force_value_type!(force_value_type) ⇒ Object

Pin force_value_type to a closed allow-list — the value is
interpolated directly into ::#{cast_type} and PG cast names are
identifiers, not literals, so quote() doesn't apply. nil passes
through (the default cast is chosen by jsonb_where).



355
356
357
358
359
360
361
362
363
364
# File 'lib/active_record_extended/jsonb_methods.rb', line 355

def resolve_force_value_type!(force_value_type)
  return nil if force_value_type.nil?

  cast_type = force_value_type.to_s
  unless ALLOWED_FORCE_VALUE_TYPES.include?(cast_type)
    raise InvalidOperator,
          "force_value_type must be one of #{ALLOWED_FORCE_VALUE_TYPES.inspect}, got #{force_value_type.inspect}"
  end
  cast_type
end

.resolve_operator!(operator) ⇒ Object



347
348
349
# File 'lib/active_record_extended/jsonb_methods.rb', line 347

def resolve_operator!(operator)
  OPERATORS_MAP[operator] || raise(InvalidOperator, "Invalid operator #{operator.inspect}")
end

.single_value_hash?(value) ⇒ Boolean

Returns:

  • (Boolean)


459
460
461
# File 'lib/active_record_extended/jsonb_methods.rb', line 459

def single_value_hash?(value)
  value.is_a?(Hash) && value.keys.one?
end

.traverse_payload(key_value_pair, keys = []) ⇒ Object



428
429
430
431
432
433
434
435
436
# File 'lib/active_record_extended/jsonb_methods.rb', line 428

def traverse_payload(key_value_pair, keys = [])
  loop do
    key, val = key_value_pair.flatten
    keys << key.to_s
    break [keys, val] unless single_value_hash?(val)

    key_value_pair = val
  end
end

.validate_atomic_input!(record, input) ⇒ Object



463
464
465
466
467
468
469
470
471
472
473
474
475
# File 'lib/active_record_extended/jsonb_methods.rb', line 463

def validate_atomic_input!(record, input)
  raise JsonbActiveRecordError, 'cannot update a new record' if record.new_record?
  raise JsonbActiveRecordError, 'cannot update a destroyed record' if record.destroyed?
  raise TypeError, 'jsonb update input must be a Hash' unless input.is_a?(Hash)

  input.each do |key, payload|
    raise ReadOnlyAttribute, "#{key} is marked readonly" if record.class.readonly_attributes.include?(key.to_s)

    col = record.class.attribute_alias?(key) ? record.class.attribute_alias(key) : key.to_s
    raise InvalidColumn, "#{record.class.table_name}.#{col} is not a jsonb column" unless record.class.column_names.include?(col) && record.class.type_for_attribute(col).type == :jsonb
    raise ArgumentError, "payload for column #{key} must not be empty" if payload.is_a?(Hash) && payload.empty?
  end
end

Instance Method Details

#jsonb_array_append(column_name, key_path, value) ⇒ Object

Append to a JSON array at the given key path. Initializes [] when absent.

Raises:

  • (ArgumentError)


266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/active_record_extended/jsonb_methods.rb', line 266

def jsonb_array_append(column_name, key_path, value)
  col = ActiveRecordExtended::JsonbMethods.assert_persisted_jsonb!(self, column_name)
  key_path = Array(key_path)
  raise ArgumentError, 'key_path must not be empty' if key_path.empty?

  ActiveRecordExtended::JsonbMethods.exec_array_op(
    self, col, key_path,
    ->(quoted_col, path_expr) {
      quoted_val = ActiveRecordExtended::JsonbMethods.quote(value.to_json)
      "COALESCE(#{quoted_col} #> #{path_expr}, '[]'::jsonb) || jsonb_build_array(#{quoted_val}::jsonb)"
    }
  )
  reload
end

#jsonb_array_remove(column_name, key_path, value) ⇒ Object

Remove every occurrence of value from a JSON array. Returns [] when emptied or absent.

Raises:

  • (ArgumentError)


282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/active_record_extended/jsonb_methods.rb', line 282

def jsonb_array_remove(column_name, key_path, value)
  col = ActiveRecordExtended::JsonbMethods.assert_persisted_jsonb!(self, column_name)
  key_path = Array(key_path)
  raise ArgumentError, 'key_path must not be empty' if key_path.empty?

  ActiveRecordExtended::JsonbMethods.exec_array_op(
    self, col, key_path,
    ->(quoted_col, path_expr) {
      quoted_val = ActiveRecordExtended::JsonbMethods.quote(value.to_json)
      <<~SQL.squish.chomp
        COALESCE(
          (SELECT jsonb_agg(e)
           FROM jsonb_array_elements(#{quoted_col} #> #{path_expr}) AS e
           WHERE e <> #{quoted_val}::jsonb),
          '[]'::jsonb
        )
      SQL
    }
  )
  reload
end

#jsonb_delete_key(column_name, *key_path) ⇒ Object

Atomically remove a key (or nested key path) from a JSONB column via #-.
Safe when the path doesn't exist (no-op).

Raises:

  • (ArgumentError)


249
250
251
252
253
254
255
# File 'lib/active_record_extended/jsonb_methods.rb', line 249

def jsonb_delete_key(column_name, *key_path)
  raise ArgumentError, 'key_path must not be empty' if key_path.empty?

  col = ActiveRecordExtended::JsonbMethods.assert_persisted_jsonb!(self, column_name)
  ActiveRecordExtended::JsonbMethods.exec_delete_key(self, col, key_path, touch: true)
  reload
end

#jsonb_delete_key_columns(column_name, *key_path) ⇒ Object

Raises:

  • (ArgumentError)


257
258
259
260
261
262
263
# File 'lib/active_record_extended/jsonb_methods.rb', line 257

def jsonb_delete_key_columns(column_name, *key_path)
  raise ArgumentError, 'key_path must not be empty' if key_path.empty?

  col = ActiveRecordExtended::JsonbMethods.assert_persisted_jsonb!(self, column_name)
  ActiveRecordExtended::JsonbMethods.exec_delete_key(self, col, key_path, touch: false)
  reload
end

#jsonb_increment(column_name, key_path, delta = 1) ⇒ Object

Atomically add delta (default 1) to a numeric JSONB key. Initializes 0 when absent.

Raises:

  • (TypeError)


305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/active_record_extended/jsonb_methods.rb', line 305

def jsonb_increment(column_name, key_path, delta = 1)
  raise TypeError, "delta must be Numeric, got #{delta.class}" unless delta.is_a?(Numeric)

  col = ActiveRecordExtended::JsonbMethods.assert_persisted_jsonb!(self, column_name)
  key_path = Array(key_path)
  raise ArgumentError, 'key_path must not be empty' if key_path.empty?

  ActiveRecordExtended::JsonbMethods.exec_array_op(
    self, col, key_path,
    ->(quoted_col, path_expr) {
      "to_jsonb(COALESCE((#{quoted_col} #>> #{path_expr})::numeric, 0) + #{ActiveRecordExtended::JsonbMethods.quote(delta)})"
    }
  )
  reload
end

#jsonb_update(input) ⇒ Object

Same as jsonb_update! but uses .validate (returns false instead of raising).

Wrapped in a transaction; a false validation result triggers
ActiveRecord::Rollback so the SQL is reverted, mirroring AR's
save semantics.



228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/active_record_extended/jsonb_methods.rb', line 228

def jsonb_update(input)
  sql = ActiveRecordExtended::JsonbMethods.build_merge_sql(self, input.deep_dup, touch: true)
  valid = false
  self.class.transaction do
    run_callbacks(:save) do
      self.class.with_connection { |conn| conn.exec_update(sql) }
      valid = reload.validate
      raise ActiveRecord::Rollback unless valid
    end
  end
  valid
end

#jsonb_update!(input) ⇒ Object

Atomic merge of nested keys via jsonb_set. Validates after reload (raises).

Wrapped in a transaction so a failing post-write validation rolls the
SQL back — otherwise an invalid record would persist before validate!
could intervene.



213
214
215
216
217
218
219
220
221
# File 'lib/active_record_extended/jsonb_methods.rb', line 213

def jsonb_update!(input)
  sql = ActiveRecordExtended::JsonbMethods.build_merge_sql(self, input.deep_dup, touch: true)
  self.class.transaction do
    run_callbacks(:save) do
      self.class.with_connection { |conn| conn.exec_update(sql) }
      reload.validate!
    end
  end
end

#jsonb_update_columns(input) ⇒ Object

Same merge, but no callbacks, no validations, no updated_at touch — like update_columns.



242
243
244
245
# File 'lib/active_record_extended/jsonb_methods.rb', line 242

def jsonb_update_columns(input)
  sql = ActiveRecordExtended::JsonbMethods.build_merge_sql(self, input.deep_dup, touch: false)
  self.class.with_connection { |conn| conn.exec_update(sql) }
end