pg_any_where 0.1.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/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +178 -0
- data/lib/pg_any_where/arel_visitor.rb +168 -0
- data/lib/pg_any_where/configuration.rb +64 -0
- data/lib/pg_any_where/railtie.rb +12 -0
- data/lib/pg_any_where/version.rb +5 -0
- data/lib/pg_any_where.rb +89 -0
- metadata +188 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a825cb6b120563bf415db84e4796acd7c4d5e370e5bf617d4b034f0721bf315a
|
|
4
|
+
data.tar.gz: 7f3fd236b46cd43e9721bdc8eb276056e3045f9793d6c8356e9c30683e202d65
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 97649e2bc9817fed522c8f90e2604489b7f09a8678776caa7ccaad0ef578ab43c463dcb6a2abe051ae2cf9320a1284f35a925d5367644034da0f80c96cce6bfd
|
|
7
|
+
data.tar.gz: '08b888d8b14851f5e4ef839ef9201931078601fdfe773349ea872fb552b8a3651f94f106673dc46f52d2b9468e88fe2add8fad3da10ab384adfdee7294f7cd61'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-06-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release.
|
|
14
|
+
- Rewrites `WHERE column IN (…)` to `WHERE column = ANY($1::type[])`.
|
|
15
|
+
- Rewrites `WHERE column NOT IN (…)` to `WHERE column != ALL($1::type[])`.
|
|
16
|
+
- Handles empty-array edge cases: `ANY(ARRAY[]::type[])` / `!= ALL(ARRAY[]::type[])`.
|
|
17
|
+
- Block-style `PgAnyWhere.configure` API.
|
|
18
|
+
- ENV variable support: `PG_ANY_WHERE_ENABLED` and `PG_ANY_WHERE_MIN_ARRAY_SIZE`.
|
|
19
|
+
- `min_array_size` threshold — fall back to IN for small arrays if desired.
|
|
20
|
+
- Rails Railtie for zero-configuration setup.
|
|
21
|
+
- RSpec test suite with SimpleCov coverage enforcement (≥ 95 %).
|
|
22
|
+
|
|
23
|
+
[Unreleased]: https://github.com/aimanabutalaah/pg_any_where/compare/v0.1.0...HEAD
|
|
24
|
+
[0.1.0]: https://github.com/aimanabutalaah/pg_any_where/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aiman Abutalaah
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# pg_any_where
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/pg_any_where)
|
|
4
|
+
[](https://github.com/aimanabutalaah/pg_any_where/actions)
|
|
5
|
+
[]()
|
|
6
|
+
[](LICENSE.txt)
|
|
7
|
+
|
|
8
|
+
> **One gem. One bind parameter. Zero plan-cache pollution.**
|
|
9
|
+
|
|
10
|
+
`pg_any_where` patches ActiveRecord so that `where(column: array)` emits
|
|
11
|
+
PostgreSQL's array operators instead of `IN (…)`:
|
|
12
|
+
|
|
13
|
+
```sql
|
|
14
|
+
-- Before (standard ActiveRecord)
|
|
15
|
+
SELECT * FROM widgets WHERE id IN ($1, $2, $3)
|
|
16
|
+
|
|
17
|
+
-- After (pg_any_where)
|
|
18
|
+
SELECT * FROM widgets WHERE id = ANY($1::integer[])
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A 1 000-element array still produces **a single bind parameter**, keeping your
|
|
22
|
+
prepared-statement cache sane and `pg_stat_statements` readable.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Why?
|
|
27
|
+
|
|
28
|
+
PostgreSQL's query planner caches plans keyed on the *shape* of a prepared
|
|
29
|
+
statement, not its bind values. A standard `IN ($1, $2, …)` generates a new
|
|
30
|
+
cache entry for *every distinct array length*, causing:
|
|
31
|
+
|
|
32
|
+
- plan-cache bloat
|
|
33
|
+
- `pg_stat_statements` pollution (thousands of near-identical rows)
|
|
34
|
+
- occasional re-planning overhead on busy servers
|
|
35
|
+
|
|
36
|
+
`= ANY($1::type[])` fixes all three: **one shape, one plan, cached forever**.
|
|
37
|
+
|
|
38
|
+
> Further reading: [rails/rails#49388](https://github.com/rails/rails/pull/49388)
|
|
39
|
+
|
|
40
|
+
### Empty-array edge cases
|
|
41
|
+
|
|
42
|
+
| Standard ActiveRecord | `pg_any_where` |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `WHERE 1=0` (empty `IN`) | `WHERE col = ANY(ARRAY[]::integer[])` |
|
|
45
|
+
| `WHERE 1=1` (empty `NOT IN`) | `WHERE col != ALL(ARRAY[]::integer[])` |
|
|
46
|
+
|
|
47
|
+
The footguns are gone. Empty arrays behave correctly and symmetrically.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Add to your `Gemfile`:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
gem "pg_any_where"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then run:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
bundle install
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Requirements
|
|
66
|
+
|
|
67
|
+
| Dependency | Version |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Ruby | ≥ 3.0 |
|
|
70
|
+
| ActiveRecord | ≥ 6.1 |
|
|
71
|
+
| PostgreSQL adapter (`pg`) | ≥ 1.2 |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
### Rails (automatic)
|
|
78
|
+
|
|
79
|
+
The gem ships a **Railtie** — no initializer code required. Simply add it to
|
|
80
|
+
your `Gemfile` and restart your server. Done.
|
|
81
|
+
|
|
82
|
+
To verify it is active:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
Widget.where(id: [1, 2, 3]).to_sql
|
|
86
|
+
# => "SELECT ... WHERE \"widgets\".\"id\" = ANY($1::integer[])"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Non-Rails
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
require "pg_any_where"
|
|
93
|
+
|
|
94
|
+
ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])
|
|
95
|
+
PgAnyWhere.patch! # call once after connecting
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
### Block-style (recommended)
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# config/initializers/pg_any_where.rb
|
|
106
|
+
PgAnyWhere.configure do |config|
|
|
107
|
+
# Disable the extension entirely (default: true).
|
|
108
|
+
config.enabled = true
|
|
109
|
+
|
|
110
|
+
# Only rewrite arrays with at least N elements; fall back to IN for smaller
|
|
111
|
+
# ones. Useful if you want IN for 1- or 2-element lists where the planner
|
|
112
|
+
# might prefer an index seek with literal values. (default: 0)
|
|
113
|
+
config.min_array_size = 0
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Environment variables
|
|
118
|
+
|
|
119
|
+
| Variable | Default | Description |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `PG_ANY_WHERE_ENABLED` | `"true"` | Set to `"false"` to disable |
|
|
122
|
+
| `PG_ANY_WHERE_MIN_ARRAY_SIZE` | `"0"` | Minimum array size to rewrite |
|
|
123
|
+
|
|
124
|
+
ENV values act as **defaults** — an explicit Ruby config call always wins.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## SQL reference
|
|
129
|
+
|
|
130
|
+
| ActiveRecord | SQL emitted |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `where(col: [1, 2, 3])` | `col = ANY($1::integer[])` |
|
|
133
|
+
| `where(col: [])` | `col = ANY(ARRAY[]::integer[])` |
|
|
134
|
+
| `where.not(col: [1, 2])` | `col != ALL($1::integer[])` |
|
|
135
|
+
| `where.not(col: [])` | `col != ALL(ARRAY[]::integer[])` |
|
|
136
|
+
| `where(str_col: %w[a b])` | `str_col = ANY($1::character varying[])` |
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Compatibility notes
|
|
141
|
+
|
|
142
|
+
### Ransack / raw Arel
|
|
143
|
+
|
|
144
|
+
Ransack and manual `attribute.in([…])` calls build `Arel::Nodes::In` nodes
|
|
145
|
+
whose values are already-quoted `Arel::Nodes::Node` objects. Re-casting those
|
|
146
|
+
would produce an empty array, so **pg_any_where deliberately leaves them
|
|
147
|
+
unchanged**. Your Ransack queries continue to work exactly as before.
|
|
148
|
+
|
|
149
|
+
### `min_array_size`
|
|
150
|
+
|
|
151
|
+
When `min_array_size > 0`, arrays shorter than the threshold fall back to the
|
|
152
|
+
standard IN / NOT IN behaviour — including the `1=0` / `1=1` edge cases for
|
|
153
|
+
empty arrays.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Contributing
|
|
158
|
+
|
|
159
|
+
1. Fork the repository.
|
|
160
|
+
2. Create a feature branch (`git checkout -b feature/my-change`).
|
|
161
|
+
3. Run the test suite (`bundle exec rspec`).
|
|
162
|
+
4. Run the linter (`bundle exec rubocop`).
|
|
163
|
+
5. Open a pull request.
|
|
164
|
+
|
|
165
|
+
### Running tests locally
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
createdb pg_any_where_test
|
|
169
|
+
DATABASE_URL=postgres://localhost/pg_any_where_test bundle exec rspec
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Coverage is enforced at **≥ 95 %** via SimpleCov.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arel
|
|
4
|
+
module Visitors
|
|
5
|
+
# Arel visitor extension that rewrites IN / NOT IN predicates for PostgreSQL
|
|
6
|
+
# to use the array operators ANY and ALL.
|
|
7
|
+
#
|
|
8
|
+
# Prepended onto +Arel::Visitors::PostgreSQL+ via +PgAnyWhere.patch!+.
|
|
9
|
+
#
|
|
10
|
+
# SQL shape produced:
|
|
11
|
+
#
|
|
12
|
+
# -- non-empty, inclusive
|
|
13
|
+
# "table"."column" = ANY($1::integer[])
|
|
14
|
+
#
|
|
15
|
+
# -- empty, inclusive (no 1=0 footgun)
|
|
16
|
+
# "table"."column" = ANY(ARRAY[]::integer[])
|
|
17
|
+
#
|
|
18
|
+
# -- non-empty, exclusive
|
|
19
|
+
# "table"."column" != ALL($1::integer[])
|
|
20
|
+
#
|
|
21
|
+
# -- empty, exclusive (no 1=1 footgun)
|
|
22
|
+
# "table"."column" != ALL(ARRAY[]::integer[])
|
|
23
|
+
#
|
|
24
|
+
# rubocop:disable Naming/MethodName -- Arel visitor method names mirror node class names
|
|
25
|
+
module PgAnyWhereVisitor
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Rails 6.1+ emits HomogeneousIn for +where(col: [1, 2, 3])+ when all
|
|
29
|
+
# values are of the same Ruby class.
|
|
30
|
+
def visit_Arel_Nodes_HomogeneousIn(o, collector)
|
|
31
|
+
return super unless array_membership_applicable?(o.attribute, o.values)
|
|
32
|
+
|
|
33
|
+
emit_array_membership(
|
|
34
|
+
o.left,
|
|
35
|
+
o.casted_values,
|
|
36
|
+
o.type == :in,
|
|
37
|
+
o.attribute,
|
|
38
|
+
collector
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Handles +Arel::Nodes::In+ — including empty-array cases produced by
|
|
43
|
+
# +where(col: [])+ and heterogeneous arrays.
|
|
44
|
+
#
|
|
45
|
+
# NOTE: Ransack (and raw Arel +.in+ calls) build In nodes whose +right+
|
|
46
|
+
# side contains +Arel::Nodes::Node+ children (e.g. Quoted / Casted).
|
|
47
|
+
# Re-casting those is unsafe, so we leave them to the default visitor.
|
|
48
|
+
def visit_Arel_Nodes_In(o, collector)
|
|
49
|
+
attr = o.left
|
|
50
|
+
values = o.right
|
|
51
|
+
|
|
52
|
+
if values.is_a?(Array) && convertible_in_list?(attr, values)
|
|
53
|
+
values.delete_if { |v| unboundable?(v) } unless values.empty?
|
|
54
|
+
|
|
55
|
+
return emit_array_membership(
|
|
56
|
+
attr,
|
|
57
|
+
cast_values_for_attribute(attr, values),
|
|
58
|
+
true,
|
|
59
|
+
attr,
|
|
60
|
+
collector
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Handles +Arel::Nodes::NotIn+ — used by +where.not(col: [1, 2])+.
|
|
68
|
+
def visit_Arel_Nodes_NotIn(o, collector)
|
|
69
|
+
attr = o.left
|
|
70
|
+
values = o.right
|
|
71
|
+
|
|
72
|
+
if values.is_a?(Array) && convertible_in_list?(attr, values)
|
|
73
|
+
values.delete_if { |v| unboundable?(v) } unless values.empty?
|
|
74
|
+
|
|
75
|
+
return emit_array_membership(
|
|
76
|
+
attr,
|
|
77
|
+
cast_values_for_attribute(attr, values),
|
|
78
|
+
false,
|
|
79
|
+
attr,
|
|
80
|
+
collector
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
super
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# -----------------------------------------------------------------------
|
|
88
|
+
# Predicate helpers
|
|
89
|
+
# -----------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def array_membership_applicable?(attribute, values)
|
|
92
|
+
::PgAnyWhere.configuration.use_for_values?(values) &&
|
|
93
|
+
attribute.respond_to?(:type_caster)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# In nodes that contain Arel::Nodes::Node children (Ransack, raw Arel)
|
|
97
|
+
# must not be converted — re-casting those produces an empty array.
|
|
98
|
+
def convertible_in_list?(attribute, values)
|
|
99
|
+
array_membership_applicable?(attribute, values) &&
|
|
100
|
+
(values.empty? || values.none? { |v| v.is_a?(Arel::Nodes::Node) })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# -----------------------------------------------------------------------
|
|
104
|
+
# SQL emission
|
|
105
|
+
# -----------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def emit_array_membership(attribute, values, inclusive, bind_attribute, collector)
|
|
108
|
+
collector.preparable = false
|
|
109
|
+
|
|
110
|
+
visit(attribute, collector)
|
|
111
|
+
collector << (inclusive ? " = ANY(" : " != ALL(")
|
|
112
|
+
append_array_operand(bind_attribute, values, collector)
|
|
113
|
+
collector << ")"
|
|
114
|
+
|
|
115
|
+
collector
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def append_array_operand(attribute, values, collector)
|
|
119
|
+
array_type = pg_oid_array_type(attribute)
|
|
120
|
+
|
|
121
|
+
if values.empty?
|
|
122
|
+
collector << "ARRAY[]::#{pg_array_sql_type(array_type)}"
|
|
123
|
+
else
|
|
124
|
+
array_data = array_type.serialize(values)
|
|
125
|
+
collector.add_binds(
|
|
126
|
+
[array_data],
|
|
127
|
+
bind_proc_for(attribute, array_type),
|
|
128
|
+
&bind_block
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# -----------------------------------------------------------------------
|
|
134
|
+
# Type casting
|
|
135
|
+
# -----------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def cast_values_for_attribute(attribute, values)
|
|
138
|
+
return [] if values.empty?
|
|
139
|
+
|
|
140
|
+
type_caster = attribute.type_caster
|
|
141
|
+
values.filter_map do |raw|
|
|
142
|
+
type_caster.serialize(raw) if type_caster.serializable?(raw)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# -----------------------------------------------------------------------
|
|
147
|
+
# PostgreSQL OID helpers
|
|
148
|
+
# -----------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def pg_oid_array_type(attribute)
|
|
151
|
+
ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(
|
|
152
|
+
attribute.type_caster
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def pg_array_sql_type(array_type)
|
|
157
|
+
"#{array_type.type}[]"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def bind_proc_for(attribute, array_type)
|
|
161
|
+
lambda do |value|
|
|
162
|
+
ActiveModel::Attribute.with_cast_value(attribute.name, value, array_type)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
# rubocop:enable Naming/MethodName
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgAnyWhere
|
|
4
|
+
# Configuration for the PgAnyWhere gem.
|
|
5
|
+
#
|
|
6
|
+
# Set options via the block form:
|
|
7
|
+
#
|
|
8
|
+
# PgAnyWhere.configure do |config|
|
|
9
|
+
# config.enabled = true
|
|
10
|
+
# config.min_array_size = 2
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Or via environment variables (ENV values act as defaults when the attribute
|
|
14
|
+
# has not been explicitly set):
|
|
15
|
+
#
|
|
16
|
+
# PG_ANY_WHERE_ENABLED = "true" | "false" (default: "true")
|
|
17
|
+
# PG_ANY_WHERE_MIN_ARRAY_SIZE = integer (default: "0")
|
|
18
|
+
#
|
|
19
|
+
class Configuration
|
|
20
|
+
# @return [Boolean, nil]
|
|
21
|
+
attr_writer :enabled
|
|
22
|
+
|
|
23
|
+
# @return [Integer, nil]
|
|
24
|
+
attr_writer :min_array_size
|
|
25
|
+
|
|
26
|
+
# Whether the extension is active.
|
|
27
|
+
#
|
|
28
|
+
# Explicit Ruby config takes precedence over the environment variable.
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def enabled?
|
|
32
|
+
if @enabled.nil?
|
|
33
|
+
ENV.fetch("PG_ANY_WHERE_ENABLED", "true") == "true"
|
|
34
|
+
else
|
|
35
|
+
@enabled
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Minimum array size required before the ANY / ALL rewrite is applied.
|
|
40
|
+
# Arrays smaller than this threshold fall back to the standard IN / NOT IN.
|
|
41
|
+
#
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
def min_array_size
|
|
44
|
+
@min_array_size || ENV.fetch("PG_ANY_WHERE_MIN_ARRAY_SIZE", "0").to_i
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns true when the rewrite should be applied to +values+.
|
|
48
|
+
#
|
|
49
|
+
# @param values [Object]
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def use_for_values?(values)
|
|
52
|
+
enabled? &&
|
|
53
|
+
values.is_a?(Array) &&
|
|
54
|
+
values.size >= min_array_size
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reset all settings back to defaults (reads ENV again).
|
|
58
|
+
def reset!
|
|
59
|
+
@enabled = nil
|
|
60
|
+
@min_array_size = nil
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgAnyWhere
|
|
4
|
+
# Rails Railtie — automatically calls +PgAnyWhere.patch!+ during
|
|
5
|
+
# the +after_initialize+ phase so the patch lands after the database
|
|
6
|
+
# connection is established and the Arel visitor class is fully loaded.
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
initializer "pg_any_where.patch_arel_visitor", after: :initialize_database do
|
|
9
|
+
PgAnyWhere.patch!
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/pg_any_where.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "pg_any_where/version"
|
|
5
|
+
require "pg_any_where/configuration"
|
|
6
|
+
require "pg_any_where/arel_visitor"
|
|
7
|
+
|
|
8
|
+
# PgAnyWhere rewrites ActiveRecord array-where conditions to use PostgreSQL's
|
|
9
|
+
# ANY / ALL operators instead of IN / NOT IN.
|
|
10
|
+
#
|
|
11
|
+
# == Quick start (non-Rails)
|
|
12
|
+
#
|
|
13
|
+
# require "pg_any_where"
|
|
14
|
+
# PgAnyWhere.patch!
|
|
15
|
+
#
|
|
16
|
+
# == Configuration
|
|
17
|
+
#
|
|
18
|
+
# PgAnyWhere.configure do |config|
|
|
19
|
+
# config.enabled = true # or set ENV["PG_ANY_WHERE_ENABLED"]
|
|
20
|
+
# config.min_array_size = 2 # or set ENV["PG_ANY_WHERE_MIN_ARRAY_SIZE"]
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# == Rails
|
|
24
|
+
#
|
|
25
|
+
# No manual setup needed — the Railtie handles everything automatically.
|
|
26
|
+
#
|
|
27
|
+
module PgAnyWhere
|
|
28
|
+
class Error < StandardError; end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Returns the global +Configuration+ instance.
|
|
32
|
+
#
|
|
33
|
+
# @return [PgAnyWhere::Configuration]
|
|
34
|
+
def configuration
|
|
35
|
+
@configuration ||= Configuration.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Yields the global +Configuration+ for block-style setup.
|
|
39
|
+
#
|
|
40
|
+
# @yieldparam config [PgAnyWhere::Configuration]
|
|
41
|
+
def configure
|
|
42
|
+
yield configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Patches +Arel::Visitors::PostgreSQL+ with the ANY / ALL rewrite logic.
|
|
46
|
+
#
|
|
47
|
+
# Idempotent — safe to call multiple times.
|
|
48
|
+
#
|
|
49
|
+
# @raise [PgAnyWhere::Error] if +Arel::Visitors::PostgreSQL+ is not defined
|
|
50
|
+
def patch!
|
|
51
|
+
return if @patched
|
|
52
|
+
|
|
53
|
+
unless defined?(Arel::Visitors::PostgreSQL)
|
|
54
|
+
raise Error, "Arel::Visitors::PostgreSQL is not defined. " \
|
|
55
|
+
"Ensure activerecord-postgresql-adapter is loaded before calling PgAnyWhere.patch!"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Arel::Visitors::PostgreSQL.prepend(Arel::Visitors::PgAnyWhereVisitor)
|
|
59
|
+
@patched = true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns +true+ after +patch!+ has been called successfully.
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def patched?
|
|
66
|
+
@patched == true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Shorthand for +PgAnyWhere.configuration.enabled?+.
|
|
70
|
+
#
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def enabled?
|
|
73
|
+
configuration.enabled?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Resets all configuration and the patched flag.
|
|
77
|
+
# Primarily used in tests.
|
|
78
|
+
#
|
|
79
|
+
# @return [void]
|
|
80
|
+
def reset!
|
|
81
|
+
@configuration = nil
|
|
82
|
+
# NOTE: we cannot un-prepend a module, so @patched is intentionally NOT
|
|
83
|
+
# reset here — calling patch! again would attempt a second prepend.
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
require "pg_any_where/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pg_any_where
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Aiman Abutalaah
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-26 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: pg
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.2'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.2'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rspec
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.12'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.12'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.60'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.60'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: rubocop-rspec
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '2.26'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '2.26'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: simplecov
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.22'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0.22'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: simplecov-console
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - "~>"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '0.9'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - "~>"
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '0.9'
|
|
125
|
+
- !ruby/object:Gem::Dependency
|
|
126
|
+
name: database_cleaner-active_record
|
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - "~>"
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '2.1'
|
|
132
|
+
type: :development
|
|
133
|
+
prerelease: false
|
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - "~>"
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '2.1'
|
|
139
|
+
description: |
|
|
140
|
+
pg_any_where patches Arel so that `where(column: array)` emits
|
|
141
|
+
`column = ANY($1::type[])` instead of `column IN ($1, $2, …)`.
|
|
142
|
+
|
|
143
|
+
This yields a stable prepared-statement shape (one bind parameter regardless
|
|
144
|
+
of array size), better PostgreSQL plan cache utilisation, and cleaner
|
|
145
|
+
pg_stat_statements output. Empty arrays are handled correctly without the
|
|
146
|
+
`1=0` / `1=1` footguns.
|
|
147
|
+
email: []
|
|
148
|
+
executables: []
|
|
149
|
+
extensions: []
|
|
150
|
+
extra_rdoc_files: []
|
|
151
|
+
files:
|
|
152
|
+
- CHANGELOG.md
|
|
153
|
+
- LICENSE.txt
|
|
154
|
+
- README.md
|
|
155
|
+
- lib/pg_any_where.rb
|
|
156
|
+
- lib/pg_any_where/arel_visitor.rb
|
|
157
|
+
- lib/pg_any_where/configuration.rb
|
|
158
|
+
- lib/pg_any_where/railtie.rb
|
|
159
|
+
- lib/pg_any_where/version.rb
|
|
160
|
+
homepage: https://github.com/aimanabutalaah/pg_any_where
|
|
161
|
+
licenses:
|
|
162
|
+
- MIT
|
|
163
|
+
metadata:
|
|
164
|
+
homepage_uri: https://github.com/aimanabutalaah/pg_any_where
|
|
165
|
+
source_code_uri: https://github.com/aimanabutalaah/pg_any_where
|
|
166
|
+
changelog_uri: https://github.com/aimanabutalaah/pg_any_where/blob/main/CHANGELOG.md
|
|
167
|
+
bug_tracker_uri: https://github.com/aimanabutalaah/pg_any_where/issues
|
|
168
|
+
rubygems_mfa_required: 'true'
|
|
169
|
+
post_install_message:
|
|
170
|
+
rdoc_options: []
|
|
171
|
+
require_paths:
|
|
172
|
+
- lib
|
|
173
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
174
|
+
requirements:
|
|
175
|
+
- - ">="
|
|
176
|
+
- !ruby/object:Gem::Version
|
|
177
|
+
version: 3.0.0
|
|
178
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
179
|
+
requirements:
|
|
180
|
+
- - ">="
|
|
181
|
+
- !ruby/object:Gem::Version
|
|
182
|
+
version: '0'
|
|
183
|
+
requirements: []
|
|
184
|
+
rubygems_version: 3.4.21
|
|
185
|
+
signing_key:
|
|
186
|
+
specification_version: 4
|
|
187
|
+
summary: Rewrite ActiveRecord array-where to PostgreSQL ANY / ALL operators
|
|
188
|
+
test_files: []
|