fetcheable_on_api 0.4 → 0.5.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.
@@ -1,12 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetcheableOnApi
4
- # FetcheableOnApi configuration object.
4
+ # Configuration class for FetcheableOnApi gem settings.
5
5
  #
6
+ # This class holds global configuration options that affect the behavior
7
+ # of filtering, sorting, and pagination across all controllers that use
8
+ # the FetcheableOnApi module.
9
+ #
10
+ # Configuration is typically set in a Rails initializer file, but can
11
+ # also be modified at runtime if needed.
12
+ #
13
+ # @example Setting configuration in an initializer
14
+ # # config/initializers/fetcheable_on_api.rb
15
+ # FetcheableOnApi.configure do |config|
16
+ # config.pagination_default_size = 50
17
+ # end
18
+ #
19
+ # @example Runtime configuration changes
20
+ # FetcheableOnApi.configuration.pagination_default_size = 100
21
+ #
22
+ # @since 0.1.0
6
23
  class Configuration
7
- # @attribute [Integer] Default pagination size
24
+ # Default number of records per page when no page[size] parameter is provided.
25
+ # This value is used by the Pageable module when clients don't specify
26
+ # a page size in their requests.
27
+ #
28
+ # @return [Integer] The default pagination size
29
+ # @example
30
+ # # With default configuration (25):
31
+ # # GET /users?page[number]=2
32
+ # # Returns 25 records starting from record 26
33
+ #
34
+ # # With custom configuration (50):
35
+ # # GET /users?page[number]=2
36
+ # # Returns 50 records starting from record 51
8
37
  attr_accessor :pagination_default_size
9
38
 
39
+ # Initialize configuration with default values.
40
+ # Sets up sensible defaults that work well for most applications.
10
41
  def initialize
11
42
  @pagination_default_size = 25
12
43
  end
@@ -1,11 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetcheableOnApi
4
- # Filterable implements `filter` parameter support.
4
+ # Filterable implements support for JSONAPI-compliant filtering via `filter` query parameters.
5
+ #
6
+ # This module enables controllers to process filter parameters in the format:
7
+ # `filter[attribute]=value` or `filter[attribute]=value1,value2` for multiple values
8
+ #
9
+ # It supports:
10
+ # - 30+ Arel predicates (eq, ilike, between, in, gt, lt, matches, etc.)
11
+ # - Association filtering with custom class names
12
+ # - Custom lambda predicates for complex filtering logic
13
+ # - Multiple filter values with OR logic
14
+ # - Date/time filtering with custom formats
15
+ #
16
+ # @example Basic filtering setup
17
+ # class UsersController < ApplicationController
18
+ # filter_by :name, :email, :status
19
+ #
20
+ # def index
21
+ # users = apply_fetcheable(User.all)
22
+ # render json: users
23
+ # end
24
+ # end
25
+ #
26
+ # # GET /users?filter[name]=john&filter[status]=active
27
+ #
28
+ # @example Association filtering
29
+ # class PostsController < ApplicationController
30
+ # filter_by :title
31
+ # filter_by :author, class_name: User, as: 'name'
32
+ #
33
+ # def index
34
+ # posts = apply_fetcheable(Post.joins(:author))
35
+ # render json: posts
36
+ # end
37
+ # end
38
+ #
39
+ # # GET /posts?filter[author]=john&filter[title]=rails
40
+ #
41
+ # @example Custom predicates
42
+ # class ProductsController < ApplicationController
43
+ # filter_by :price, with: :gteq # Greater than or equal
44
+ # filter_by :created_at, with: :between, format: :datetime
45
+ #
46
+ # def index
47
+ # products = apply_fetcheable(Product.all)
48
+ # render json: products
49
+ # end
50
+ # end
51
+ #
52
+ # # GET /products?filter[price]=100&filter[created_at]=1609459200,1640995200
53
+ #
54
+ # @see https://jsonapi.org/format/#fetching-filtering JSONAPI Filtering Specification
5
55
  module Filterable
56
+ # Arel predicates that expect array values instead of single values.
57
+ # These predicates work with multiple values and are handled differently
58
+ # during parameter validation and processing.
6
59
  #
