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_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) ⇒ Object
- .build_existence_clause(quoted_column, operator, value, json_keys) ⇒ Object
- .build_lhs_expression(quoted_column, json_keys, text_extraction: false) ⇒ Object
- .build_merge_sql(record, input, touch:) ⇒ Object
-
.deep_merge_jsonb_set(target, payload) ⇒ Object
Recursively walks a (possibly nested-single-key) hash and emits a chained jsonb_set(...) expression.
-
.exec_array_op(record, col, key_path, inner_builder) ⇒ Object
Shared shape for jsonb_array_append / jsonb_array_remove / jsonb_increment.
- .exec_delete_key(record, col, key_path, touch:) ⇒ Object
-
.jsonb_batch_update(records_and_payloads) ⇒ Object
Wraps multiple jsonb_update! calls in a single transaction.
-
.jsonb_gin_index_sql(column_name:, using: :jsonb_path_ops) ⇒ Object
Migration helper.
-
.jsonb_order(column_name:, json_keys:, direction:) ⇒ Object
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 #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) ⇒ Object
-
.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) ⇒ Object
- .jsonb_where_exists_all(column_name:, keys:, json_keys: [], exclude: false) ⇒ Object
- .jsonb_where_exists_any(column_name:, keys:, json_keys: [], exclude: false) ⇒ Object
- .jsonb_where_not(column_name:, operator:, value:, json_keys: [], force_value_type: nil) ⇒ Object
- .multi_value_hash?(value) ⇒ Boolean
- .quote(value) ⇒ Object
- .quote_column_name(name) ⇒ Object
- .quote_table_name(name) ⇒ Object
- .resolve_column!(ar_model, column_name) ⇒ Object
-
.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, soquote()doesn't apply. - .resolve_operator!(operator) ⇒ Object
- .single_value_hash?(value) ⇒ Boolean
- .traverse_payload(key_value_pair, keys = []) ⇒ Object
- .validate_atomic_input!(record, input) ⇒ Object
Instance Method Summary collapse
-
#jsonb_array_append(column_name, key_path, value) ⇒ Object
Append to a JSON array at the given key path.
-
#jsonb_array_remove(column_name, key_path, value) ⇒ Object
Remove every occurrence of value from a JSON array.
-
#jsonb_delete_key(column_name, *key_path) ⇒ Object
Atomically remove a key (or nested key path) from a JSONB column via #-.
- #jsonb_delete_key_columns(column_name, *key_path) ⇒ Object
-
#jsonb_increment(column_name, key_path, delta = 1) ⇒ Object
Atomically add
delta(default 1) to a numeric JSONB key. -
#jsonb_update(input) ⇒ Object
Same as jsonb_update! but uses .validate (returns false instead of raising).
-
#jsonb_update!(input) ⇒ Object
Atomic merge of nested keys via jsonb_set.
-
#jsonb_update_columns(input) ⇒ Object
Same merge, but no callbacks, no validations, no updated_at touch — like update_columns.
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.
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.
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.
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.
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
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
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
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.
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.
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).
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
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.
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 |