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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StrictPagination
4
+ VERSION = "0.1.1"
5
+ 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: []