7
- # Predicates supported for filtering.
8
- #
60
+ # @example Usage with array predicates
61
+ # filter_by :tags, with: :in_all
62
+ # # Expects: filter[tags][]= or filter[tags]=value1,value2
63
+ # Arel predicates that expect array values instead of single values.
9
64
  PREDICATES_WITH_ARRAY = %i[
10
65
  does_not_match_all
11
66
  does_not_match_any
@@ -29,149 +84,278 @@ module FetcheableOnApi
29
84
  not_in_any
30
85
  ].freeze
31
86
 
87
+ # Hook called when Filterable is included in a class.
88
+ # Sets up the class to support filter configuration and provides
89
+ # the filter_by class method.
32
90
  #
33
- # Public class methods
34
- #
91
+ # @param base [Class] The class including this module
92
+ # @private
35
93
  def self.included(base)
36
94
  base.class_eval do
37
95
  extend ClassMethods
96
+ # Store filter configurations per class to avoid conflicts between controllers
38
97
  class_attribute :filters_configuration, instance_writer: false
39
98
  self.filters_configuration = {}
40
99
  end
41
100
  end
42
101
 
43
- # Class methods made available to your controllers.
102
+ # Class methods made available to controllers when Filterable is included.
44
103
  module ClassMethods
45
- # Define a filterable attribute.
104
+ # Define one or more filterable attributes for the controller.
105
+ #
106
+ # This method configures which model attributes can be filtered via query parameters
107
+ # and how those filters should be processed.
108
+ #
109
+ # @param attrs [Array<Symbol>] List of attribute names to make filterable
110
+ # @param options [Hash] Configuration options for the filters
111
+ # @option options [String, Symbol] :as Alias for the database column name
112
+ # @option options [Class] :class_name Model class for association filtering (defaults to collection class)
113
+ # @option options [Symbol, Proc] :with Arel predicate to use (:ilike, :eq, :between, etc.) or custom lambda
114
+ # @option options [Symbol] :format Value format (:string, :array, :datetime) for parameter processing
115
+ # @option options [Symbol] :association Association name when different from inferred name
116
+ #
117
+ # @example Basic attribute filtering
118
+ # filter_by :name, :email, :status
119
+ # # Allows: filter[name]=john&filter[email]=john@example.com&filter[status]=active
120
+ #
121
+ # @example Custom predicate
122
+ # filter_by :age, with: :gteq # Greater than or equal
123
+ # filter_by :created_at, with: :between, format: :datetime
124
+ # # Allows: filter[age]=18&filter[created_at]=1609459200,1640995200
125
+ #
126
+ # @example Association filtering
127
+ # filter_by :author, class_name: User, as: 'name'
128
+ # # Allows: filter[author]=john (filters by users.name)
46
129
  #
47
- # @see FetcheableOnApi::Filterable::PREDICATES_WITH_ARRAY
130
+ # @example Custom lambda predicate
131
+ # filter_by :full_name, with: -> (collection, value) {
132
+ # collection.arel_table[:first_name].matches("%#{value}%").or(
133
+ # collection.arel_table[:last_name].matches("%#{value}%")
134
+ # )
135
+ # }
48
136
  #
49
- # @param attrs [Array] options to define one or more filters.
50
- # @option attrs [String, nil] :as Alias the filtered attribute
51
- # @option attrs [String, nil] :class_name Override the class of the filter target
52
- # @option attrs [String, nil] :with Use a specific predicate
137
+ # @raise [ArgumentError] When invalid options are provided
138
+ # @see PREDICATES_WITH_ARRAY For list of array-based predicates
53
139
  def filter_by(*attrs)
54
140
  options = attrs.extract_options!
55
141
  options.symbolize_keys!
56
- options.assert_valid_keys(:as, :class_name, :with, :format)
57
142
 
143
+ # Validate that only supported options are provided
144
+ options.assert_valid_keys(:as, :class_name, :with, :format, :association)
145
+
146
+ # Create a new configuration hash to avoid modifying parent class config
58
147
  self.filters_configuration = filters_configuration.dup
