Module: ActiveRecordExtended::JsonbMethods

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

Overview

Class-level query helpers and instance-level atomic-update helpers for
PostgreSQL JSONB columns. Adapted from rails_psql_jsonb (MIT,
https://github.com/bubiche/rails_psql_jsonb v0.2.0) and lifted in-tree so
we control the surface and don't depend on a single-maintainer gem with
zero ecosystem signal. Inspired in turn by atomic_json.

Class methods (call on any AR model):

Model.jsonb_where(column_name: "metadata", operator: :contains, value: { foo: "bar" })
Model.jsonb_where(column_name: "metadata", json_keys: %w[address city], operator: :eq, value: "Berlin")
Model.jsonb_where(column_name: "props", json_keys: ["age"], operator: :gt, value: 20)
Model.jsonb_where_not(...) -- WHERE NOT
Model.jsonb_where_exists(column_name: "metadata", key: "foo") -- key existence (?)
Model.jsonb_where_exists_any(column_name: "metadata", keys: %w[a b]) -- (?|)
Model.jsonb_where_exists_all(column_name: "metadata", keys: %w[a b]) -- (?&)
Model.jsonb_order(column_name: "metadata", json_keys: ["score"], direction: :desc)
Model.jsonb_gin_index_sql(column_name: "metadata") -- migration helper
Model.jsonb_batch_update([[record_a, payload_a], [record_b, payload_b]])

Instance methods (atomic, single-statement UPDATE via jsonb_set / #- /
jsonb_array_elements). Each runs callbacks where applicable and reloads
the record:

record.jsonb_update!(metadata: { score: 31 }) -- merge keys, validates!
record.jsonb_update(metadata: { score: 31 }) -- merge keys, validates (no raise)
record.jsonb_update_columns(metadata: { score: 31 }) -- merge keys, no callbacks/validations/touch
record.jsonb_delete_key("metadata", "foo") -- removes metadata['foo']
record.jsonb_delete_key("metadata", "addr", "city") -- removes metadata['addr']['city']
record.jsonb_array_append("metadata", ["tags"], "ruby") -- append to JSON array
record.jsonb_array_remove("metadata", ["tags"], "ruby") -- remove every match
record.jsonb_increment("metadata", ["score"], 5) -- += 5 (default 1)

JSONB-only by design: validate_jsonb_column! refuses non-:jsonb columns
so a typo or a regular column doesn't silently get wrapped in jsonb_set.

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) ⇒ String

Returns the resolved jsonb column name.

Returns:

  • (String)

    the resolved jsonb column name

Raises:



506
507
508
509
510
511
512
513
514
# File 'lib/active_record_extended/jsonb_methods.rb', line 506

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) ⇒ String

Returns the key-existence SQL clause.

Returns:

  • (String)

    the key-existence SQL clause



406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/active_record_extended/jsonb_methods.rb', line 406

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) ⇒ String

Returns the LHS SQL expression for the key path.

Returns:

  • (String)

    the LHS SQL expression for the key path



388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/active_record_extended/jsonb_methods.rb', line 388

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:) ⇒ String

Returns the UPDATE SQL statement.

Returns:

  • (String)

    the UPDATE SQL statement



425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/active_record_extended/jsonb_methods.rb', line 425

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) ⇒ String

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.

Returns:

  • (String)

    the chained jsonb_set(...) expression



445
446
447
448
449
450
451
# File 'lib/active_record_extended/jsonb_methods.rb', line 445

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) ⇒ PG::Result

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.

Returns:

  • (PG::Result)

    result of the UPDATE statement



533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
# File 'lib/active_record_extended/jsonb_methods.rb', line 533

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:) ⇒ PG::Result

Returns result of the UPDATE statement.

Returns:

  • (PG::Result)

    result of the UPDATE statement



517
518
519
520
521
522
523
524
# File 'lib/active_record_extended/jsonb_methods.rb', line 517

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) ⇒ Array

Wraps multiple jsonb_update! calls in a single transaction.

Returns:

  • (Array)

    the given records-and-payloads pairs



208
209
210
211
212
# File 'lib/active_record_extended/jsonb_methods.rb', line 208

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) ⇒ String

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

Returns:

  • (String)

    the CREATE INDEX SQL statement



198
199
200
201
202
203
204
# File 'lib/active_record_extended/jsonb_methods.rb', line 198

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:) ⇒ ActiveRecord::Relation

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

Returns:

  • (ActiveRecord::Relation)

    relation ordered by the JSONB key path

Raises:



185
186
187
188
189
190
191
192
193
# File 'lib/active_record_extended/jsonb_methods.rb', line 185

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).



158
159
160
161
162
163
164
165
# File 'lib/active_record_extended/jsonb_methods.rb', line 158

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).



