strict_pagination 0.1.1
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/LICENSE.txt +21 -0
- data/README.md +322 -0
- data/lib/strict_pagination/active_record.rb +15 -0
- data/lib/strict_pagination/config.rb +36 -0
- data/lib/strict_pagination/railtie.rb +11 -0
- data/lib/strict_pagination/relation_extension.rb +12 -0
- data/lib/strict_pagination/relation_methods.rb +80 -0
- data/lib/strict_pagination/validator.rb +158 -0
- data/lib/strict_pagination/version.rb +5 -0
- data/lib/strict_pagination.rb +19 -0
- metadata +102 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 7234dbd288be581e5a708b4c854d4918e7ddb7f79c80c13d4be3f3cfd681bf23
|
4
|
+
data.tar.gz: 9edfc21298022007fa6727da2d93b92ee5ff160ab9bb094de7a43f24749c14f6
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c4fea6505c3b97324c0520271ddfbd722aacec173ab014fd969f459de5d6fca545b7eb5fb5cf59a41e17344a7948ebaf582c17925f4c52f8b61d1b027f7a8126
|
7
|
+
data.tar.gz: e074c0ebcbf7cc4664ecfad44552b1ba67f58478b6602b2af14a82d67af45d2f3d4781cdbf09a5cd200d66946fe368c66d3ba0ba93908d298f1a6124a3d5b20b
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 [Your Name]
|
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,322 @@
|
|
1
|
+
# StrictPagination
|
2
|
+
|
3
|
+
A Ruby gem that adds strict pagination validation to ActiveRecord, preventing unsafe JOINs that can cause inconsistent pagination results.
|
4
|
+
|
5
|
+
## Problem
|
6
|
+
|
7
|
+
When using LIMIT/OFFSET with DISTINCT and JOINs to associations that multiply rows (like `has_many` or unsafe `has_one`), pagination can become inconsistent. This happens because:
|
8
|
+
|
9
|
+
1. JOINs multiply rows (e.g., a User with 3 posts becomes 3 rows)
|
10
|
+
2. DISTINCT collapses them back to 1 row
|
11
|
+
3. But LIMIT is applied BEFORE DISTINCT, not after
|
12
|
+
4. Result: inconsistent page sizes and missing records
|
13
|
+
|
14
|
+
## Solution
|
15
|
+
|
16
|
+
This gem provides `strict_pagination` mode that validates queries before execution, ensuring they don't use JOINs that multiply rows when using pagination.
|
17
|
+
|
18
|
+
## Installation
|
19
|
+
|
20
|
+
Add this line to your application's Gemfile:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
gem 'strict_pagination'
|
24
|
+
```
|
25
|
+
|
26
|
+
And then execute:
|
27
|
+
|
28
|
+
```bash
|
29
|
+
bundle install
|
30
|
+
```
|
31
|
+
|
32
|
+
Or install it yourself as:
|
33
|
+
|
34
|
+
```bash
|
35
|
+
gem install strict_pagination
|
36
|
+
```
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
### Basic Usage
|
41
|
+
|
42
|
+
Simply add `.strict_pagination` to your ActiveRecord queries with DISTINCT:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
# Safe: no associations
|
46
|
+
User.strict_pagination.distinct.page(1).per(10)
|
47
|
+
|
48
|
+
# Safe: belongs_to associations don't multiply rows
|
49
|
+
Article.strict_pagination
|
50
|
+
.includes(:author)
|
51
|
+
.distinct
|
52
|
+
.page(1).per(10)
|
53
|
+
|
54
|
+
# Safe: view-backed has_one associations
|
55
|
+
Order.strict_pagination
|
56
|
+
.includes(:latest_payment) # backed by a database view
|
57
|
+
.distinct
|
58
|
+
.page(1).per(10)
|
59
|
+
|
60
|
+
# ERROR: has_many multiplies rows
|
61
|
+
User.strict_pagination
|
62
|
+
.includes(:posts)
|
63
|
+
.distinct
|
64
|
+
.page(1).per(10)
|
65
|
+
# => StrictPagination::ViolationError
|
66
|
+
```
|
67
|
+
|
68
|
+
**Note:** Validation only runs when **both** `LIMIT` (from `.page()`) and `DISTINCT` are present. This is because the pagination issue only occurs with DISTINCT queries.
|
69
|
+
|
70
|
+
### When is Validation Triggered?
|
71
|
+
|
72
|
+
The validation only runs when BOTH conditions are met:
|
73
|
+
- Query has `LIMIT` (pagination)
|
74
|
+
- Query has `DISTINCT`
|
75
|
+
|
76
|
+
This means you can use `strict_pagination` safely on all queries, and it will only validate when necessary.
|
77
|
+
|
78
|
+
### Safe Associations
|
79
|
+
|
80
|
+
The following associations are considered safe:
|
81
|
+
|
82
|
+
1. **belongs_to** - Never multiplies rows
|
83
|
+
2. **has_one backed by database views** - Views starting with `Views::` or table names starting with `views_`
|
84
|
+
3. **has_one with unique constraint** - When the foreign key has a unique index
|
85
|
+
|
86
|
+
### Unsafe Associations
|
87
|
+
|
88
|
+
The following associations will trigger an error:
|
89
|
+
|
90
|
+
1. **has_many** - Always multiplies rows
|
91
|
+
2. **has_and_belongs_to_many** - Always multiplies rows
|
92
|
+
3. **has_one without unique constraint** - Can multiply rows
|
93
|
+
|
94
|
+
### Error Messages
|
95
|
+
|
96
|
+
When a violation is detected, you'll get a detailed error message:
|
97
|
+
|
98
|
+
```
|
99
|
+
StrictPagination::ViolationError: Strict pagination violation: The query includes unsafe
|
100
|
+
associations `posts` that can multiply rows, causing inconsistent pagination with
|
101
|
+
LIMIT/OFFSET + DISTINCT.
|
102
|
+
|
103
|
+
Unsafe associations detected:
|
104
|
+
- posts: has_many (always multiplies rows)
|
105
|
+
|
106
|
+
Solutions:
|
107
|
+
1. Remove `posts` from the query's includes/preload/eager_load/joins
|
108
|
+
2. Replace has_many with has_one backed by a database view (automatically safe)
|
109
|
+
3. Add a unique constraint to the foreign key (for has_one associations)
|
110
|
+
4. Remove .strict_pagination if you understand the pagination risks
|
111
|
+
```
|
112
|
+
|
113
|
+
## Configuration
|
114
|
+
|
115
|
+
You can configure the gem's behavior using an initializer:
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
# config/initializers/strict_pagination.rb
|
119
|
+
|
120
|
+
StrictPagination.configure do |config|
|
121
|
+
# Validate all paginated queries, even without DISTINCT
|
122
|
+
# Default: false (only validates with DISTINCT)
|
123
|
+
config.validate_on_all_queries = false
|
124
|
+
|
125
|
+
# Add custom view prefixes to consider as safe
|
126
|
+
# Default: [] (uses "Views::" and "views_" by default)
|
127
|
+
config.safe_view_prefixes = ['Reports::', 'Analytics::']
|
128
|
+
end
|
129
|
+
```
|
130
|
+
|
131
|
+
### Configuration Options
|
132
|
+
|
133
|
+
#### `validate_on_all_queries`
|
134
|
+
|
135
|
+
By default, validation only runs when a query has both `LIMIT` and `DISTINCT`. Set this to `true` to validate all paginated queries regardless of DISTINCT:
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
StrictPagination.configure do |config|
|
139
|
+
config.validate_on_all_queries = true
|
140
|
+
end
|
141
|
+
|
142
|
+
# Now this will be validated even without DISTINCT
|
143
|
+
User.strict_pagination.includes(:posts).limit(10)
|
144
|
+
# => StrictPagination::ViolationError
|
145
|
+
```
|
146
|
+
|
147
|
+
#### `safe_view_prefixes`
|
148
|
+
|
149
|
+
Add custom prefixes for view classes or table names that should be considered safe:
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
StrictPagination.configure do |config|
|
153
|
+
config.safe_view_prefixes = ['Reports::', 'Analytics::']
|
154
|
+
end
|
155
|
+
|
156
|
+
# Now these are considered safe
|
157
|
+
class Reports::UserSummary < ApplicationRecord; end
|
158
|
+
Order.strict_pagination.includes(:user_summary).page(1) # Safe
|
159
|
+
```
|
160
|
+
|
161
|
+
## Best Practices
|
162
|
+
|
163
|
+
### For API Endpoints
|
164
|
+
|
165
|
+
Use `strict_pagination` on all paginated API endpoints with DISTINCT:
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
# app/controllers/api/v1/users_controller.rb
|
169
|
+
def index
|
170
|
+
@users = User.strict_pagination
|
171
|
+
.includes(:profile) # Safe: belongs_to
|
172
|
+
.distinct
|
173
|
+
.page(params[:page])
|
174
|
+
.per(params[:per_page])
|
175
|
+
|
176
|
+
render json: @users
|
177
|
+
end
|
178
|
+
```
|
179
|
+
|
180
|
+
### Using Database Views for Safe has_one
|
181
|
+
|
182
|
+
Replace `has_many` with `has_one` backed by a database view:
|
183
|
+
|
184
|
+
```ruby
|
185
|
+
# Instead of:
|
186
|
+
class Author < ApplicationRecord
|
187
|
+
has_many :books
|
188
|
+
end
|
189
|
+
|
190
|
+
# Create a view for the latest book:
|
191
|
+
# CREATE VIEW views_latest_books AS
|
192
|
+
# SELECT DISTINCT ON (author_id) *
|
193
|
+
# FROM books
|
194
|
+
# ORDER BY author_id, published_at DESC
|
195
|
+
|
196
|
+
class Author < ApplicationRecord
|
197
|
+
has_one :latest_book, class_name: 'Views::LatestBook'
|
198
|
+
end
|
199
|
+
|
200
|
+
# Now safe to use with strict_pagination:
|
201
|
+
Author.strict_pagination
|
202
|
+
.includes(:latest_book)
|
203
|
+
.distinct
|
204
|
+
.page(1).per(10)
|
205
|
+
```
|
206
|
+
|
207
|
+
### Gradual Adoption
|
208
|
+
|
209
|
+
You can gradually adopt strict pagination in your codebase:
|
210
|
+
|
211
|
+
```ruby
|
212
|
+
# Start with critical endpoints
|
213
|
+
class Api::V1::OrdersController < ApplicationController
|
214
|
+
def index
|
215
|
+
@orders = Order.strict_pagination.distinct.page(params[:page])
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Once confident, use it everywhere
|
220
|
+
class ApplicationRecord < ActiveRecord::Base
|
221
|
+
# Override default scope to use strict_pagination
|
222
|
+
def self.paginated(**options)
|
223
|
+
strict_pagination.distinct.page(options[:page]).per(options[:per_page])
|
224
|
+
end
|
225
|
+
end
|
226
|
+
```
|
227
|
+
|
228
|
+
## How It Works
|
229
|
+
|
230
|
+
The gem:
|
231
|
+
|
232
|
+
1. Adds a `strict_pagination` method to `ActiveRecord::Relation`
|
233
|
+
2. Uses `prepend` to hook into query execution (`exec_queries`)
|
234
|
+
3. Before executing, validates that no unsafe associations are included/joined
|
235
|
+
4. Only validates when the query has LIMIT (and optionally DISTINCT)
|
236
|
+
5. Raises `StrictPagination::ViolationError` if unsafe associations are detected
|
237
|
+
|
238
|
+
### Architecture
|
239
|
+
|
240
|
+
The gem follows Rails best practices:
|
241
|
+
|
242
|
+
- **ActiveSupport::Concern** - For clean module inclusion
|
243
|
+
- **ActiveSupport.on_load** - For proper Rails initialization
|
244
|
+
- **Railtie** - For Rails lifecycle integration
|
245
|
+
- **Prepend pattern** - For clean method overriding
|
246
|
+
|
247
|
+
## Examples
|
248
|
+
|
249
|
+
### Safe Queries
|
250
|
+
|
251
|
+
```ruby
|
252
|
+
# No associations
|
253
|
+
User.strict_pagination.distinct.page(1).per(20)
|
254
|
+
|
255
|
+
# belongs_to only
|
256
|
+
Comment.strict_pagination
|
257
|
+
.includes(:author, :post)
|
258
|
+
.distinct
|
259
|
+
.page(1).per(20)
|
260
|
+
|
261
|
+
# View-backed has_one
|
262
|
+
Invoice.strict_pagination
|
263
|
+
.includes(:latest_payment)
|
264
|
+
.distinct
|
265
|
+
.page(1).per(20)
|
266
|
+
|
267
|
+
# has_one with unique constraint
|
268
|
+
User.strict_pagination
|
269
|
+
.includes(:profile) # profiles.user_id has unique index
|
270
|
+
.distinct
|
271
|
+
.page(1).per(20)
|
272
|
+
```
|
273
|
+
|
274
|
+
### Unsafe Queries
|
275
|
+
|
276
|
+
```ruby
|
277
|
+
# has_many association
|
278
|
+
User.strict_pagination
|
279
|
+
.includes(:posts) # Error: posts multiplies rows
|
280
|
+
.distinct
|
281
|
+
.page(1).per(20)
|
282
|
+
|
283
|
+
# has_and_belongs_to_many
|
284
|
+
Article.strict_pagination
|
285
|
+
.includes(:tags) # Error: tags multiplies rows
|
286
|
+
.distinct
|
287
|
+
.page(1).per(20)
|
288
|
+
|
289
|
+
# has_one without unique constraint
|
290
|
+
Account.strict_pagination
|
291
|
+
.includes(:primary_contact) # Error: no unique constraint
|
292
|
+
.distinct
|
293
|
+
.page(1).per(20)
|
294
|
+
```
|
295
|
+
|
296
|
+
## Development
|
297
|
+
|
298
|
+
After checking out the repo, run:
|
299
|
+
|
300
|
+
```bash
|
301
|
+
bundle install
|
302
|
+
```
|
303
|
+
|
304
|
+
To run tests:
|
305
|
+
|
306
|
+
```bash
|
307
|
+
bundle exec rspec
|
308
|
+
```
|
309
|
+
|
310
|
+
To build the gem:
|
311
|
+
|
312
|
+
```bash
|
313
|
+
gem build strict_pagination.gemspec
|
314
|
+
```
|
315
|
+
|
316
|
+
## Contributing
|
317
|
+
|
318
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/hdamico/strict_pagination.
|
319
|
+
|
320
|
+
## License
|
321
|
+
|
322
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "strict_pagination/version"
|
4
|
+
require "strict_pagination/validator"
|
5
|
+
require "strict_pagination/relation_methods"
|
6
|
+
require "strict_pagination/relation_extension"
|
7
|
+
require "active_support/lazy_load_hooks"
|
8
|
+
|
9
|
+
ActiveSupport.on_load :active_record do
|
10
|
+
require "active_record"
|
11
|
+
|
12
|
+
::ActiveRecord::Relation.include StrictPagination::RelationMethods
|
13
|
+
::ActiveRecord::Relation.include StrictPagination::Validator
|
14
|
+
::ActiveRecord::Relation.prepend StrictPagination::RelationExtension
|
15
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StrictPagination
|
4
|
+
# Configures global settings for StrictPagination
|
5
|
+
# StrictPagination.configure do |config|
|
6
|
+
# config.enabled_by_default = false
|
7
|
+
# config.validate_on_all_queries = false
|
8
|
+
# end
|
9
|
+
class << self
|
10
|
+
def configure
|
11
|
+
yield config
|
12
|
+
end
|
13
|
+
|
14
|
+
def config
|
15
|
+
@_config ||= Config.new
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class Config
|
20
|
+
# Whether to enable strict_pagination by default on all queries
|
21
|
+
attr_accessor :enabled_by_default
|
22
|
+
|
23
|
+
# Whether to validate even without DISTINCT
|
24
|
+
# (useful if you want to enforce no JOINs on paginated queries regardless)
|
25
|
+
attr_accessor :validate_on_all_queries
|
26
|
+
|
27
|
+
# Custom view prefixes to consider as safe (in addition to "Views::" and "views_")
|
28
|
+
attr_accessor :safe_view_prefixes
|
29
|
+
|
30
|
+
def initialize
|
31
|
+
@enabled_by_default = false
|
32
|
+
@validate_on_all_queries = false
|
33
|
+
@safe_view_prefixes = []
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StrictPagination
|
4
|
+
class Railtie < ::Rails::Railtie
|
5
|
+
initializer "strict_pagination.configure_rails_initialization" do
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
7
|
+
require "strict_pagination/active_record"
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StrictPagination
|
4
|
+
# Prepended module to hook into exec_queries
|
5
|
+
# Using prepend instead of alias_method is cleaner and more maintainable
|
6
|
+
module RelationExtension
|
7
|
+
def exec_queries
|
8
|
+
validate_strict_pagination! if strict_pagination_value
|
9
|
+
super
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StrictPagination
|
4
|
+
# Methods added to ActiveRecord::Relation for strict pagination validation
|
5
|
+
module RelationMethods
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
# Add strict_pagination to SINGLE_VALUE_METHODS if not already present
|
10
|
+
self::SINGLE_VALUE_METHODS.push(:strict_pagination) unless self::SINGLE_VALUE_METHODS.include?(:strict_pagination)
|
11
|
+
end
|
12
|
+
|
13
|
+
##
|
14
|
+
# Sets the strict_pagination mode for this relation.
|
15
|
+
#
|
16
|
+
# When strict_pagination is enabled, the query will be validated before execution
|
17
|
+
# to ensure it doesn't use JOINs that multiply rows (has_many, unsafe has_one).
|
18
|
+
# The validation only runs when the query has LIMIT (pagination).
|
19
|
+
#
|
20
|
+
# @param value [Boolean] whether to enable strict pagination
|
21
|
+
# @return [ActiveRecord::Relation] a new relation with strict_pagination enabled
|
22
|
+
#
|
23
|
+
# @example Enable strict pagination for a paginated API endpoint
|
24
|
+
# products = Product.strict_pagination.page(1).per(10)
|
25
|
+
# # Automatically validates on load
|
26
|
+
#
|
27
|
+
# @example Use with includes (will validate safety)
|
28
|
+
# products = Product.strict_pagination
|
29
|
+
# .includes(:latest_active_deal) # Safe has_one
|
30
|
+
# .page(1).per(10)
|
31
|
+
# # No error - view-backed has_one is safe
|
32
|
+
#
|
33
|
+
# @example This will raise an error if a collection association is included
|
34
|
+
# products = Product.strict_pagination
|
35
|
+
# .includes(:deals) # Unsafe has_many
|
36
|
+
# .page(1).per(10)
|
37
|
+
# # => StrictPagination::ViolationError
|
38
|
+
def strict_pagination(value: true)
|
39
|
+
spawn.strict_pagination!(value:)
|
40
|
+
end
|
41
|
+
|
42
|
+
##
|
43
|
+
# Like #strict_pagination, but modifies relation in place.
|
44
|
+
# @private
|
45
|
+
def strict_pagination!(value: true)
|
46
|
+
self.strict_pagination_value = value
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
def strict_pagination_value
|
51
|
+
@values.fetch(:strict_pagination, false)
|
52
|
+
end
|
53
|
+
|
54
|
+
def strict_pagination_value=(value)
|
55
|
+
assert_modifiable!
|
56
|
+
@values[:strict_pagination] = value
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
##
|
62
|
+
# Validates that the query is safe for pagination.
|
63
|
+
# Only validates if the query has LIMIT + DISTINCT (classic pagination scenario).
|
64
|
+
def validate_strict_pagination!
|
65
|
+
return unless limit_value
|
66
|
+
|
67
|
+
# Check if we should validate without DISTINCT requirement
|
68
|
+
unless StrictPagination.config.validate_on_all_queries
|
69
|
+
return unless distinct_value
|
70
|
+
end
|
71
|
+
|
72
|
+
# Get all unsafe associations that are actually included/joined
|
73
|
+
unsafe_associations = detect_unsafe_included_associations
|
74
|
+
|
75
|
+
return if unsafe_associations.empty?
|
76
|
+
|
77
|
+
raise_strict_pagination_violation!(unsafe_associations)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StrictPagination
|
4
|
+
module Validator
|
5
|
+
private
|
6
|
+
|
7
|
+
##
|
8
|
+
# Detects all unsafe associations that are currently included/preloaded/joined.
|
9
|
+
def detect_unsafe_included_associations
|
10
|
+
unsafe = Set.new
|
11
|
+
all_included = preloaded_association_names
|
12
|
+
|
13
|
+
check_collection_associations(unsafe, all_included, :has_many)
|
14
|
+
check_collection_associations(unsafe, all_included, :has_and_belongs_to_many)
|
15
|
+
check_has_one_associations(unsafe, all_included)
|
16
|
+
detect_unsafe_joins(unsafe)
|
17
|
+
|
18
|
+
unsafe.to_a
|
19
|
+
end
|
20
|
+
|
21
|
+
def preloaded_association_names
|
22
|
+
(includes_values + preload_values + eager_load_values).map do |value|
|
23
|
+
next value.keys.first if value.is_a?(Hash)
|
24
|
+
|
25
|
+
value
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def check_collection_associations(unsafe, all_included, macro)
|
30
|
+
klass.reflect_on_all_associations(macro).each do |reflection|
|
31
|
+
unsafe << reflection.name.to_s if all_included.include?(reflection.name)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def check_has_one_associations(unsafe, all_included)
|
36
|
+
klass.reflect_on_all_associations(:has_one).each do |reflection|
|
37
|
+
next unless all_included.include?(reflection.name)
|
38
|
+
next if safe_has_one_association?(reflection)
|
39
|
+
|
40
|
+
unsafe << reflection.name.to_s
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
##
|
45
|
+
# Checks if a has_one association is safe for pagination.
|
46
|
+
def safe_has_one_association?(reflection)
|
47
|
+
klass_obj = reflection.klass
|
48
|
+
|
49
|
+
# Check if it's a database view (default prefixes)
|
50
|
+
return true if klass_obj.name.start_with?("Views::")
|
51
|
+
return true if klass_obj.table_name.start_with?("views_")
|
52
|
+
|
53
|
+
# Check custom view prefixes from config
|
54
|
+
StrictPagination.config.safe_view_prefixes.each do |prefix|
|
55
|
+
return true if klass_obj.name.start_with?(prefix)
|
56
|
+
return true if klass_obj.table_name.start_with?(prefix.underscore)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Check for unique constraint on the foreign key
|
60
|
+
unique_constraint_on_foreign_key?(klass_obj, reflection.foreign_key)
|
61
|
+
rescue StandardError
|
62
|
+
false
|
63
|
+
end
|
64
|
+
|
65
|
+
##
|
66
|
+
# Checks if a table has a unique constraint on a specific column.
|
67
|
+
def unique_constraint_on_foreign_key?(klass_obj, foreign_key)
|
68
|
+
return false unless klass_obj.respond_to?(:connection)
|
69
|
+
|
70
|
+
klass_obj.connection.indexes(klass_obj.table_name).any? do |index|
|
71
|
+
index.unique && index.columns.include?(foreign_key.to_s)
|
72
|
+
end
|
73
|
+
rescue ActiveRecord::StatementInvalid
|
74
|
+
false
|
75
|
+
end
|
76
|
+
|
77
|
+
##
|
78
|
+
# Detects unsafe associations in explicit joins_values.
|
79
|
+
def detect_unsafe_joins(unsafe)
|
80
|
+
joins_values.each do |join|
|
81
|
+
process_join(unsafe, join)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def process_join(unsafe, join)
|
86
|
+
case join
|
87
|
+
when Symbol, String
|
88
|
+
add_unsafe_join(unsafe, join.to_sym, join.to_s)
|
89
|
+
when Hash
|
90
|
+
join.each_key { |key| add_unsafe_join(unsafe, key.to_sym, key.to_s) }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def add_unsafe_join(unsafe, association_name, association_string)
|
95
|
+
reflection = klass.reflect_on_association(association_name)
|
96
|
+
return unless reflection
|
97
|
+
return if reflection.macro == :belongs_to
|
98
|
+
|
99
|
+
unsafe << association_string if unsafe_association?(reflection)
|
100
|
+
end
|
101
|
+
|
102
|
+
def unsafe_association?(reflection)
|
103
|
+
collection_macro?(reflection.macro) || unsafe_has_one?(reflection)
|
104
|
+
end
|
105
|
+
|
106
|
+
def collection_macro?(macro)
|
107
|
+
%i[has_many has_and_belongs_to_many].include?(macro)
|
108
|
+
end
|
109
|
+
|
110
|
+
def unsafe_has_one?(reflection)
|
111
|
+
reflection.macro == :has_one && !safe_has_one_association?(reflection)
|
112
|
+
end
|
113
|
+
|
114
|
+
##
|
115
|
+
# Raises a detailed error message about the strict pagination violation.
|
116
|
+
def raise_strict_pagination_violation!(unsafe_associations)
|
117
|
+
message = build_error_message(unsafe_associations)
|
118
|
+
raise StrictPagination::ViolationError, message
|
119
|
+
end
|
120
|
+
|
121
|
+
def build_error_message(unsafe_associations)
|
122
|
+
associations_list = unsafe_associations.map { |name| "`#{name}`" }.join(", ")
|
123
|
+
associations_details = unsafe_associations.map { |a| " - #{a}: #{describe_association_type(a)}" }.join("\n")
|
124
|
+
|
125
|
+
<<~MSG.squish
|
126
|
+
Strict pagination violation: The query includes unsafe associations #{associations_list}
|
127
|
+
that can multiply rows, causing inconsistent pagination with LIMIT/OFFSET + DISTINCT.
|
128
|
+
|
129
|
+
Unsafe associations detected:
|
130
|
+
#{associations_details}
|
131
|
+
|
132
|
+
Solutions:
|
133
|
+
1. Remove #{associations_list} from the query's includes/preload/eager_load/joins
|
134
|
+
2. Replace has_many with has_one backed by a database view (automatically safe)
|
135
|
+
3. Add a unique constraint to the foreign key (for has_one associations)
|
136
|
+
4. Remove .strict_pagination if you understand the pagination risks
|
137
|
+
MSG
|
138
|
+
end
|
139
|
+
|
140
|
+
##
|
141
|
+
# Describes the association type for error messages.
|
142
|
+
def describe_association_type(association_name)
|
143
|
+
reflection = klass.reflect_on_association(association_name.to_sym)
|
144
|
+
return "unknown" unless reflection
|
145
|
+
|
146
|
+
association_type_description(reflection.macro)
|
147
|
+
end
|
148
|
+
|
149
|
+
def association_type_description(macro)
|
150
|
+
case macro
|
151
|
+
when :has_many then "has_many (always multiplies rows)"
|
152
|
+
when :has_and_belongs_to_many then "has_and_belongs_to_many (always multiplies rows)"
|
153
|
+
when :has_one then "has_one without unique constraint (can multiply rows)"
|
154
|
+
else macro.to_s
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/lazy_load_hooks"
|
4
|
+
require "strict_pagination/version"
|
5
|
+
require "strict_pagination/config"
|
6
|
+
|
7
|
+
module StrictPagination
|
8
|
+
# Custom error raised when attempting pagination with unsafe JOINs
|
9
|
+
class ViolationError < ActiveRecord::ActiveRecordError
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
# Load Rails integration if Rails is available
|
14
|
+
if defined?(Rails::Railtie)
|
15
|
+
require "strict_pagination/railtie"
|
16
|
+
else
|
17
|
+
# For non-Rails environments, load ActiveRecord integration directly
|
18
|
+
require "strict_pagination/active_record"
|
19
|
+
end
|
metadata
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: strict_pagination
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Hernán d'Amico
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 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.0'
|
19
|
+
- - "<"
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '8.0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - ">="
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: '6.0'
|
29
|
+
- - "<"
|
30
|
+
- !ruby/object:Gem::Version
|
31
|
+
version: '8.0'
|
32
|
+
- !ruby/object:Gem::Dependency
|
33
|
+
name: rake
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
35
|
+
requirements:
|
36
|
+
- - "~>"
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: '13.0'
|
39
|
+
type: :development
|
40
|
+
prerelease: false
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
42
|
+
requirements:
|
43
|
+
- - "~>"
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '13.0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: rspec
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - "~>"
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: '3.0'
|
53
|
+
type: :development
|
54
|
+
prerelease: false
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - "~>"
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '3.0'
|
60
|
+
description: Adds strict_pagination mode to ActiveRecord::Relation to validate queries
|
61
|
+
before execution, ensuring they don't use JOINs that multiply rows (has_many, unsafe
|
62
|
+
has_one). Helps prevent inconsistent pagination with LIMIT/OFFSET + DISTINCT.
|
63
|
+
email:
|
64
|
+
- hernan.damico67@gmail.com
|
65
|
+
executables: []
|
66
|
+
extensions: []
|
67
|
+
extra_rdoc_files: []
|
68
|
+
files:
|
69
|
+
- LICENSE.txt
|
70
|
+
- README.md
|
71
|
+
- lib/strict_pagination.rb
|
72
|
+
- lib/strict_pagination/active_record.rb
|
73
|
+
- lib/strict_pagination/config.rb
|
74
|
+
- lib/strict_pagination/railtie.rb
|
75
|
+
- lib/strict_pagination/relation_extension.rb
|
76
|
+
- lib/strict_pagination/relation_methods.rb
|
77
|
+
- lib/strict_pagination/validator.rb
|
78
|
+
- lib/strict_pagination/version.rb
|
79
|
+
homepage: https://github.com/hdamico/strict_pagination
|
80
|
+
licenses:
|
81
|
+
- MIT
|
82
|
+
metadata:
|
83
|
+
source_code_uri: https://github.com/hdamico/strict_pagination
|
84
|
+
changelog_uri: https://github.com/hdamico/strict_pagination/blob/main/CHANGELOG.md
|
85
|
+
rdoc_options: []
|
86
|
+
require_paths:
|
87
|
+
- lib
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: 2.7.0
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
requirements: []
|
99
|
+
rubygems_version: 3.7.1
|
100
|
+
specification_version: 4
|
101
|
+
summary: Strict pagination validation for ActiveRecord to prevent unsafe JOINs
|
102
|
+
test_files: []
|