strict_pagination 0.1.1 → 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 +4 -4
- data/README.md +67 -20
- data/lib/strict_pagination/active_record.rb +9 -9
- data/lib/strict_pagination/railtie.rb +2 -2
- data/lib/strict_pagination/relation_methods.rb +1 -3
- data/lib/strict_pagination/validator.rb +7 -7
- data/lib/strict_pagination/version.rb +1 -1
- data/lib/strict_pagination.rb +6 -5
- metadata +12 -37
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 88249f37e3e76bb824c4c0a527bf42d5206562f239c55896d30459990b9047f9
|
4
|
+
data.tar.gz: c0ecab0ea113dbbfdd792a7854f21011af99e703e8038281a04e1edd42951aad
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 40be886cb36389573902cbdaf8cfdda2ec8161b94dbe0df1ab0c35ac2219c709d368c8178a1597205e5ab1fe2d7954fa99bbaa8031c3f7d86289a8494d610ab1
|
7
|
+
data.tar.gz: 15db3c489fa654ad9b2cde3529b03986a74a5a0d8a8fd5111eec78d6ff22043b0c734023b4b8f02463d385d7140f70bb9a4242fb2cceefbaccc0dc2f9bcd88bb
|
data/README.md
CHANGED
@@ -1,7 +1,12 @@
|
|
1
1
|
# StrictPagination
|
2
2
|
|
3
|
+
> **Keywords:** `pagination`, `rails`, `ruby`, `activerecord`, `api`, `validation`, `params`, `database`, `sql`, `query-validation`
|
4
|
+
|
3
5
|
A Ruby gem that adds strict pagination validation to ActiveRecord, preventing unsafe JOINs that can cause inconsistent pagination results.
|
4
6
|
|
7
|
+
[](https://badge.fury.io/rb/strict_pagination)
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
9
|
+
|
5
10
|
## Problem
|
6
11
|
|
7
12
|
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:
|
@@ -15,6 +20,11 @@ When using LIMIT/OFFSET with DISTINCT and JOINs to associations that multiply ro
|
|
15
20
|
|
16
21
|
This gem provides `strict_pagination` mode that validates queries before execution, ensuring they don't use JOINs that multiply rows when using pagination.
|
17
22
|
|
23
|
+
## Requirements
|
24
|
+
|
25
|
+
- Ruby 3.1.0 or higher
|
26
|
+
- ActiveRecord 6.1 or higher (Rails 6.1+)
|
27
|
+
|
18
28
|
## Installation
|
19
29
|
|
20
30
|
Add this line to your application's Gemfile:
|
@@ -43,29 +53,29 @@ Simply add `.strict_pagination` to your ActiveRecord queries with DISTINCT:
|
|
43
53
|
|
44
54
|
```ruby
|
45
55
|
# Safe: no associations
|
46
|
-
User.strict_pagination.distinct.
|
56
|
+
User.strict_pagination.distinct.limit(10).offset(0)
|
47
57
|
|
48
58
|
# Safe: belongs_to associations don't multiply rows
|
49
59
|
Article.strict_pagination
|
50
60
|
.includes(:author)
|
51
61
|
.distinct
|
52
|
-
.
|
62
|
+
.limit(10).offset(0)
|
53
63
|
|
54
64
|
# Safe: view-backed has_one associations
|
55
65
|
Order.strict_pagination
|
56
66
|
.includes(:latest_payment) # backed by a database view
|
57
67
|
.distinct
|
58
|
-
.
|
68
|
+
.limit(10).offset(0)
|
59
69
|
|
60
70
|
# ERROR: has_many multiplies rows
|
61
71
|
User.strict_pagination
|
62
72
|
.includes(:posts)
|
63
73
|
.distinct
|
64
|
-
.
|
74
|
+
.limit(10).offset(0)
|
65
75
|
# => StrictPagination::ViolationError
|
66
76
|
```
|
67
77
|
|
68
|
-
**Note:** Validation only runs when **both** `LIMIT` (from `.
|
78
|
+
**Note:** Validation only runs when **both** `LIMIT` (from `.limit()`) and `DISTINCT` are present. This is because the pagination issue only occurs with DISTINCT queries.
|
69
79
|
|
70
80
|
### When is Validation Triggered?
|
71
81
|
|
@@ -155,7 +165,7 @@ end
|
|
155
165
|
|
156
166
|
# Now these are considered safe
|
157
167
|
class Reports::UserSummary < ApplicationRecord; end
|
158
|
-
Order.strict_pagination.includes(:user_summary).
|
168
|
+
Order.strict_pagination.includes(:user_summary).limit(20) # Safe
|
159
169
|
```
|
160
170
|
|
161
171
|
## Best Practices
|
@@ -167,11 +177,14 @@ Use `strict_pagination` on all paginated API endpoints with DISTINCT:
|
|
167
177
|
```ruby
|
168
178
|
# app/controllers/api/v1/users_controller.rb
|
169
179
|
def index
|
180
|
+
page = (params[:page] || 1).to_i
|
181
|
+
per_page = (params[:per_page] || 20).to_i
|
182
|
+
|
170
183
|
@users = User.strict_pagination
|
171
184
|
.includes(:profile) # Safe: belongs_to
|
172
185
|
.distinct
|
173
|
-
.
|
174
|
-
.
|
186
|
+
.limit(per_page)
|
187
|
+
.offset((page - 1) * per_page)
|
175
188
|
|
176
189
|
render json: @users
|
177
190
|
end
|
@@ -201,7 +214,7 @@ end
|
|
201
214
|
Author.strict_pagination
|
202
215
|
.includes(:latest_book)
|
203
216
|
.distinct
|
204
|
-
.
|
217
|
+
.limit(10).offset(0)
|
205
218
|
```
|
206
219
|
|
207
220
|
### Gradual Adoption
|
@@ -212,15 +225,27 @@ You can gradually adopt strict pagination in your codebase:
|
|
212
225
|
# Start with critical endpoints
|
213
226
|
class Api::V1::OrdersController < ApplicationController
|
214
227
|
def index
|
215
|
-
|
228
|
+
page = (params[:page] || 1).to_i
|
229
|
+
per_page = 20
|
230
|
+
|
231
|
+
@orders = Order.strict_pagination
|
232
|
+
.distinct
|
233
|
+
.limit(per_page)
|
234
|
+
.offset((page - 1) * per_page)
|
216
235
|
end
|
217
236
|
end
|
218
237
|
|
219
238
|
# Once confident, use it everywhere
|
220
239
|
class ApplicationRecord < ActiveRecord::Base
|
221
|
-
#
|
222
|
-
def self.paginated(
|
223
|
-
|
240
|
+
# Helper method for pagination
|
241
|
+
def self.paginated(page: 1, per_page: 20)
|
242
|
+
page = page.to_i
|
243
|
+
per_page = per_page.to_i
|
244
|
+
|
245
|
+
strict_pagination
|
246
|
+
.distinct
|
247
|
+
.limit(per_page)
|
248
|
+
.offset((page - 1) * per_page)
|
224
249
|
end
|
225
250
|
end
|
226
251
|
```
|
@@ -250,25 +275,25 @@ The gem follows Rails best practices:
|
|
250
275
|
|
251
276
|
```ruby
|
252
277
|
# No associations
|
253
|
-
User.strict_pagination.distinct.
|
278
|
+
User.strict_pagination.distinct.limit(20).offset(0)
|
254
279
|
|
255
280
|
# belongs_to only
|
256
281
|
Comment.strict_pagination
|
257
282
|
.includes(:author, :post)
|
258
283
|
.distinct
|
259
|
-
.
|
284
|
+
.limit(20).offset(0)
|
260
285
|
|
261
286
|
# View-backed has_one
|
262
287
|
Invoice.strict_pagination
|
263
288
|
.includes(:latest_payment)
|
264
289
|
.distinct
|
265
|
-
.
|
290
|
+
.limit(20).offset(0)
|
266
291
|
|
267
292
|
# has_one with unique constraint
|
268
293
|
User.strict_pagination
|
269
294
|
.includes(:profile) # profiles.user_id has unique index
|
270
295
|
.distinct
|
271
|
-
.
|
296
|
+
.limit(20).offset(0)
|
272
297
|
```
|
273
298
|
|
274
299
|
### Unsafe Queries
|
@@ -278,19 +303,19 @@ User.strict_pagination
|
|
278
303
|
User.strict_pagination
|
279
304
|
.includes(:posts) # Error: posts multiplies rows
|
280
305
|
.distinct
|
281
|
-
.
|
306
|
+
.limit(20).offset(0)
|
282
307
|
|
283
308
|
# has_and_belongs_to_many
|
284
309
|
Article.strict_pagination
|
285
310
|
.includes(:tags) # Error: tags multiplies rows
|
286
311
|
.distinct
|
287
|
-
.
|
312
|
+
.limit(20).offset(0)
|
288
313
|
|
289
314
|
# has_one without unique constraint
|
290
315
|
Account.strict_pagination
|
291
316
|
.includes(:primary_contact) # Error: no unique constraint
|
292
317
|
.distinct
|
293
|
-
.
|
318
|
+
.limit(20).offset(0)
|
294
319
|
```
|
295
320
|
|
296
321
|
## Development
|
@@ -317,6 +342,28 @@ gem build strict_pagination.gemspec
|
|
317
342
|
|
318
343
|
Bug reports and pull requests are welcome on GitHub at https://github.com/hdamico/strict_pagination.
|
319
344
|
|
345
|
+
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
|
346
|
+
|
347
|
+
## Roadmap
|
348
|
+
|
349
|
+
- [ ] Add helper methods for easier pagination parameter handling
|
350
|
+
- [ ] Performance benchmarks and optimization guide
|
351
|
+
- [ ] Rails generator for adding strict_pagination to existing controllers
|
352
|
+
- [ ] Automated migration helper for converting has_many to view-backed has_one
|
353
|
+
- [ ] Support for custom validation rules
|
354
|
+
|
355
|
+
## Community & Support
|
356
|
+
|
357
|
+
- **Issues:** Report bugs or request features at [GitHub Issues](https://github.com/hdamico/strict_pagination/issues)
|
358
|
+
- **Discussions:** Ask questions and share ideas at [GitHub Discussions](https://github.com/hdamico/strict_pagination/discussions)
|
359
|
+
- **Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines
|
360
|
+
|
320
361
|
## License
|
321
362
|
|
322
363
|
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
364
|
+
|
365
|
+
---
|
366
|
+
|
367
|
+
**Made with ❤️ for the Ruby and Rails community**
|
368
|
+
|
369
|
+
*If you find this gem useful, please ⭐ star the repository and share it with others!*
|
@@ -1,15 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
6
|
-
require
|
7
|
-
require
|
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
8
|
|
9
9
|
ActiveSupport.on_load :active_record do
|
10
|
-
require
|
10
|
+
require 'active_record'
|
11
11
|
|
12
|
-
|
13
|
-
|
14
|
-
|
12
|
+
ActiveRecord::Relation.include StrictPagination::RelationMethods
|
13
|
+
ActiveRecord::Relation.include StrictPagination::Validator
|
14
|
+
ActiveRecord::Relation.prepend StrictPagination::RelationExtension
|
15
15
|
end
|
@@ -2,9 +2,9 @@
|
|
2
2
|
|
3
3
|
module StrictPagination
|
4
4
|
class Railtie < ::Rails::Railtie
|
5
|
-
initializer
|
5
|
+
initializer 'strict_pagination.configure_rails_initialization' do
|
6
6
|
ActiveSupport.on_load(:active_record) do
|
7
|
-
require
|
7
|
+
require 'strict_pagination/active_record'
|
8
8
|
end
|
9
9
|
end
|
10
10
|
end
|
@@ -65,9 +65,7 @@ module StrictPagination
|
|
65
65
|
return unless limit_value
|
66
66
|
|
67
67
|
# Check if we should validate without DISTINCT requirement
|
68
|
-
|
69
|
-
return unless distinct_value
|
70
|
-
end
|
68
|
+
return if !StrictPagination.config.validate_on_all_queries && !distinct_value
|
71
69
|
|
72
70
|
# Get all unsafe associations that are actually included/joined
|
73
71
|
unsafe_associations = detect_unsafe_included_associations
|
@@ -47,8 +47,8 @@ module StrictPagination
|
|
47
47
|
klass_obj = reflection.klass
|
48
48
|
|
49
49
|
# Check if it's a database view (default prefixes)
|
50
|
-
return true if klass_obj.name.start_with?(
|
51
|
-
return true if klass_obj.table_name.start_with?(
|
50
|
+
return true if klass_obj.name.start_with?('Views::')
|
51
|
+
return true if klass_obj.table_name.start_with?('views_')
|
52
52
|
|
53
53
|
# Check custom view prefixes from config
|
54
54
|
StrictPagination.config.safe_view_prefixes.each do |prefix|
|
@@ -119,7 +119,7 @@ module StrictPagination
|
|
119
119
|
end
|
120
120
|
|
121
121
|
def build_error_message(unsafe_associations)
|
122
|
-
associations_list = unsafe_associations.map { |name| "`#{name}`" }.join(
|
122
|
+
associations_list = unsafe_associations.map { |name| "`#{name}`" }.join(', ')
|
123
123
|
associations_details = unsafe_associations.map { |a| " - #{a}: #{describe_association_type(a)}" }.join("\n")
|
124
124
|
|
125
125
|
<<~MSG.squish
|
@@ -141,16 +141,16 @@ module StrictPagination
|
|
141
141
|
# Describes the association type for error messages.
|
142
142
|
def describe_association_type(association_name)
|
143
143
|
reflection = klass.reflect_on_association(association_name.to_sym)
|
144
|
-
return
|
144
|
+
return 'unknown' unless reflection
|
145
145
|
|
146
146
|
association_type_description(reflection.macro)
|
147
147
|
end
|
148
148
|
|
149
149
|
def association_type_description(macro)
|
150
150
|
case macro
|
151
|
-
when :has_many then
|
152
|
-
when :has_and_belongs_to_many then
|
153
|
-
when :has_one then
|
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
154
|
else macro.to_s
|
155
155
|
end
|
156
156
|
end
|
data/lib/strict_pagination.rb
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
3
|
+
require 'active_record'
|
4
|
+
require 'active_support/lazy_load_hooks'
|
5
|
+
require 'strict_pagination/version'
|
6
|
+
require 'strict_pagination/config'
|
6
7
|
|
7
8
|
module StrictPagination
|
8
9
|
# Custom error raised when attempting pagination with unsafe JOINs
|
@@ -12,8 +13,8 @@ end
|
|
12
13
|
|
13
14
|
# Load Rails integration if Rails is available
|
14
15
|
if defined?(Rails::Railtie)
|
15
|
-
require
|
16
|
+
require 'strict_pagination/railtie'
|
16
17
|
else
|
17
18
|
# For non-Rails environments, load ActiveRecord integration directly
|
18
|
-
require
|
19
|
+
require 'strict_pagination/active_record'
|
19
20
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: strict_pagination
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Hernán d'Amico
|
@@ -15,7 +15,7 @@ dependencies:
|
|
15
15
|
requirements:
|
16
16
|
- - ">="
|
17
17
|
- !ruby/object:Gem::Version
|
18
|
-
version: '6.
|
18
|
+
version: '6.1'
|
19
19
|
- - "<"
|
20
20
|
- !ruby/object:Gem::Version
|
21
21
|
version: '8.0'
|
@@ -25,41 +25,14 @@ dependencies:
|
|
25
25
|
requirements:
|
26
26
|
- - ">="
|
27
27
|
- !ruby/object:Gem::Version
|
28
|
-
version: '6.
|
28
|
+
version: '6.1'
|
29
29
|
- - "<"
|
30
30
|
- !ruby/object:Gem::Version
|
31
31
|
version: '8.0'
|
32
|
-
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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.
|
32
|
+
description: StrictPagination enforces type-safe pagination parameters and validates
|
33
|
+
queries before execution, preventing unsafe JOINs (has_many, unsafe has_one) that
|
34
|
+
multiply rows and cause inconsistent pagination with LIMIT/OFFSET + DISTINCT. Provides
|
35
|
+
helpers for ActiveRecord and API responses.
|
63
36
|
email:
|
64
37
|
- hernan.damico67@gmail.com
|
65
38
|
executables: []
|
@@ -80,8 +53,10 @@ homepage: https://github.com/hdamico/strict_pagination
|
|
80
53
|
licenses:
|
81
54
|
- MIT
|
82
55
|
metadata:
|
83
|
-
|
56
|
+
homepage_uri: https://github.com/hdamico/strict_pagination
|
84
57
|
changelog_uri: https://github.com/hdamico/strict_pagination/blob/main/CHANGELOG.md
|
58
|
+
bug_tracker_uri: https://github.com/hdamico/strict_pagination/issues
|
59
|
+
documentation_uri: https://github.com/hdamico/strict_pagination/blob/main/README.md
|
85
60
|
rdoc_options: []
|
86
61
|
require_paths:
|
87
62
|
- lib
|
@@ -89,7 +64,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
64
|
requirements:
|
90
65
|
- - ">="
|
91
66
|
- !ruby/object:Gem::Version
|
92
|
-
version:
|
67
|
+
version: 3.1.0
|
93
68
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
69
|
requirements:
|
95
70
|
- - ">="
|
@@ -98,5 +73,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
98
73
|
requirements: []
|
99
74
|
rubygems_version: 3.7.1
|
100
75
|
specification_version: 4
|
101
|
-
summary: Strict pagination
|
76
|
+
summary: Strict and safe pagination for Ruby/Rails apps with ActiveRecord
|
102
77
|
test_files: []
|