59
148
 
60
149
  attrs.each do |attr|
150
+ # Initialize default configuration for this attribute
61
151
  filters_configuration[attr] ||= {
62
- as: options[:as] || attr,
152
+ as: options[:as] || attr
63
153
  }
64
154
 
155
+ # Merge in the provided options
65
156
  filters_configuration[attr].merge!(options)
66
157
  end
67
158
  end
68
159
  end
69
160
 
70
- #
71
- # Public instance methods
72
- #
161
+ # Protected instance methods for filtering functionality
73
162
 
74
- #
75
- # Protected instance methods
76
- #
77
163
  protected
78
164
 
165
+ # Generate the list of valid parameter keys for Rails strong parameters.
166
+ # This method examines the filter configuration to determine which parameters
167
+ # should be permitted, taking into account predicates that expect arrays.
168
+ #
169
+ # @return [Array] Array of parameter keys, with Hash format for array predicates
170
+ # @example
171
+ # # For filter_by :name, :tags (where tags uses in_any predicate)
172
+ # # Returns: [:name, {tags: []}]
173
+ # @private
79
174
  def valid_keys
80
175
  keys = filters_configuration.keys
81
176
  keys.each_with_index do |key, index|
82
177
  predicate = filters_configuration[key.to_sym].fetch(:with, :ilike)
83
178
 
84
- if(%i[between not_between in in_all in_any].include?(predicate))
179
+ # Special handling for predicates that work with ranges or arrays
180
+ if %i[between not_between in in_all in_any].include?(predicate)
85
181
  format = filters_configuration[key.to_sym].fetch(:format) { nil }
86
- keys[index] = {key => []} if format == :array
182
+ # Use array format for explicit array formatting or for array predicates
183
+ if format == :array || PREDICATES_WITH_ARRAY.include?(predicate.to_sym)
184
+ keys[index] = { key => [] }
185
+ end
87
186
  next
88
187
  end
89
188
 
189
+ # Skip if it's a custom lambda predicate or doesn't expect arrays
90
190
  next if predicate.respond_to?(:call) ||
91
191
  PREDICATES_WITH_ARRAY.exclude?(predicate.to_sym)
92
192
 
93
- keys[index] = {key => []}
193
+ # Convert to array format for predicates that expect multiple values
194
+ keys[index] = { key => [] }
94
195
  end
95
196
 
96
197
  keys
97
198
  end
98
199
 
200
+ # Apply filtering to the collection based on filter query parameters.
201
+ # This is the main method that processes all configured filters and
202
+ # applies them to the ActiveRecord relation.
203
+ #
204
+ # @param collection [ActiveRecord::Relation] The collection to filter
205
+ # @return [ActiveRecord::Relation] The filtered collection
206
+ # @raise [FetcheableOnApi::ArgumentError] When filter parameters are invalid
207
+ #
208
+ # @example
209
+ # # With params: { filter: { name: 'john', status: 'active' } }
210
+ # filtered_users = apply_filters(User.all)
211
+ # # Generates: WHERE users.name ILIKE '%john%' AND users.status ILIKE '%active%'
99
212
  def apply_filters(collection)
213
+ # Return early if no filter parameters are provided
100
214
  return collection if params[:filter].blank?
101
215
 
216
+ # Validate that filter parameters are properly formatted
102
217
  foa_valid_parameters!(:filter)
103
218
 
219
+ # Extract and permit only configured filter parameters
104
220
  filter_params = params.require(:filter)
105
- .permit(valid_keys)
221
+ .permit(*valid_keys)
106
222
  .to_hash
107
223
 
224
+ # Process each filter parameter and build Arel predicates
108
225
  filtering = filter_params.map do |column, values|
109
- format = filters_configuration[column.to_sym].fetch(:format, :string)
110
- column_name = filters_configuration[column.to_sym].fetch(:as, column)
111
- klass = filters_configuration[column.to_sym].fetch(:class_name, collection.klass)
226
+ config = filters_configuration[column.to_sym]
227
+
228
+ # Extract configuration for this filter
229
+ format = config.fetch(:format, :string)
230
+ column_name = config.fetch(:as, column)
231
+ klass = config.fetch(:class_name, collection.klass)
112
232
  collection_klass = collection.name.constantize
