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.
- checksums.yaml +4 -4
- data/ASSOCIATION_SORTING_SOLUTION.md +119 -0
- data/CLAUDE.md +97 -0
- data/Gemfile.lock +54 -39
- data/README.md +315 -1
- data/lib/fetcheable_on_api/configuration.rb +33 -2
- data/lib/fetcheable_on_api/filterable.rb +250 -77
- 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,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
|
-
#
|
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(: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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
110
|
-
|
111
|
-
|
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
|
-
|
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(
|
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
|
-
|
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
|
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
|
-
|
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
|
-
#
|
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 :
|
146
|
-
klass.arel_table[column_name].
|
147
|
-
|
148
|
-
|
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 :
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
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
|
-
#
|
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
|
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]
|