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_typeis 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
-
.assert_persisted_jsonb!(record, column_name) ⇒ String
The resolved jsonb column name.
-
.build_existence_clause(quoted_column, operator, value, json_keys) ⇒ String
The key-existence SQL clause.
-
.build_lhs_expression(quoted_column, json_keys, text_extraction: false) ⇒ String
The LHS SQL expression for the key path.
-
.build_merge_sql(record, input, touch:) ⇒ String
The UPDATE SQL statement.
-
.deep_merge_jsonb_set(target, payload) ⇒ String
Recursively walks a (possibly nested-single-key) hash and emits a chained jsonb_set(...) expression.
-
.exec_array_op(record, col, key_path, inner_builder) ⇒ PG::Result
Shared shape for jsonb_array_append / jsonb_array_remove / jsonb_increment.
-
.exec_delete_key(record, col, key_path, touch:) ⇒ PG::Result
Result of the UPDATE statement.
-
.jsonb_batch_update(records_and_payloads) ⇒ Array
Wraps multiple jsonb_update! calls in a single transaction.
-
.jsonb_gin_index_sql(column_name:, using: :jsonb_path_ops) ⇒ String
Migration helper.
-
.jsonb_order(column_name:, json_keys:, direction:) ⇒ ActiveRecord::Relation
ORDER BY a JSONB key path.
-
.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.
-
.jsonb_pluck(column_name:, key:, cast: nil) ⇒ Array
pluckcounterpart of JsonbMethods.jsonb_pick — returns an array of values extracted from the JSONB key path across every row in the scope. -
.jsonb_set_expression(target, keys, value) ⇒ String
The jsonb_set(...) expression.
-
.jsonb_where(column_name:, operator:, value:, json_keys: [], force_value_type: nil, exclude: false) ⇒ ActiveRecord::Relation
Build a WHERE clause against a JSONB column.
-
.jsonb_where_exists(column_name:, key:, json_keys: [], exclude: false) ⇒ ActiveRecord::Relation
Relation filtered on key existence.
-
.jsonb_where_exists_all(column_name:, keys:, json_keys: [], exclude: false) ⇒ ActiveRecord::Relation
Relation filtered on all-keys existence.
-
.jsonb_where_exists_any(column_name:, keys:, json_keys: [], exclude: false) ⇒ ActiveRecord::Relation
Relation filtered on any-key existence.
-
.jsonb_where_not(column_name:, operator:, value:, json_keys: [], force_value_type: nil) ⇒ ActiveRecord::Relation
Relation with the negated JSONB condition.
- .multi_value_hash?(value) ⇒ Boolean
-
.quote(value) ⇒ String
The quoted value.
-
.quote_column_name(name) ⇒ String
The quoted column name.
-
.quote_table_name(name) ⇒ String
The quoted table name.
-
.resolve_column!(ar_model, column_name) ⇒ String
The quoted, table-qualified column reference.
-
.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, soquote()doesn't apply. -
.resolve_operator!(operator) ⇒ String
The SQL operator.
- .single_value_hash?(value) ⇒ Boolean
-
.traverse_payload(key_value_pair, keys = []) ⇒ Array(Array<String>, Object)
The accumulated key path and leaf value.
-
.validate_atomic_input!(record, input) ⇒ void
Raises on invalid input.
Instance Method Summary collapse
-
#jsonb_array_append(column_name, key_path, value) ⇒ self
Append to a JSON array at the given key path.
-
#jsonb_array_remove(column_name, key_path, value) ⇒ self
Remove every occurrence of value from a JSON array.
-
#jsonb_delete_key(column_name, *key_path) ⇒ self
Atomically remove a key (or nested key path) from a JSONB column via #-.
-
#jsonb_delete_key_columns(column_name, *key_path) ⇒ self
The reloaded record.
-
#jsonb_increment(column_name, key_path, delta = 1) ⇒ self
Atomically add
delta(default 1) to a numeric JSONB key. -
#jsonb_update(input) ⇒ Boolean
Same as jsonb_update! but uses .validate (returns false instead of raising).
-
#jsonb_update!(input) ⇒ Boolean
Atomic merge of nested keys via jsonb_set.
-
#jsonb_update_columns(input) ⇒ PG::Result
Same merge, but no callbacks, no validations, no updated_at touch — like update_columns.
Class Method Details
.assert_persisted_jsonb!(record, column_name) ⇒ String
Returns the resolved jsonb column name.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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).
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.
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
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.
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.
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.
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).
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.
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.
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.
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.
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.
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 |