113
- predicate = filters_configuration[column.to_sym].fetch(:with, :ilike)
233
+ association_class_or_name = config.fetch(
234
+ :association, klass.table_name.to_sym
235
+ )
236
+
237
+ predicate = config.fetch(:with, :ilike)
114
238
 
239
+ # Join association table if filtering on a different model
115
240
  if collection_klass != klass
116
- collection = collection.joins(klass.table_name.to_sym)
241
+ collection = collection.joins(association_class_or_name)
117
242
  end
118
243
 
244
+ # Skip if values is nil or empty
245
+ next if values.nil? || values == ""
246
+
247
+ # Handle range-based predicates (between, not_between)
119
248
  if %i[between not_between].include?(predicate)
120
249
  if values.is_a?(String)
121
- predicates(predicate, collection, klass, column_name, values.split(","))
250
+ # Single range: "start,end"
251
+ predicates(predicate, collection, klass, column_name, values.split(','))
122
252
  else
253
+ # Multiple ranges: ["start1,end1", "start2,end2"] with OR logic
123
254
  values.map do |value|
124
- predicates(predicate, collection, klass, column_name, value.split(","))
255
+ predicates(predicate, collection, klass, column_name, value.split(','))
125
256
  end.inject(:or)
126
257
  end
127
258
  elsif values.is_a?(String)
128
- values.split(",").map do |value|
259
+ # Single value or comma-separated values with OR logic
260
+ values.split(',').map do |value|
129
261
  predicates(predicate, collection, klass, column_name, value)
130
262
  end.inject(:or)
131
263
  else
132
- values.map! { |el| el.split(",") }
264
+ # Array of values, each potentially comma-separated
265
+ values.map! { |el| el.split(',') }
133
266
  predicates(predicate, collection, klass, column_name, values)
134
267
  end
135
268
  end
136
269
 
270
+ # Combine all filter predicates with AND logic
137
271
  collection.where(filtering.flatten.compact.inject(:and))
138
272
  end
139
273
 
140
- # Apply arel predicate on collection
274
+ # Build an Arel predicate for the given parameters.
275
+ # This method translates filter predicates into Arel expressions that can
276
+ # be used in ActiveRecord where clauses.
277
+ #
278
+ # @param predicate [Symbol, Proc] The predicate type (:eq, :ilike, :between, etc.) or custom lambda
279
+ # @param collection [ActiveRecord::Relation] The collection being filtered (used for lambda predicates)
280
+ # @param klass [Class] The model class for the attribute being filtered
281
+ # @param column_name [String, Symbol] The database column name to filter on
282
+ # @param value [Object] The filter value(s) to compare against
283
+ # @return [Arel::Node] An Arel predicate node
284
+ # @raise [ArgumentError] When an unsupported predicate is used
285
+ #
286
+ # @example
287
+ # # predicates(:eq, collection, User, 'name', 'john')
288
+ # # Returns: users.name = 'john'
289
+ #
290
+ # # predicates(:between, collection, User, 'age', [18, 65])
291
+ # # Returns: users.age BETWEEN 18 AND 65
292
+ #
293
+ # @private
141
294
  def predicates(predicate, collection, klass, column_name, value)
142
295
  case predicate
296
+ # Range predicates - work with two values (start, end)
143
297
  when :between
144
298
  klass.arel_table[column_name].between(value.first..value.last)
145
- when :does_not_match
146
- klass.arel_table[column_name].does_not_match("%#{value}%")
147
- when :does_not_match_all
148
- klass.arel_table[column_name].does_not_match_all(value)
149
- when :does_not_match_any
150
- klass.arel_table[column_name].does_not_match_any(value)
299
+ when :not_between
300
+ klass.arel_table[column_name].not_between(value.first..value.last)
301
+
302
+ # Equality predicates - exact matching
151
303
  when :eq
152
304
  klass.arel_table[column_name].eq(value)