174
175
176
177
178
179
180
181
# File 'lib/active_record_extended/jsonb_methods.rb', line 174

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) ⇒ String

Returns the jsonb_set(...) expression.

Returns:

  • (String)

    the jsonb_set(...) expression



465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'lib/active_record_extended/jsonb_methods.rb', line 465

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) ⇒ ActiveRecord::Relation

Returns relation filtered on key existence.

Returns:

  • (ActiveRecord::Relation)

    relation filtered on key existence



118
119
120
121
# File 'lib/active_record_extended/jsonb_methods.rb', line 118

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) ⇒ ActiveRecord::Relation

Returns relation filtered on all-keys existence.

Returns:

  • (ActiveRecord::Relation)

    relation filtered on all-keys existence



130
131
132
133
# File 'lib/active_record_extended/jsonb_methods.rb', line 130

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) ⇒ ActiveRecord::Relation

Returns relation filtered on any-key existence.

Returns:

  • (ActiveRecord::Relation)

    relation filtered on any-key existence



124
125
126
127
# File 'lib/active_record_extended/jsonb_methods.rb', line 124

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) ⇒ ActiveRecord::Relation

Returns relation with the negated JSONB condition.

Returns:

  • (ActiveRecord::Relation)

    relation with the negated JSONB condition



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

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)


482
483
484
# File 'lib/active_record_extended/jsonb_methods.rb', line 482

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

.quote(value) ⇒ String

Returns the quoted value.

Returns:

  • (String)

    the quoted value



343
344
345
# File 'lib/active_record_extended/jsonb_methods.rb', line 343

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

.quote_column_name(name) ⇒ String

Returns the quoted column name.

Returns:

  • (String)

    the quoted column name



353
354
355
# File 'lib/active_record_extended/jsonb_methods.rb', line 353

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

.quote_table_name(name) ⇒ String

Returns the quoted table name.

Returns:

  • (String)

    the quoted table name



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

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

.resolve_column!(ar_model, column_name) ⇒ String

Returns the quoted, table-qualified column reference.

Returns:

  • (String)

    the quoted, table-qualified column reference

Raises:



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

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) ⇒ String?

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).

Returns:

  • (String, nil)

    the validated cast type, or nil



376
377
378
379
380
381
382
383
384
385
# File 'lib/active_record_extended/jsonb_methods.rb', line 376

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) ⇒ String

Returns the SQL operator.

Returns:

  • (String)

    the SQL operator



367
368
369
# File 'lib/active_record_extended/jsonb_methods.rb', line 367

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

.single_value_hash?(value) ⇒ Boolean

Returns:

  • (Boolean)


486
487
488
# File 'lib/active_record_extended/jsonb_methods.rb', line 486

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

.traverse_payload(key_value_pair, keys = []) ⇒ Array(Array<String>, Object)

Returns the accumulated key path and leaf value.

Returns:

  • (Array(Array<String>, Object))

    the accumulated key path and leaf value



454
455
456
457
458
459
460
461
462
# File 'lib/active_record_extended/jsonb_methods.rb', line 454

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) ⇒ void

This method returns an undefined value.

Returns raises on invalid input.



491
492
493
494
495
496
497
498
499
500
501
502
503
# File 'lib/active_record_extended/jsonb_methods.rb', line 491

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) ⇒ self

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

Returns:

  • (self)

    the reloaded record

Raises:

  • (ArgumentError)


279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/active_record_extended/jsonb_methods.rb', line 279

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) ⇒ self

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

Returns:

  • (self)

    the reloaded record

Raises:

  • (ArgumentError)


296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/active_record_extended/jsonb_methods.rb', line 296

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) ⇒ self

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

Returns:

  • (self)

    the reloaded record

Raises:

  • (ArgumentError)


260
261
262
263
264
265
266
# File 'lib/active_record_extended/jsonb_methods.rb', line 260

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) ⇒ self

Returns the reloaded record.

Returns:

  • (self)

    the reloaded record

Raises:

  • (ArgumentError)


269
270
271
272
273
274
275
# File 'lib/active_record_extended/jsonb_methods.rb', line 269

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) ⇒ self

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

Returns:

  • (self)

    the reloaded record

Raises:

  • (TypeError)


320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/active_record_extended/jsonb_methods.rb', line 320

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) ⇒ Boolean

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.

Returns:

  • (Boolean)

    true when the update validated, false when rolled back



237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/active_record_extended/jsonb_methods.rb', line 237

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) ⇒ Boolean

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.

Returns:

  • (Boolean)

    true once the reloaded record validates (raises otherwise)



221
222
223
224
225
226
227
228
229
# File 'lib/active_record_extended/jsonb_methods.rb', line 221

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) ⇒ PG::Result

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

Returns:

  • (PG::Result)

    result of the UPDATE statement



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

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