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 +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +30 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +81 -0
- data/LICENSE.txt +21 -0
- data/README.md +154 -0
- data/Rakefile +8 -0
- data/lib/rails_psql_jsonb/atomic_update.rb +241 -0
- data/lib/rails_psql_jsonb/errors.rb +38 -0
- data/lib/rails_psql_jsonb/query_helpers.rb +115 -0
- data/lib/rails_psql_jsonb/querying.rb +131 -0
- data/lib/rails_psql_jsonb/quoting.rb +115 -0
- data/lib/rails_psql_jsonb/version.rb +5 -0
- data/lib/rails_psql_jsonb.rb +18 -0
- metadata +145 -0
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
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
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,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,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: []
|