305
+ when :not_eq
306
+ klass.arel_table[column_name].not_eq(value)
307
+
308
+ # Comparison predicates - numeric/date comparisons
309
+ when :gt
310
+ klass.arel_table[column_name].gt(value)
311
+ when :gteq
312
+ klass.arel_table[column_name].gteq(value)
313
+ when :lt
314
+ klass.arel_table[column_name].lt(value)
315
+ when :lteq
316
+ klass.arel_table[column_name].lteq(value)
317
+
318
+ # Array inclusion predicates - check if value is in a set
319
+ when :in
320
+ if value.is_a?(Array)
321
+ klass.arel_table[column_name].in(value.flatten.compact.uniq)
322
+ else
323
+ klass.arel_table[column_name].in(value)
324
+ end
325
+ when :not_in
326
+ klass.arel_table[column_name].not_in(value)
327
+
328
+ # Pattern matching predicates - for text search
329
+ when :ilike
330
+ # Default predicate - case-insensitive partial matching
331
+ klass.arel_table[column_name].matches("%#{value}%")
332
+ when :matches
333
+ # Exact pattern matching (supports SQL wildcards)
334
+ klass.arel_table[column_name].matches(value)
335
+ when :does_not_match
336
+ klass.arel_table[column_name].does_not_match("%#{value}%")
337
+
338
+ # Array-based predicates (work with multiple values)
153
339
  when :eq_all
154
340
  klass.arel_table[column_name].eq_all(value)
155
341
  when :eq_any
156
342
  klass.arel_table[column_name].eq_any(value)
157
- when :gt
158
- klass.arel_table[column_name].gt(value)
159
343
  when :gt_all
160
344
  klass.arel_table[column_name].gt_all(value)
161
345
  when :gt_any
162
346
  klass.arel_table[column_name].gt_any(value)
163
- when :gteq
164
- klass.arel_table[column_name].gteq(value)
165
347
  when :gteq_all
166
348
  klass.arel_table[column_name].gteq_all(value)
167
349
  when :gteq_any
168
350
  klass.arel_table[column_name].gteq_any(value)
169
- when :in
170
- if value.is_a?(Array)
171
- klass.arel_table[column_name].in(value.flatten.compact.uniq)
172
- else
173
- klass.arel_table[column_name].in(value)
174
- end
351
+ when :lt_all
352
+ klass.arel_table[column_name].lt_all(value)
353
+ when :lt_any
354
+ klass.arel_table[column_name].lt_any(value)
355
+ when :lteq_all
356
+ klass.arel_table[column_name].lteq_all(value)
357
+ when :lteq_any
358
+ klass.arel_table[column_name].lteq_any(value)
175
359
  when :in_all
176
360
  if value.is_a?(Array)
177
361
  klass.arel_table[column_name].in_all(value.flatten.compact.uniq)
@@ -184,51 +368,40 @@ module FetcheableOnApi
184
368
  else
185
369
  klass.arel_table[column_name].in_any(value)
186
370
  end
187
- when :lt
188
- klass.arel_table[column_name].lt(value)
189
- when :lt_all
190
- klass.arel_table[column_name].lt_all(value)
191
- when :lt_any
192
- klass.arel_table[column_name].lt_any(value)
193
- when :lteq
194
- klass.arel_table[column_name].lteq(value)
195
- when :lteq_all
196
- klass.arel_table[column_name].lteq_all(value)
197
- when :lteq_any
198
- klass.arel_table[column_name].lteq_any(value)
199
- when :ilike
200
- klass.arel_table[column_name].matches("%#{value}%")
201
- when :matches
202
- klass.arel_table[column_name].matches(value)
203
- when :matches_all
204
- klass.arel_table[column_name].matches_all(value)
205
- when :matches_any
206
- klass.arel_table[column_name].matches_any(value)
207
- when :not_between
208
- klass.arel_table[column_name].not_between(value.first..value.last)
209
- when :not_eq
210
- klass.arel_table[column_name].not_eq(value)
211
371
  when :not_eq_all
212
372
  klass.arel_table[column_name].not_eq_all(value)
213
373
  when :not_eq_any
214
374
  klass.arel_table[column_name].not_eq_any(value)
