rails_psql_jsonb 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 20375e29b24d21edfb8eb5a7160048ec5bbc3c214d9db32636df59c8cf3353ba
4
+ data.tar.gz: c3d5751129d850e27f98108280a660e63f3e87c8f38d6b7db1abfe16dd5adb8f
5
+ SHA512:
6
+ metadata.gz: 6ad4396fecd958c4bfffd7703052476ccbde5f9ae1502da302d51ca33bd213b6e24c76a4146cb4a2f80309d25ac62d5dbcc697196688d2c224c93e71d00b23d9
7
+ data.tar.gz: a10648261f1625b72bd30562cdc2bab15b4f53dad0b305c8662eecbfec9cbe719b11b4d47687e4b27104d101caf49d49921d3eb0464e1b41990b854550204494
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.2.0] - 2026-04-07
4
+
5
+ ### Fixed
6
+ - Removed debug `puts` statement left in `jsonb_update!`
7
+ - Fixed `jsonb_update!` and `jsonb_update_columns` mutating the caller's input hash
8
+ - Fixed `self.name.constantize` in querying class methods (broke on anonymous models)
9
+ - Fixed `NoOrderKey` error initializer accepting an unused `attribute:` keyword argument
10
+ - Fixed `ReadOnlyAttribute` raise syntax (`ReadOnlyAttribute(...)` → `ReadOnlyAttribute.new(...)`)
11
+
12
+ ### Added
13
+ - **Key existence queries**: `jsonb_where_exists`, `jsonb_where_exists_any`, `jsonb_where_exists_all` using PostgreSQL `?`, `?|`, `?&` operators
14
+ - **Atomic key deletion**: `jsonb_delete_key` / `jsonb_delete_key!` / `jsonb_delete_key_columns` via PostgreSQL `#-`
15
+ - **Atomic array append**: `jsonb_array_append` — initializes missing key to `[]` automatically
16
+ - **Atomic array remove**: `jsonb_array_remove` — removes all occurrences of a value
17
+ - **Atomic numeric increment**: `jsonb_increment` — initializes missing key to `0` automatically
18
+ - **Batch update**: `jsonb_batch_update` — wraps multiple `jsonb_update!` calls in a transaction
19
+ - **GIN index helper**: `jsonb_gin_index_sql` — returns the SQL to create an optimal GIN index
20
+
21
+ ### Improved
22
+ - Multi-key paths use `#>`/`#>>` path operators instead of chained `->` (more idiomatic, better index usage)
23
+ - Numeric comparisons use `->>` text extraction before `::float` cast
24
+ - `jsonb_order` now emits explicit `NULLS LAST` (asc) / `NULLS FIRST` (desc)
25
+ - Trimmed `quoting.rb` to only the methods actually used; fixed `default_timezone` reference
26
+ - Added test isolation via `database_cleaner-active_record`
27
+
28
+ ## [0.1.0] - 2024-06-20
29
+
30
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in rails_psql_jsonb.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,81 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ rails_psql_jsonb (0.2.0)
5
+ activerecord (>= 6.1)
6
+ activesupport (>= 6.1)
7
+ pg (>= 1.5.6)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ activemodel (8.1.3)
13
+ activesupport (= 8.1.3)
14
+ activerecord (8.1.3)
15
+ activemodel (= 8.1.3)
16
+ activesupport (= 8.1.3)
17
+ timeout (>= 0.4.0)
18
+ activesupport (8.1.3)
19
+ base64
20
+ bigdecimal
21
+ concurrent-ruby (~> 1.0, >= 1.3.1)
22
+ connection_pool (>= 2.2.5)
23
+ drb
24
+ i18n (>= 1.6, < 2)
25
+ json
26
+ logger (>= 1.4.2)
27
+ minitest (>= 5.1)
28
+ securerandom (>= 0.3)
29
+ tzinfo (~> 2.0, >= 2.0.5)
30
+ uri (>= 0.13.1)
31
+ base64 (0.3.0)
32
+ bigdecimal (4.1.1)
33
+ concurrent-ruby (1.3.6)
34
+ connection_pool (3.0.2)
35
+ database_cleaner-active_record (2.2.2)
36
+ activerecord (>= 5.a)
37
+ database_cleaner-core (~> 2.0)
38
+ database_cleaner-core (2.0.1)
39
+ diff-lcs (1.6.2)
40
+ drb (2.2.3)
41
+ i18n (1.14.8)
42
+ concurrent-ruby (~> 1.0)
43
+ json (2.19.3)
44
+ logger (1.7.0)
45
+ minitest (6.0.3)
46
+ drb (~> 2.0)
47
+ prism (~> 1.5)
48
+ pg (1.6.3-arm64-darwin)
49
+ prism (1.9.0)
50
+ rake (13.3.1)
51
+ rspec (3.13.2)
52
+ rspec-core (~> 3.13.0)
53
+ rspec-expectations (~> 3.13.0)
54
+ rspec-mocks (~> 3.13.0)
55
+ rspec-core (3.13.6)
56
+ rspec-support (~> 3.13.0)
57
+ rspec-expectations (3.13.5)
58
+ diff-lcs (>= 1.2.0, < 2.0)
59
+ rspec-support (~> 3.13.0)
60
+ rspec-mocks (3.13.8)
61
+ diff-lcs (>= 1.2.0, < 2.0)
62
+ rspec-support (~> 3.13.0)
63
+ rspec-support (3.13.7)
64
+ securerandom (0.4.1)
65
+ timeout (0.6.1)
66
+ tzinfo (2.0.6)
67
+ concurrent-ruby (~> 1.0)
68
+ uri (1.1.1)
69
+
70
+ PLATFORMS
71
+ arm64-darwin-23
72
+ arm64-darwin-24
73
+
74
+ DEPENDENCIES
75
+ database_cleaner-active_record (~> 2.1)
76
+ rails_psql_jsonb!
77
+ rake (~> 13.0)
78
+ rspec (~> 3.0)
79
+
80
+ BUNDLED WITH
81
+ 2.4.13
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 bubiche
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # RailsPsqlJsonb
2
+
3
+ Helpers for querying and atomically updating PostgreSQL JSONB columns in Rails ActiveRecord.
4
+
5
+ Inspired by https://github.com/madeintandem/jsonb_accessor and https://github.com/antoinemacia/atomic_json
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "rails_psql_jsonb"
13
+ ```
14
+
15
+ Include in your model:
16
+
17
+ ```ruby
18
+ class Friend < ApplicationRecord
19
+ include RailsPsqlJsonb
20
+ end
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Querying
26
+
27
+ **Contains / equality / numeric comparison**
28
+
29
+ ```ruby
30
+ # @> contains
31
+ Friend.jsonb_where(column_name: "props", operator: "contains", value: { age: 90 })
32
+
33
+ # Numeric operators: gt, lt, gte, lte, eq (also accept >, <, >=, <=, =)
34
+ Friend.jsonb_where(column_name: "props", json_keys: ["age"], operator: "gt", value: 20)
35
+
36
+ # Nested key path
37
+ Friend.jsonb_where(column_name: "props", json_keys: ["address", "city"], operator: "eq", value: "Berlin")
38
+
39
+ # Exclusion
40
+ Friend.jsonb_where_not(column_name: "props", operator: "contains", value: { active: true })
41
+ ```
42
+
43
+ **Key existence**
44
+
45
+ ```ruby
46
+ # Records where the key exists
47
+ Friend.jsonb_where_exists(column_name: "props", key: "age")
48
+
49
+ # Records where the key is absent
50
+ Friend.jsonb_where_exists(column_name: "props", key: "age", exclude: true)
51
+
52
+ # Records having any of the given keys
53
+ Friend.jsonb_where_exists_any(column_name: "props", keys: ["age", "score"])
54
+
55
+ # Records having all of the given keys
56
+ Friend.jsonb_where_exists_all(column_name: "props", keys: ["age", "name"])
57
+
58
+ # Scope check to a nested object
59
+ Friend.jsonb_where_exists(column_name: "props", key: "city", json_keys: ["address"])
60
+ ```
61
+
62
+ **Ordering**
63
+
64
+ ```ruby
65
+ Friend.all.jsonb_order(column_name: "props", json_keys: ["age"], direction: "desc")
66
+ # NULLs sort first for desc, last for asc
67
+ ```
68
+
69
+ ### Updating
70
+
71
+ All update methods are atomic at the database level using `jsonb_set`.
72
+
73
+ **Set / merge keys**
74
+
75
+ ```ruby
76
+ # Merges new keys without overwriting unrelated keys
77
+ friend.jsonb_update!({ "props" => { "age" => 31 } })
78
+ friend.jsonb_update!({ "props" => { "age" => 31, "score" => 100 } })
79
+
80
+ # Without touching updated_at
81
+ friend.jsonb_update_columns({ "props" => { "age" => 31 } })
82
+
83
+ # Without running validations
84
+ friend.jsonb_update({ "props" => { "age" => 31 } })
85
+ ```
86
+
87
+ **Delete a key**
88
+
89
+ ```ruby
90
+ friend.jsonb_delete_key("props", "age") # removes props['age']
91
+ friend.jsonb_delete_key("props", "address", "city") # removes props['address']['city']
92
+ friend.jsonb_delete_key_columns("props", "age") # no updated_at touch
93
+ ```
94
+
95
+ **Array operations**
96
+
97
+ ```ruby
98
+ # Append — initializes to [value] if the key doesn't exist
99
+ friend.jsonb_array_append("props", ["tags"], "ruby")
100
+
101
+ # Remove all occurrences — returns [] if the last element is removed
102
+ friend.jsonb_array_remove("props", ["tags"], "ruby")
103
+ ```
104
+
105
+ **Numeric increment / decrement**
106
+
107
+ ```ruby
108
+ friend.jsonb_increment("props", ["score"]) # +1 (default)
109
+ friend.jsonb_increment("props", ["score"], 5) # +5
110
+ friend.jsonb_increment("props", ["score"], -1) # -1 (decrement)
111
+ # Missing key is initialized to 0 before applying the delta
112
+ ```
113
+
114
+ **Batch update**
115
+
116
+ ```ruby
117
+ # Wraps multiple jsonb_update! calls in a single transaction
118
+ Friend.jsonb_batch_update([
119
+ [friend_a, { "props" => { "score" => 10 } }],
120
+ [friend_b, { "props" => { "score" => 20 } }],
121
+ ])
122
+ ```
123
+
124
+ ### GIN index recommendation
125
+
126
+ For best query performance, create a GIN index on your JSONB column. Use `jsonb_gin_index_sql` to get the correct SQL for your migration:
127
+
128
+ ```ruby
129
+ # In a migration:
130
+ execute Friend.jsonb_gin_index_sql(column_name: "props")
131
+ # => CREATE INDEX ON "friends" USING GIN ("props" jsonb_path_ops);
132
+
133
+ # jsonb_path_ops — smaller index, faster for @> (contains) queries (default)
134
+ # jsonb_ops — also supports ?, ?|, ?& key-existence operators
135
+ execute Friend.jsonb_gin_index_sql(column_name: "props", using: :jsonb_ops)
136
+ ```
137
+
138
+ ## Development
139
+
140
+ To run tests you must have [PostgreSQL](https://www.postgresql.org/) installed and create the test database:
141
+
142
+ ```sh
143
+ PGPASSWORD=postgres createdb -U postgres -h localhost rails_psql_jsonb_test
144
+ ```
145
+
146
+ Tests assume PostgreSQL is running on `localhost:5432` with username `postgres` and password `postgres`.
147
+
148
+ After checking out the repo, run `bin/setup` to install dependencies. Then run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt.
149
+
150
+ To release a new version, update `version.rb`, then run `bundle exec rake release` to create a git tag, push commits and the tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
151
+
152
+ ## License
153
+
154
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inspired by https://github.com/antoinemacia/atomic_json
4
+
5
+ require_relative "query_helpers"
6
+
7
+ module RailsPsqlJsonb
8
+ module AtomicUpdate
9
+ extend ActiveSupport::Concern
10
+
11
+ def jsonb_update(input)
12
+ update_query = build_update_query(input.deep_dup, touch: true)
13
+ run_callbacks(:save) do
14
+ self.class.connection.exec_update(update_query)
15
+ reload.validate
16
+ end
17
+ end
18
+
19
+ def jsonb_update!(input)
20
+ update_query = build_update_query(input.deep_dup, touch: true)
21
+ run_callbacks(:save) do
22
+ self.class.connection.exec_update(update_query)
23
+ reload.validate!
24
+ end
25
+ end
26
+
27
+ def jsonb_update_columns(input)
28
+ update_query = build_update_query(input.deep_dup, touch: false)
29
+ self.class.connection.exec_update(update_query)
30
+ end
31
+
32
+ # Atomically removes a key (or nested key path) from a JSONB column.
33
+ # Uses PostgreSQL's #- operator which is safe when the path doesn't exist.
34
+ #
35
+ # Examples:
36
+ # record.jsonb_delete_key("props", "age") # removes props['age']
37
+ # record.jsonb_delete_key("props", "nested", "inner") # removes props['nested']['inner']
38
+ def jsonb_delete_key(column_name, *key_path)
39
+ validate_key_path!(key_path)
40
+ col = validate_record_and_column!(column_name)
41
+ exec_delete_key(col, key_path, touch: true)
42
+ reload
43
+ end
44
+
45
+ def jsonb_delete_key!(column_name, *key_path)
46
+ jsonb_delete_key(column_name, *key_path)
47
+ validate!
48
+ self
49
+ end
50
+
51
+ def jsonb_delete_key_columns(column_name, *key_path)
52
+ validate_key_path!(key_path)
53
+ col = validate_record_and_column!(column_name)
54
+ exec_delete_key(col, key_path, touch: false)
55
+ reload
56
+ end
57
+
58
+ # Atomically appends a value to a JSONB array at the given key path.
59
+ # Initializes to [value] if the key doesn't exist yet.
60
+ #
61
+ # Example:
62
+ # record.jsonb_array_append("props", ["tags"], "ruby")
63
+ def jsonb_array_append(column_name, key_path, value)
64
+ col = validate_record_and_column!(column_name)
65
+ key_path = Array(key_path)
66
+ validate_key_path!(key_path)
67
+
68
+ pg_path = "{#{key_path.map(&:to_s).join(",")}}"
69
+ quoted_col = RailsPsqlJsonb::QueryHelpers.quote_column_name(col)
70
+ quoted_tbl = RailsPsqlJsonb::QueryHelpers.quote_table_name(self.class.table_name)
71
+ quoted_val = RailsPsqlJsonb::QueryHelpers.quote(value.to_json)
72
+
73
+ sql = <<~SQL
74
+ UPDATE #{quoted_tbl}
75
+ SET #{quoted_col} = jsonb_set(
76
+ #{quoted_col}::jsonb,
77
+ '#{pg_path}',
78
+ COALESCE(#{quoted_col} #> '#{pg_path}', '[]'::jsonb) || jsonb_build_array(#{quoted_val}::jsonb)
79
+ )#{optional_touch_sql}
80
+ WHERE id = #{RailsPsqlJsonb::QueryHelpers.quote(id)};
81
+ SQL
82
+ self.class.connection.exec_update(sql)
83
+ reload
84
+ end
85
+
86
+ # Atomically removes all occurrences of value from a JSONB array at the given key path.
87
+ # Returns [] if all elements are removed or the key doesn't exist.
88
+ #
89
+ # Example:
90
+ # record.jsonb_array_remove("props", ["tags"], "ruby")
91
+ def jsonb_array_remove(column_name, key_path, value)
92
+ col = validate_record_and_column!(column_name)
93
+ key_path = Array(key_path)
94
+ validate_key_path!(key_path)
95
+
96
+ pg_path = "{#{key_path.map(&:to_s).join(",")}}"
97
+ quoted_col = RailsPsqlJsonb::QueryHelpers.quote_column_name(col)
98
+ quoted_tbl = RailsPsqlJsonb::QueryHelpers.quote_table_name(self.class.table_name)
99
+ quoted_val = RailsPsqlJsonb::QueryHelpers.quote(value.to_json)
100
+
101
+ sql = <<~SQL
102
+ UPDATE #{quoted_tbl}
103
+ SET #{quoted_col} = jsonb_set(
104
+ #{quoted_col}::jsonb,
105
+ '#{pg_path}',
106
+ COALESCE(
107
+ (SELECT jsonb_agg(e)
108
+ FROM jsonb_array_elements(#{quoted_col} #> '#{pg_path}') AS e
109
+ WHERE e <> #{quoted_val}::jsonb),
110
+ '[]'::jsonb
111
+ )
112
+ )#{optional_touch_sql}
113
+ WHERE id = #{RailsPsqlJsonb::QueryHelpers.quote(id)};
114
+ SQL
115
+ self.class.connection.exec_update(sql)
116
+ reload
117
+ end
118
+
119
+ # Atomically increments (or decrements with negative delta) a numeric JSONB value.
120
+ # Initializes missing keys to 0 before applying the delta.
121
+ #
122
+ # Example:
123
+ # record.jsonb_increment("props", ["score"], 5)
124
+ # record.jsonb_increment("props", ["score"], -1) # decrement
125
+ def jsonb_increment(column_name, key_path, delta = 1)
126
+ raise TypeError, "delta must be Numeric, got #{delta.class}" unless delta.is_a?(Numeric)
127
+ col = validate_record_and_column!(column_name)
128
+ key_path = Array(key_path)
129
+ validate_key_path!(key_path)
130
+
131
+ pg_path = "{#{key_path.map(&:to_s).join(",")}}"
132
+ quoted_col = RailsPsqlJsonb::QueryHelpers.quote_column_name(col)
133
+ quoted_tbl = RailsPsqlJsonb::QueryHelpers.quote_table_name(self.class.table_name)
134
+
135
+ sql = <<~SQL
136
+ UPDATE #{quoted_tbl}
137
+ SET #{quoted_col} = jsonb_set(
138
+ #{quoted_col}::jsonb,
139
+ '#{pg_path}',
140
+ to_jsonb(COALESCE((#{quoted_col} #>> '#{pg_path}')::numeric, 0) + #{RailsPsqlJsonb::QueryHelpers.quote(delta)})
141
+ )#{optional_touch_sql}
142
+ WHERE id = #{RailsPsqlJsonb::QueryHelpers.quote(id)};
143
+ SQL
144
+ self.class.connection.exec_update(sql)
145
+ reload
146
+ end
147
+
148
+ private
149
+
150
+ def validate_key_path!(key_path)
151
+ raise ArgumentError, "key_path must not be empty" if key_path.empty?
152
+ end
153
+
154
+ # Returns ", updated_at = '...'" when the model has an updated_at column, else "".
155
+ # Pass touch: false to suppress the timestamp even when the column exists.
156
+ def optional_touch_sql(touch: true)
157
+ return "" unless touch && has_attribute?(:updated_at)
158
+ ", #{timestamp_update_string}"
159
+ end
160
+
161
+ # Validates that the record is persisted and the column is a JSONB column.
162
+ # Returns the resolved (alias-expanded) column name.
163
+ def validate_record_and_column!(column_name)
164
+ raise RailsPsqlJsonb::Errors::ActiveRecordError, "cannot update a new record" if new_record?
165
+ raise RailsPsqlJsonb::Errors::ActiveRecordError, "cannot update a destroyed record" if destroyed?
166
+ col = RailsPsqlJsonb::QueryHelpers.db_column_name(self.class, column_name.to_s)
167
+ RailsPsqlJsonb::QueryHelpers.validate_column_name!(self.class, col)
168
+ col
169
+ end
170
+
171
+ def exec_delete_key(col, key_path, touch:)
172
+ quoted_col = RailsPsqlJsonb::QueryHelpers.quote_column_name(col)
173
+ quoted_tbl = RailsPsqlJsonb::QueryHelpers.quote_table_name(self.class.table_name)
174
+ path_array = key_path.map { |k| RailsPsqlJsonb::QueryHelpers.quote(k.to_s) }.join(", ")
175
+ sql = "UPDATE #{quoted_tbl} SET #{quoted_col} = #{quoted_col} #- ARRAY[#{path_array}]#{optional_touch_sql(touch: touch)} WHERE id = #{RailsPsqlJsonb::QueryHelpers.quote(id)};"
176
+ self.class.connection.exec_update(sql)
177
+ end
178
+
179
+ def build_update_query(input, touch: false)
180
+ RailsPsqlJsonb::QueryHelpers.validate_atomic_update!(self, input)
181
+
182
+ <<~SQL
183
+ UPDATE #{RailsPsqlJsonb::QueryHelpers.quote_table_name(self.class.table_name)}
184
+ SET #{build_set_subquery(input, touch)}
185
+ WHERE id = #{RailsPsqlJsonb::QueryHelpers.quote(self.id)};
186
+ SQL
187
+ end
188
+
189
+ def build_set_subquery(attributes, touch)
190
+ updates = json_updates_agg(attributes)
191
+ updates << timestamp_update_string if touch && has_attribute?(:updated_at)
192
+ updates.join(',')
193
+ end
194
+
195
+ def json_updates_agg(attributes)
196
+ attributes.map do |column, payload|
197
+ "#{RailsPsqlJsonb::QueryHelpers.quote_column_name(column)} = #{json_deep_merge(column, payload)}"
198
+ end
199
+ end
200
+
201
+ def timestamp_update_string
202
+ "#{RailsPsqlJsonb::QueryHelpers.quote_column_name(:updated_at)} = #{RailsPsqlJsonb::QueryHelpers.quote(Time.now)}"
203
+ end
204
+
205
+ def json_deep_merge(target, payload)
206
+ loop do
207
+ keys, value = traverse_payload(Hash[*payload.shift])
208
+ target = jsonb_set_query_string(target, keys, value)
209
+ break target if payload.empty?
210
+ end
211
+ end
212
+
213
+ # Returns [key_path, leaf_value] by walking nested single-key hashes.
214
+ def traverse_payload(key_value_pair, keys = [])
215
+ loop do
216
+ key, val = key_value_pair.flatten
217
+ keys << key.to_s
218
+ break [keys, val] unless single_value_hash?(val)
219
+ key_value_pair = val
220
+ end
221
+ end
222
+
223
+ def jsonb_set_query_string(target, keys, value)
224
+ <<~EOF
225
+ jsonb_set(
226
+ #{target}::jsonb,
227
+ #{RailsPsqlJsonb::QueryHelpers.quote_jsonb_keys(keys)},
228
+ #{multi_value_hash?(value) ? RailsPsqlJsonb::QueryHelpers.concatenation(target, keys, value) : RailsPsqlJsonb::QueryHelpers.quote_jsonb_value(value)}
229
+ )::jsonb
230
+ EOF
231
+ end
232
+
233
+ def multi_value_hash?(value)
234
+ value.is_a?(Hash) && value.keys.count > 1
235
+ end
236
+
237
+ def single_value_hash?(value)
238
+ value.is_a?(Hash) && value.keys.count == 1
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPsqlJsonb
4
+ module Errors
5
+ class InvalidColumnName < StandardError
6
+ def initialize(table_name:, column_name:)
7
+ super("Table #{table_name} does not have jsonb column name #{column_name}")
8
+ end
9
+ end
10
+
11
+ class InvalidOperator < StandardError
12
+ def initialize(value:)
13
+ super("Invalid operator #{value}")
14
+ end
15
+ end
16
+
17
+ class InvalidOrder < StandardError
18
+ def initialize(value:)
19
+ super("only `asc` or `desc` can be used for ordering, got: #{value}")
20
+ end
21
+ end
22
+
23
+ class ReadOnlyAttribute < StandardError
24
+ def initialize(attribute:)
25
+ super("#{attribute} is marked as readonly")
26
+ end
27
+ end
28
+
29
+ class NoOrderKey < StandardError
30
+ def initialize
31
+ super("order json_keys must be a non-empty array")
32
+ end
33
+ end
34
+
35
+ class ActiveRecordError < StandardError
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "quoting"
5
+ require "json"
6
+
7
+ module RailsPsqlJsonb
8
+ module QueryHelpers
9
+ OPERATORS_MAP = {
10
+ :gt => ">",
11
+ "gt" => ">",
12
+ ">" => ">",
13
+ :> => ">",
14
+ :lt => "<",
15
+ "lt" => "<",
16
+ "<" => "<",
17
+ :< => "<",
18
+ :gte => ">=",
19
+ "gte" => ">=",
20
+ :>= => ">=",
21
+ ">=" => ">=",
22
+ :lte => "<=",
23
+ "lte" => "<=",
24
+ :<= => "<=",
25
+ "<=" => "<=",
26
+ :eq => "=",
27
+ "eq" => "=",
28
+ :"=" => "=",
29
+ "=" => "=",
30
+ :contains => "@>",
31
+ "contains" => "@>",
32
+ :"@>" => "@>",
33
+ "@>" => "@>",
34
+ :exists => "?",
35
+ "exists" => "?",
36
+ :"?" => "?",
37
+ "?" => "?",
38
+ :exists_any => "?|",
39
+ "exists_any" => "?|",
40
+ :"?|" => "?|",
41
+ "?|" => "?|",
42
+ :exists_all => "?&",
43
+ "exists_all" => "?&",
44
+ :"?&" => "?&",
45
+ "?&" => "?&"
46
+ }.freeze
47
+
48
+ def self.numeric_operator?(query_operator)
49
+ [">", "<", ">=", "<="].include?(query_operator)
50
+ end
51
+
52
+ def self.existence_operator?(query_operator)
53
+ ["?", "?|", "?&"].include?(query_operator)
54
+ end
55
+
56
+ def self.validate_operator!(operator)
57
+ raise RailsPsqlJsonb::Errors::InvalidOperator.new(value: operator) if !OPERATORS_MAP.key?(operator)
58
+ end
59
+
60
+ def self.validate_column_name!(ar_model, column_name)
61
+ raise RailsPsqlJsonb::Errors::InvalidColumnName.new(table_name: ar_model.table_name, column_name:) if !ar_model.column_names.include?(column_name.to_s) || ar_model.type_for_attribute(column_name).type != :jsonb
62
+ end
63
+
64
+ def self.validate_ordering!(value)
65
+ raise RailsPsqlJsonb::Errors::InvalidOrder.new(value: value) if ![:asc, :desc, "asc", "desc"].include?(value)
66
+ end
67
+
68
+ def self.validate_json_keys_for_ordering!(json_keys)
69
+ raise RailsPsqlJsonb::Errors::NoOrderKey unless json_keys.is_a?(Array) && !json_keys.empty?
70
+ end
71
+
72
+ def self.validate_atomic_update!(record, input)
73
+ raise RailsPsqlJsonb::Errors::ActiveRecordError, "cannot update a new record" if record.new_record?
74
+ raise RailsPsqlJsonb::Errors::ActiveRecordError, "cannot update a destroyed record" if record.destroyed?
75
+
76
+ raise TypeError, "Atomic update input must be a hash" unless input.is_a?(Hash)
77
+
78
+ input.each do |key, payload|
79
+ raise RailsPsqlJsonb::Errors::ReadOnlyAttribute.new(attribute: key) if record.class.readonly_attributes.include?(key.to_s)
80
+
81
+ validate_column_name!(record.class, db_column_name(record.class, key))
82
+
83
+ raise ArgumentError, "payload for column #{key} must not be empty" if payload.is_a?(Hash) && payload.empty?
84
+ end
85
+ end
86
+
87
+ def self.db_column_name(ar_model, column_name)
88
+ ar_model.attribute_alias?(column_name) ? ar_model.attribute_alias(column_name) : column_name
89
+ end
90
+
91
+ def self.quote(value)
92
+ RailsPsqlJsonb::Quoting.quote(value)
93
+ end
94
+
95
+ def self.quote_column_name(value)
96
+ RailsPsqlJsonb::Quoting.quote_column_name(value)
97
+ end
98
+
99
+ def self.quote_table_name(value)
100
+ RailsPsqlJsonb::Quoting.quote_table_name(value)
101
+ end
102
+
103
+ def self.quote_jsonb_value(value)
104
+ %('#{value.to_json}')
105
+ end
106
+
107
+ def self.quote_jsonb_keys(keys)
108
+ "'{#{keys.map(&:to_s).join(',')}}'"
109
+ end
110
+
111
+ def self.concatenation(target, keys, value)
112
+ "#{target}->#{keys.map { |x| quote(x) }.join('->')} || #{quote_jsonb_value(value)}"
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query_helpers"
4
+ require "json"
5
+
6
+ module RailsPsqlJsonb
7
+ module Querying
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ def jsonb_where(column_name:, operator:, value:, force_value_type: nil, json_keys: [], exclude: false)
12
+ _resolved, quoted_column = resolve_jsonb_column(column_name)
13
+ RailsPsqlJsonb::QueryHelpers.validate_operator!(operator)
14
+
15
+ query_operator = RailsPsqlJsonb::QueryHelpers::OPERATORS_MAP[operator]
16
+
17
+ if RailsPsqlJsonb::QueryHelpers.existence_operator?(query_operator)
18
+ query_clause = build_existence_clause(quoted_column, query_operator, value, json_keys)
19
+ return exclude ? where.not(query_clause) : where(query_clause)
20
+ end
21
+
22
+ is_numeric = RailsPsqlJsonb::QueryHelpers.numeric_operator?(query_operator)
23
+ # Use text-extraction operators (->> / #>>) for numeric comparisons when json_keys
24
+ # are present: extracts as text then casts to float, which is the idiomatic PG approach.
25
+ use_text_extraction = is_numeric && force_value_type.nil? && !json_keys.empty?
26
+
27
+ lhs_raw = build_lhs_expression(quoted_column, json_keys, text_extraction: use_text_extraction)
28
+
29
+ query_clause = if use_text_extraction
30
+ "(#{lhs_raw})::float #{query_operator} #{RailsPsqlJsonb::QueryHelpers.quote(value)}"
31
+ else
32
+ query_rhs = RailsPsqlJsonb::QueryHelpers.quote(is_numeric ? value : value.to_json)
33
+ cast_type = force_value_type || (is_numeric ? "float" : "jsonb")
34
+ "(#{lhs_raw})::#{cast_type} #{query_operator} (#{query_rhs})::#{cast_type}"
35
+ end
36
+
37
+ exclude ? where.not(query_clause) : where(query_clause)
38
+ end
39
+
40
+ def jsonb_where_not(column_name:, operator:, value:, force_value_type: nil, json_keys: [])
41
+ jsonb_where(column_name:, operator:, value:, force_value_type:, json_keys:, exclude: true)
42
+ end
43
+
44
+ def jsonb_order(column_name:, json_keys:, direction:)
45
+ _resolved, quoted_column = resolve_jsonb_column(column_name)
46
+ RailsPsqlJsonb::QueryHelpers.validate_json_keys_for_ordering!(json_keys)
47
+ RailsPsqlJsonb::QueryHelpers.validate_ordering!(direction)
48
+
49
+ lhs = build_lhs_expression(quoted_column, json_keys)
50
+ nulls_clause = direction.to_s.downcase == "asc" ? "NULLS LAST" : "NULLS FIRST"
51
+ order(Arel.sql("(#{lhs}) #{direction} #{nulls_clause}"))
52
+ end
53
+
54
+ def jsonb_where_exists(column_name:, key:, json_keys: [], exclude: false)
55
+ jsonb_where(column_name:, operator: :exists, value: key, json_keys:, exclude:)
56
+ end
57
+
58
+ def jsonb_where_exists_any(column_name:, keys:, json_keys: [], exclude: false)
59
+ jsonb_where(column_name:, operator: :exists_any, value: keys, json_keys:, exclude:)
60
+ end
61
+
62
+ def jsonb_where_exists_all(column_name:, keys:, json_keys: [], exclude: false)
63
+ jsonb_where(column_name:, operator: :exists_all, value: keys, json_keys:, exclude:)
64
+ end
65
+
66
+ # Returns the SQL to create a GIN index on a JSONB column for use in a migration:
67
+ # execute MyModel.jsonb_gin_index_sql(column_name: "props")
68
+ #
69
+ # using: :jsonb_path_ops — smaller index, faster for @> (contains) queries
70
+ # using: :jsonb_ops — default GIN, also supports ?, ?|, ?& key-existence operators
71
+ def jsonb_gin_index_sql(column_name:, using: :jsonb_path_ops)
72
+ table = connection.quote_table_name(table_name)
73
+ col = connection.quote_column_name(column_name.to_s)
74
+ "CREATE INDEX ON #{table} USING GIN (#{col} #{using});"
75
+ end
76
+
77
+ def jsonb_batch_update(records_and_payloads)
78
+ transaction do
79
+ records_and_payloads.each do |record, input|
80
+ record.jsonb_update!(input)
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def resolve_jsonb_column(column_name)
88
+ resolved = RailsPsqlJsonb::QueryHelpers.db_column_name(self, column_name)
89
+ RailsPsqlJsonb::QueryHelpers.validate_column_name!(self, resolved)
90
+ quoted = "#{RailsPsqlJsonb::QueryHelpers.quote_table_name(table_name)}.#{RailsPsqlJsonb::QueryHelpers.quote_column_name(resolved)}"
91
+ [resolved, quoted]
92
+ end
93
+
94
+ # Builds the LHS SQL expression for a JSONB path.
95
+ # For multi-key paths uses #> / #>> (path operators) instead of chained ->.
96
+ # text_extraction: true uses ->> / #>> (returns text, used before numeric cast).
97
+ def build_lhs_expression(quoted_column, json_keys, text_extraction: false)
98
+ return quoted_column if json_keys.empty?
99
+ if json_keys.length == 1
100
+ op = text_extraction ? "->>" : "->"
101
+ "#{quoted_column} #{op} #{RailsPsqlJsonb::QueryHelpers.quote(json_keys.first.to_s)}"
102
+ else
103
+ path = json_keys.map(&:to_s).join(",")
104
+ op = text_extraction ? "#>>" : "#>"
105
+ "#{quoted_column} #{op} '{#{path}}'"
106
+ end
107
+ end
108
+
109
+ def build_existence_clause(quoted_column, operator, value, json_keys)
110
+ case operator
111
+ when "?"
112
+ unless value.is_a?(String) || value.is_a?(Symbol)
113
+ raise TypeError, "value for ? (exists) operator must be a String or Symbol, got #{value.class}"
114
+ end
115
+ when "?|", "?&"
116
+ raise TypeError, "value for #{operator} operator must be an Array, got #{value.class}" unless value.is_a?(Array)
117
+ end
118
+
119
+ lhs = build_lhs_expression(quoted_column, json_keys)
120
+
121
+ case operator
122
+ when "?"
123
+ "#{lhs} ? #{RailsPsqlJsonb::QueryHelpers.quote(value.to_s)}"
124
+ when "?|", "?&"
125
+ keys = Array(value).map { |k| RailsPsqlJsonb::QueryHelpers.quote(k.to_s) }.join(", ")
126
+ "#{lhs} #{operator} ARRAY[#{keys}]"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal copy of ActiveRecord's PostgreSQL quoting module.
4
+ # Clone of: https://github.com/rails/rails/blob/5bec50bc70380bb1e70e8fb0a1654130042b1f16/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb
5
+ #
6
+ # Used as a module so we don't need ActiveRecord::Base.connection.quote, which
7
+ # requires an active database connection even though quoting itself doesn't need one.
8
+ #
9
+ # A fix is available in ActiveRecord 7.2:
10
+ # https://github.com/rails/rails/commit/0016280f4fde55d96738887093dc333aae0d107b
11
+ # TODO: remove this module when minimum supported ActiveRecord version is >= 7.2.
12
+
13
+ module RailsPsqlJsonb
14
+ module Quoting
15
+
16
+ class IntegerOutOf64BitRange < StandardError
17
+ def initialize(msg)
18
+ super(msg)
19
+ end
20
+ end
21
+
22
+ def self.quote_column_name(name)
23
+ "\"#{name}\""
24
+ end
25
+
26
+ def self.quote_table_name(name)
27
+ "\"#{name}\""
28
+ end
29
+
30
+ def self.check_int_in_range(value)
31
+ if value.to_int > 9223372036854775807 || value.to_int < -9223372036854775808
32
+ exception = <<~ERROR
33
+ Provided value outside of the range of a signed 64bit integer.
34
+
35
+ PostgreSQL will treat the column type in question as a numeric.
36
+ This may result in a slow sequential scan due to a comparison
37
+ being performed between an integer or bigint value and a numeric value.
38
+
39
+ To allow for this potentially unwanted behavior, set
40
+ ActiveRecord.raise_int_wider_than_64bit to false.
41
+ ERROR
42
+ raise IntegerOutOf64BitRange.new exception
43
+ end
44
+ end
45
+
46
+ def self.quote(value)
47
+ if ActiveRecord.raise_int_wider_than_64bit && value.is_a?(Integer)
48
+ check_int_in_range(value)
49
+ end
50
+
51
+ case value
52
+ when Numeric
53
+ if value.finite?
54
+ value.to_s
55
+ else
56
+ "'#{value}'"
57
+ end
58
+ when Range
59
+ quote(encode_range(value))
60
+ when String, Symbol, ActiveSupport::Multibyte::Chars
61
+ "'#{quote_string(value.to_s)}'"
62
+ when true then "TRUE"
63
+ when false then "FALSE"
64
+ when nil then "NULL"
65
+ when BigDecimal then value.to_s("F")
66
+ when Date, Time then "'#{quoted_date(value)}'"
67
+ when Class then "'#{value}'"
68
+ else raise TypeError, "can't quote #{value.class.name}"
69
+ end
70
+ end
71
+
72
+ def self.quote_string(s)
73
+ s.gsub("\\", '\&\&').gsub("'", "''")
74
+ end
75
+
76
+ def self.quoted_date(value)
77
+ if value.acts_like?(:time)
78
+ if ActiveRecord.default_timezone == :utc
79
+ value = value.getutc if !value.utc?
80
+ else
81
+ value = value.getlocal
82
+ end
83
+ end
84
+
85
+ result = value.to_fs(:db)
86
+ if value.respond_to?(:usec) && value.usec > 0
87
+ result << "." << sprintf("%06d", value.usec)
88
+ else
89
+ result
90
+ end
91
+ end
92
+
93
+ def self.encode_range(range)
94
+ "[#{type_cast_range_value(range.begin)},#{type_cast_range_value(range.end)}#{range.exclude_end? ? ')' : ']'}"
95
+ end
96
+
97
+ def self.type_cast_range_value(value)
98
+ return "" if infinity?(value)
99
+ case value
100
+ when Rational then value.to_f
101
+ when Symbol, ActiveSupport::Multibyte::Chars then value.to_s
102
+ when true then true
103
+ when false then false
104
+ when BigDecimal then value.to_s("F")
105
+ when nil, Numeric, String then value
106
+ when Date, Time then quoted_date(value)
107
+ else raise TypeError, "can't cast #{value.class.name}"
108
+ end
109
+ end
110
+
111
+ def self.infinity?(value)
112
+ value.respond_to?(:infinite?) && value.infinite?
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsPsqlJsonb
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_record"
5
+ require "active_record/connection_adapters/postgresql_adapter"
6
+
7
+ require_relative "rails_psql_jsonb/version"
8
+ require_relative "rails_psql_jsonb/errors"
9
+ require_relative "rails_psql_jsonb/quoting"
10
+ require_relative "rails_psql_jsonb/query_helpers"
11
+ require_relative "rails_psql_jsonb/querying"
12
+ require_relative "rails_psql_jsonb/atomic_update"
13
+
14
+ module RailsPsqlJsonb
15
+ extend ActiveSupport::Concern
16
+ include RailsPsqlJsonb::Querying
17
+ include RailsPsqlJsonb::AtomicUpdate
18
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_psql_jsonb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - bubiche
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-04-06 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '6.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '6.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: pg
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.5.6
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 1.5.6
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: database_cleaner-active_record
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.1'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.1'
96
+ description: Provides jsonb_where, jsonb_order, and atomic update methods (jsonb_update!,
97
+ jsonb_delete_key, jsonb_array_append, jsonb_increment, and more) for PostgreSQL
98
+ JSONB columns in Rails ActiveRecord. Generates safe, type-cast SQL with no lost-update
99
+ race conditions under concurrency.
100
+ email:
101
+ - bubiche95@gmail.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - ".rspec"
107
+ - CHANGELOG.md
108
+ - Gemfile
109
+ - Gemfile.lock
110
+ - LICENSE.txt
111
+ - README.md
112
+ - Rakefile
113
+ - lib/rails_psql_jsonb.rb
114
+ - lib/rails_psql_jsonb/atomic_update.rb
115
+ - lib/rails_psql_jsonb/errors.rb
116
+ - lib/rails_psql_jsonb/query_helpers.rb
117
+ - lib/rails_psql_jsonb/querying.rb
118
+ - lib/rails_psql_jsonb/quoting.rb
119
+ - lib/rails_psql_jsonb/version.rb
120
+ homepage: https://github.com/bubiche/rails_psql_jsonb
121
+ licenses:
122
+ - MIT
123
+ metadata:
124
+ homepage_uri: https://github.com/bubiche/rails_psql_jsonb
125
+ source_code_uri: https://github.com/bubiche/rails_psql_jsonb
126
+ changelog_uri: https://github.com/bubiche/rails_psql_jsonb/blob/main/CHANGELOG.md
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '3'
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubygems_version: 3.6.2
142
+ specification_version: 4
143
+ summary: ActiveRecord helpers for querying and atomically updating PostgreSQL JSONB
144
+ columns.
145
+ test_files: []