fetcheable_on_api 0.4.1 → 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.
- checksums.yaml +4 -4
- data/ASSOCIATION_SORTING_SOLUTION.md +119 -0
- data/CLAUDE.md +97 -0
- data/Gemfile.lock +54 -39
- data/README.md +311 -1
- data/lib/fetcheable_on_api/configuration.rb +33 -2
- data/lib/fetcheable_on_api/filterable.rb +239 -74
- data/lib/fetcheable_on_api/pageable.rb +85 -27
- data/lib/fetcheable_on_api/sortable.rb +192 -31
- data/lib/fetcheable_on_api/version.rb +5 -1
- data/lib/fetcheable_on_api.rb +160 -42
- data/lib/generators/fetcheable_on_api/install_generator.rb +15 -1
- data/lib/generators/templates/fetcheable_on_api_initializer.rb +14 -0
- metadata +9 -7
@@ -1,12 +1,43 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module FetcheableOnApi
|
4
|
-
# FetcheableOnApi
|
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
|
-
#
|
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`
|
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
|
-
#
|
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,87 +84,148 @@ 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
|
-
#
|
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
|
102
|
+
# Class methods made available to controllers when Filterable is included.
|
44
103
|
module ClassMethods
|
45
|
-
# Define
|
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
|
-
# @
|
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
|
-
# @
|
50
|
-
# @
|
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(
|
57
|
-
:as, :class_name, :with, :format, :association
|
58
|
-
)
|
59
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
|
60
147
|
self.filters_configuration = filters_configuration.dup
|
61
148
|
|
62
149
|
attrs.each do |attr|
|
150
|
+
# Initialize default configuration for this attribute
|
63
151
|
filters_configuration[attr] ||= {
|
64
|
-
as: options[:as] || attr
|
152
|
+
as: options[:as] || attr
|
65
153
|
}
|
66
154
|
|
155
|
+
# Merge in the provided options
|
67
156
|
filters_configuration[attr].merge!(options)
|
68
157
|
end
|
69
158
|
end
|
70
159
|
end
|
71
160
|
|
72
|
-
#
|
73
|
-
# Public instance methods
|
74
|
-
#
|
161
|
+
# Protected instance methods for filtering functionality
|
75
162
|
|
76
|
-
#
|
77
|
-
# Protected instance methods
|
78
|
-
#
|
79
163
|
protected
|
80
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
|
81
174
|
def valid_keys
|
82
175
|
keys = filters_configuration.keys
|
83
176
|
keys.each_with_index do |key, index|
|
84
177
|
predicate = filters_configuration[key.to_sym].fetch(:with, :ilike)
|
85
178
|
|
86
|
-
|
179
|
+
# Special handling for predicates that work with ranges or arrays
|
180
|
+
if %i[between not_between in in_all in_any].include?(predicate)
|
87
181
|
format = filters_configuration[key.to_sym].fetch(:format) { nil }
|
88
|
-
|
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
|
89
186
|
next
|
90
187
|
end
|
91
188
|
|
189
|
+
# Skip if it's a custom lambda predicate or doesn't expect arrays
|
92
190
|
next if predicate.respond_to?(:call) ||
|
93
191
|
PREDICATES_WITH_ARRAY.exclude?(predicate.to_sym)
|
94
192
|
|
95
|
-
|
193
|
+
# Convert to array format for predicates that expect multiple values
|
194
|
+
keys[index] = { key => [] }
|
96
195
|
end
|
97
196
|
|
98
197
|
keys
|
99
198
|
end
|
100
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%'
|
101
212
|
def apply_filters(collection)
|
213
|
+
# Return early if no filter parameters are provided
|
102
214
|
return collection if params[:filter].blank?
|
103
215
|
|
216
|
+
# Validate that filter parameters are properly formatted
|
104
217
|
foa_valid_parameters!(:filter)
|
105
218
|
|
219
|
+
# Extract and permit only configured filter parameters
|
106
220
|
filter_params = params.require(:filter)
|
107
|
-
.permit(valid_keys)
|
221
|
+
.permit(*valid_keys)
|
108
222
|
.to_hash
|
109
223
|
|
224
|
+
# Process each filter parameter and build Arel predicates
|
110
225
|
filtering = filter_params.map do |column, values|
|
111
226
|
config = filters_configuration[column.to_sym]
|
112
227
|
|
228
|
+
# Extract configuration for this filter
|
113
229
|
format = config.fetch(:format, :string)
|
114
230
|
column_name = config.fetch(:as, column)
|
115
231
|
klass = config.fetch(:class_name, collection.klass)
|
@@ -120,66 +236,126 @@ module FetcheableOnApi
|
|
120
236
|
|
121
237
|
predicate = config.fetch(:with, :ilike)
|
122
238
|
|
239
|
+
# Join association table if filtering on a different model
|
123
240
|
if collection_klass != klass
|
124
241
|
collection = collection.joins(association_class_or_name)
|
125
242
|
end
|
126
243
|
|
244
|
+
# Skip if values is nil or empty
|
245
|
+
next if values.nil? || values == ""
|
246
|
+
|
247
|
+
# Handle range-based predicates (between, not_between)
|
127
248
|
if %i[between not_between].include?(predicate)
|
128
249
|
if values.is_a?(String)
|
129
|
-
|
250
|
+
# Single range: "start,end"
|
251
|
+
predicates(predicate, collection, klass, column_name, values.split(','))
|
130
252
|
else
|
253
|
+
# Multiple ranges: ["start1,end1", "start2,end2"] with OR logic
|
131
254
|
values.map do |value|
|
132
|
-
predicates(predicate, collection, klass, column_name, value.split(
|
255
|
+
predicates(predicate, collection, klass, column_name, value.split(','))
|
133
256
|
end.inject(:or)
|
134
257
|
end
|
135
258
|
elsif values.is_a?(String)
|
136
|
-
values
|
259
|
+
# Single value or comma-separated values with OR logic
|
260
|
+
values.split(',').map do |value|
|
137
261
|
predicates(predicate, collection, klass, column_name, value)
|
138
262
|
end.inject(:or)
|
139
263
|
else
|
140
|
-
|
264
|
+
# Array of values, each potentially comma-separated
|
265
|
+
values.map! { |el| el.split(',') }
|
141
266
|
predicates(predicate, collection, klass, column_name, values)
|
142
267
|
end
|
143
268
|
end
|
144
269
|
|
270
|
+
# Combine all filter predicates with AND logic
|
145
271
|
collection.where(filtering.flatten.compact.inject(:and))
|
146
272
|
end
|
147
273
|
|
148
|
-
#
|
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
|
149
294
|
def predicates(predicate, collection, klass, column_name, value)
|
150
295
|
case predicate
|
296
|
+
# Range predicates - work with two values (start, end)
|
151
297
|
when :between
|
152
298
|
klass.arel_table[column_name].between(value.first..value.last)
|
153
|
-
when :
|
154
|
-
klass.arel_table[column_name].
|
155
|
-
|
156
|
-
|
157
|
-
when :does_not_match_any
|
158
|
-
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
|
159
303
|
when :eq
|
160
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)
|
161
339
|
when :eq_all
|
162
340
|
klass.arel_table[column_name].eq_all(value)
|
163
341
|
when :eq_any
|
164
342
|
klass.arel_table[column_name].eq_any(value)
|
165
|
-
when :gt
|
166
|
-
klass.arel_table[column_name].gt(value)
|
167
343
|
when :gt_all
|
168
344
|
klass.arel_table[column_name].gt_all(value)
|
169
345
|
when :gt_any
|
170
346
|
klass.arel_table[column_name].gt_any(value)
|
171
|
-
when :gteq
|
172
|
-
klass.arel_table[column_name].gteq(value)
|
173
347
|
when :gteq_all
|
174
348
|
klass.arel_table[column_name].gteq_all(value)
|
175
349
|
when :gteq_any
|
176
350
|
klass.arel_table[column_name].gteq_any(value)
|
177
|
-
when :
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
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)
|
183
359
|
when :in_all
|
184
360
|
if value.is_a?(Array)
|
185
361
|
klass.arel_table[column_name].in_all(value.flatten.compact.uniq)
|
@@ -192,51 +368,40 @@ module FetcheableOnApi
|
|
192
368
|
else
|
193
369
|
klass.arel_table[column_name].in_any(value)
|
194
370
|
end
|
195
|
-
when :lt
|
196
|
-
klass.arel_table[column_name].lt(value)
|
197
|
-
when :lt_all
|
198
|
-
klass.arel_table[column_name].lt_all(value)
|
199
|
-
when :lt_any
|
200
|
-
klass.arel_table[column_name].lt_any(value)
|
201
|
-
when :lteq
|
202
|
-
klass.arel_table[column_name].lteq(value)
|
203
|
-
when :lteq_all
|
204
|
-
klass.arel_table[column_name].lteq_all(value)
|
205
|
-
when :lteq_any
|
206
|
-
klass.arel_table[column_name].lteq_any(value)
|
207
|
-
when :ilike
|
208
|
-
klass.arel_table[column_name].matches("%#{value}%")
|
209
|
-
when :matches
|
210
|
-
klass.arel_table[column_name].matches(value)
|
211
|
-
when :matches_all
|
212
|
-
klass.arel_table[column_name].matches_all(value)
|
213
|
-
when :matches_any
|
214
|
-
klass.arel_table[column_name].matches_any(value)
|
215
|
-
when :not_between
|
216
|
-
klass.arel_table[column_name].not_between(value.first..value.last)
|
217
|
-
when :not_eq
|
218
|
-
klass.arel_table[column_name].not_eq(value)
|
219
371
|
when :not_eq_all
|
220
372
|
klass.arel_table[column_name].not_eq_all(value)
|
221
373
|
when :not_eq_any
|
222
374
|
klass.arel_table[column_name].not_eq_any(value)
|
223
|
-
when :not_in
|
224
|
-
klass.arel_table[column_name].not_in(value)
|
225
375
|
when :not_in_all
|
226
376
|
klass.arel_table[column_name].not_in_all(value)
|
227
377
|
when :not_in_any
|
228
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)
|
229
387
|
else
|
388
|
+
# Handle custom lambda predicates
|
230
389
|
unless predicate.respond_to?(:call)
|
231
390
|
raise ArgumentError,
|
232
391
|
"unsupported predicate `#{predicate}`"
|
233
392
|
end
|
234
393
|
|
394
|
+
# Call the custom predicate with collection and value
|
235
395
|
predicate.call(collection, value)
|
236
396
|
end
|
237
397
|
end
|
238
398
|
|
239
|
-
#
|
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
|
240
405
|
def foa_default_permitted_types
|
241
406
|
[ActionController::Parameters, Hash, Array]
|
242
407
|
end
|
@@ -1,68 +1,126 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module FetcheableOnApi
|
4
|
-
# Pageable implements pagination
|
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
|
-
#
|
9
|
-
#
|
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
|
-
#
|
12
|
-
# returned.
|
30
|
+
# # GET /users?page[number]=2&page[size]=10
|
13
31
|
#
|
14
|
-
#
|
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
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
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
|
-
#
|
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
|
-
|
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[
|
54
|
-
response.headers[
|
55
|
-
response.headers[
|
56
|
-
response.headers[
|
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]
|