215
- when :not_in
216
- klass.arel_table[column_name].not_in(value)
217
375
  when :not_in_all
218
376
  klass.arel_table[column_name].not_in_all(value)
219
377
  when :not_in_any
220
378
  klass.arel_table[column_name].not_in_any(value)
379
+ when :matches_all
380
+ klass.arel_table[column_name].matches_all(value)
381
+ when :matches_any
382
+ klass.arel_table[column_name].matches_any(value)
383
+ when :does_not_match_all
384
+ klass.arel_table[column_name].does_not_match_all(value)
385
+ when :does_not_match_any
386
+ klass.arel_table[column_name].does_not_match_any(value)
221
387
  else
388
+ # Handle custom lambda predicates
222
389
  unless predicate.respond_to?(:call)
223
390
  raise ArgumentError,
224
391
  "unsupported predicate `#{predicate}`"
225
392
  end
226
393
 
394
+ # Call the custom predicate with collection and value
227
395
  predicate.call(collection, value)
228
396
  end
229
397
  end
230
398
 
231
- # Types allowed by default for filter action.
399
+ # Override the default permitted types to allow Arrays for filter parameters.
400
+ # Filtering supports more flexible parameter types compared to sorting/pagination
401
+ # since filter values can be arrays of values for certain predicates.
402
+ #
403
+ # @return [Array<Class>] Array of permitted parameter types for filtering
404
+ # @private
232
405
  def foa_default_permitted_types
233
406
  [ActionController::Parameters, Hash, Array]
234
407
  end
@@ -1,68 +1,126 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetcheableOnApi
4
- # Pageable implements pagination support.
4
+ # Pageable implements support for JSONAPI-compliant pagination via `page` query parameters.
5
+ #
6
+ # This module enables controllers to process pagination parameters in the format:
7
+ # `page[number]=2&page[size]=25` following the JSONAPI specification for page-based pagination.
5
8
  #
6
9
  # It handles the controller parameters:
10
+ # - `page[number]` - The requested page number (default: 1)
11
+ # - `page[size]` - Number of records per page (default: from configuration)
12
+ #
13
+ # If no `page` parameter is present on the request, the full collection is returned.
14
+ #
15
+ # The following pagination information is automatically added to response headers:
16
+ # - `Pagination-Current-Page` - The page number that is returned
17
+ # - `Pagination-Per` - The number of records included in the page
18
+ # - `Pagination-Total-Pages` - The total number of pages available
19
+ # - `Pagination-Total-Count` - The total number of records available
7
20
  #
8
- # - <code>page[:number]</code> the requested page (default: 1).
9
- # - <code>page[:size]</code> number of records per page.
21
+ # @example Basic pagination setup
22
+ # class UsersController < ApplicationController
23
+ # def index
24
+ # users = apply_fetcheable(User.all)
25
+ # render json: users
26
+ # # Response headers will include pagination info
27
+ # end
28
+ # end
10
29
  #
11
- # If no <code>page</code> parameter is present on the request, the full collection is
12
- # returned.
30
+ # # GET /users?page[number]=2&page[size]=10
13
31
  #
14
- # The following pagination information is add to the response headers:
32
+ # @example With custom default page size
33
+ # # In config/initializers/fetcheable_on_api.rb
34
+ # FetcheableOnApi.configure do |config|
35
+ # config.pagination_default_size = 50
36
+ # end
15
37
  #
16
- # - <code>Pagination-Current-Page</code> the page that is returned.
17
- # - <code>Pagination-Per</code> the number of records included in the page.
18
- # - <code>Pagination-Total-Pages</code> the total number of pages available.
19
- # - <code>Pagination-Total-Count</code> the total number of records available.
38
+ # @example Response headers
39
+ # # Pagination-Current-Page: 2
40
+ # # Pagination-Per: 10
41
+ # # Pagination-Total-Pages: 15
42
+ # # Pagination-Total-Count: 150
20
43
  #
44
+ # @see https://jsonapi.org/format/#fetching-pagination JSONAPI Pagination Specification
21
45
  module Pageable
22
- #
23
- # Supports
24
- #
46
+ # Protected instance methods for pagination functionality
25
47
 
26
- #
27
- # Public class methods
28
- #
29
-
30
- #
31
- # Public instance methods
32
- #
48
+ protected
33
49
 
50
+ # Apply pagination to the collection based on page query parameters.
51
+ # This is the main method that processes page parameters and applies
52
+ # limit/offset to the ActiveRecord relation while setting response headers.
34
53
  #
35
- # Protected instance methods
54
+ # @param collection [ActiveRecord::Relation] The collection to paginate
55
+ # @return [ActiveRecord::Relation] The paginated collection with limit and offset applied
56
+ # @raise [FetcheableOnApi::ArgumentError] When page parameters are invalid
36
57
  #
37
- protected
38
-
58
+ # @example
59
+ # # With params: { page: { number: 2, size: 10 } }
60
+ # paginated_users = apply_pagination(User.all)
61
+ # # Generates: LIMIT 10 OFFSET 10
62
+ # # Sets headers: Pagination-Current-Page: 2, Pagination-Per: 10, etc.
39
63
  def apply_pagination(collection)
64
+ # Return early if no pagination parameters are provided
40
65
  return collection if params[:page].blank?
41
66
 
67
+ # Validate that page parameters are properly formatted
42
68
  foa_valid_parameters!(:page)
43
69
 
70
+ # Extract pagination values and count total records
44
71
  limit, offset, count, page = extract_pagination_informations(collection)
72
+
73
+ # Set pagination headers for the response
45
74
  define_header_pagination(limit, count, page)
46
75
 
76
+ # Apply limit and offset to the collection
47
77
  collection.limit(limit).offset(offset)
48
78
  end
49
79
 
50
80
  private
51
81
 
82
+ # Set pagination information in the response headers.
83
+ # These headers provide clients with information about the current page
84
+ # and total number of records/pages available.
85
+ #
86
+ # @param limit [Integer] Number of records per page
87
+ # @param count [Integer] Total number of records
88
+ # @param page [Integer] Current page number
89
+ # @private
52
90
  def define_header_pagination(limit, count, page)
53
- response.headers["Pagination-Current-Page"] = page
54
- response.headers["Pagination-Per"] = limit
55
- response.headers["Pagination-Total-Pages"] = (count.to_f / limit.to_f).ceil
56
- response.headers["Pagination-Total-Count"] = count
91
+ response.headers['Pagination-Current-Page'] = page
92
+ response.headers['Pagination-Per'] = limit
93
+ response.headers['Pagination-Total-Pages'] = limit > 0 ? (count.to_f / limit.to_f).ceil : 0
94
+ response.headers['Pagination-Total-Count'] = count
57
95
  end
58
96
 
97
+ # Extract and calculate pagination information from parameters and collection.
98
+ # This method processes the page parameters and calculates the appropriate
99
+ # limit, offset, total count, and current page number.
100
+ #
101
+ # @param collection [ActiveRecord::Relation] The collection to paginate
102
+ # @return [Array<Integer>] Array containing [limit, offset, count, page]
103
+ #
104
+ # @example
105
+ # # With params: { page: { number: 3, size: 20 } }
106
+ # limit, offset, count, page = extract_pagination_informations(User.all)
107
+ # # => [20, 40, 150, 3] (20 per page, skip 40 records, 150 total, page 3)
108
+ #
109
+ # @private
59
110
  def extract_pagination_informations(collection)
111
+ # Get page size from parameters or use configured default
60
112
  limit = params[:page].fetch(
61
113
  :size, FetcheableOnApi.configuration.pagination_default_size
62
114
  ).to_i
63
115
 
116
+ # Get page number from parameters or default to 1
64
117
  page = params[:page].fetch(:number, 1).to_i
118
+
119
+ # Calculate offset based on page number and size
65
120
  offset = (page - 1) * limit
121
+
122
+ # Count total records excluding any existing pagination/ordering
123
+ # This ensures we get the total count before any pagination is applied
66
124
  count = collection.except(:offset, :limit, :order).count
67
125
 
68
126
  [limit, offset, count, page]