fetcheable_on_api 0.4.1 → 0.6.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.
@@ -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,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
- # 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
46
116
  #
47
- # @see FetcheableOnApi::Filterable::PREDICATES_WITH_ARRAY
117
+ # @example Basic attribute filtering
118
+ # filter_by :name, :email, :status
119
+ # # Allows: filter[name]=john&filter[email]=john@example.com&filter[status]=active
48
120
  #
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
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)
129
+ #
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
+ # }
136
+ #
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
- 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)
87
181
  format = filters_configuration[key.to_sym].fetch(:format) { nil }
88
- 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
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
- keys[index] = {key => []}
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,133 @@ 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
- predicates(predicate, collection, klass, column_name, values.split(","))
250
+ # Single range: "start,end"
251
+ range_values = values.split(',')
252
+ range_values = apply_format_conversion(range_values, format)
253
+ predicates(predicate, collection, klass, column_name, range_values)
130
254
  else
255
+ # Multiple ranges: ["start1,end1", "start2,end2"] with OR logic
131
256
  values.map do |value|
132
- predicates(predicate, collection, klass, column_name, value.split(","))
257
+ range_values = value.split(',')
258
+ range_values = apply_format_conversion(range_values, format)
259
+ predicates(predicate, collection, klass, column_name, range_values)
133
260
  end.inject(:or)
134
261
  end
135
262
  elsif values.is_a?(String)
136
- values.split(",").map do |value|
263
+ # Single value or comma-separated values with OR logic
264
+ split_values = values.split(',')
265
+ split_values = apply_format_conversion(split_values, format)
266
+ split_values.map do |value|
137
267
  predicates(predicate, collection, klass, column_name, value)
138
268
  end.inject(:or)
139
269
  else
140
- values.map! { |el| el.split(",") }
141
- predicates(predicate, collection, klass, column_name, values)
270
+ # Array of values, each potentially comma-separated
271
+ flat_values = values.map { |el| el.split(',') }.flatten
272
+ converted_values = apply_format_conversion(flat_values, format)
273
+ predicates(predicate, collection, klass, column_name, converted_values)
142
274
  end
143
275
  end
144
276
 
277
+ # Combine all filter predicates with AND logic
145
278
  collection.where(filtering.flatten.compact.inject(:and))
146
279
  end
147
280
 
148
- # Apply arel predicate on collection
281
+ # Build an Arel predicate for the given parameters.
282
+ # This method translates filter predicates into Arel expressions that can
283
+ # be used in ActiveRecord where clauses.
284
+ #
285
+ # @param predicate [Symbol, Proc] The predicate type (:eq, :ilike, :between, etc.) or custom lambda
286
+ # @param collection [ActiveRecord::Relation] The collection being filtered (used for lambda predicates)
287
+ # @param klass [Class] The model class for the attribute being filtered
288
+ # @param column_name [String, Symbol] The database column name to filter on
289
+ # @param value [Object] The filter value(s) to compare against
290
+ # @return [Arel::Node] An Arel predicate node
291
+ # @raise [ArgumentError] When an unsupported predicate is used
292
+ #
293
+ # @example
294
+ # # predicates(:eq, collection, User, 'name', 'john')
295
+ # # Returns: users.name = 'john'
296
+ #
297
+ # # predicates(:between, collection, User, 'age', [18, 65])
298
+ # # Returns: users.age BETWEEN 18 AND 65
299
+ #
300
+ # @private
149
301
  def predicates(predicate, collection, klass, column_name, value)
150
302
  case predicate
303
+ # Range predicates - work with two values (start, end)
151
304
  when :between
152
305
  klass.arel_table[column_name].between(value.first..value.last)
153
- when :does_not_match
154
- klass.arel_table[column_name].does_not_match("%#{value}%")
155
- when :does_not_match_all
156
- klass.arel_table[column_name].does_not_match_all(value)
157
- when :does_not_match_any
158
- klass.arel_table[column_name].does_not_match_any(value)
306
+ when :not_between
307
+ klass.arel_table[column_name].not_between(value.first..value.last)
308
+
309
+ # Equality predicates - exact matching
159
310
  when :eq
160
311
  klass.arel_table[column_name].eq(value)
312
+ when :not_eq
313
+ klass.arel_table[column_name].not_eq(value)
314
+
315
+ # Comparison predicates - numeric/date comparisons
316
+ when :gt
317
+ klass.arel_table[column_name].gt(value)
318
+ when :gteq
319
+ klass.arel_table[column_name].gteq(value)
320
+ when :lt
321
+ klass.arel_table[column_name].lt(value)
322
+ when :lteq
323
+ klass.arel_table[column_name].lteq(value)
324
+
325
+ # Array inclusion predicates - check if value is in a set
326
+ when :in
327
+ if value.is_a?(Array)
328
+ klass.arel_table[column_name].in(value.flatten.compact.uniq)
329
+ else
330
+ klass.arel_table[column_name].in(value)
331
+ end
332
+ when :not_in
333
+ klass.arel_table[column_name].not_in(value)
334
+
335
+ # Pattern matching predicates - for text search
336
+ when :ilike
337
+ # Default predicate - case-insensitive partial matching
338
+ klass.arel_table[column_name].matches("%#{value}%")
339
+ when :matches
340
+ # Exact pattern matching (supports SQL wildcards)
341
+ klass.arel_table[column_name].matches(value)
342
+ when :does_not_match
343
+ klass.arel_table[column_name].does_not_match("%#{value}%")
344
+
345
+ # Array-based predicates (work with multiple values)
161
346
  when :eq_all
162
347
  klass.arel_table[column_name].eq_all(value)
163
348
  when :eq_any
164
349
  klass.arel_table[column_name].eq_any(value)
165
- when :gt
166
- klass.arel_table[column_name].gt(value)
167
350
  when :gt_all
168
351
  klass.arel_table[column_name].gt_all(value)
169
352
  when :gt_any
170
353
  klass.arel_table[column_name].gt_any(value)
171
- when :gteq
172
- klass.arel_table[column_name].gteq(value)
173
354
  when :gteq_all
174
355
  klass.arel_table[column_name].gteq_all(value)
175
356
  when :gteq_any
176
357
  klass.arel_table[column_name].gteq_any(value)
177
- when :in
178
- if value.is_a?(Array)
179
- klass.arel_table[column_name].in(value.flatten.compact.uniq)
180
- else
181
- klass.arel_table[column_name].in(value)
182
- end
358
+ when :lt_all
359
+ klass.arel_table[column_name].lt_all(value)
360
+ when :lt_any
361
+ klass.arel_table[column_name].lt_any(value)
362
+ when :lteq_all
363
+ klass.arel_table[column_name].lteq_all(value)
364
+ when :lteq_any
365
+ klass.arel_table[column_name].lteq_any(value)
183
366
  when :in_all
184
367
  if value.is_a?(Array)
185
368
  klass.arel_table[column_name].in_all(value.flatten.compact.uniq)
@@ -192,51 +375,67 @@ module FetcheableOnApi
192
375
  else
193
376
  klass.arel_table[column_name].in_any(value)
194
377
  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
378
  when :not_eq_all
220
379
  klass.arel_table[column_name].not_eq_all(value)
221
380
  when :not_eq_any
222
381
  klass.arel_table[column_name].not_eq_any(value)
223
- when :not_in
224
- klass.arel_table[column_name].not_in(value)
225
382
  when :not_in_all
226
383
  klass.arel_table[column_name].not_in_all(value)
227
384
  when :not_in_any
228
385
  klass.arel_table[column_name].not_in_any(value)
386
+ when :matches_all
387
+ klass.arel_table[column_name].matches_all(value)
388
+ when :matches_any
389
+ klass.arel_table[column_name].matches_any(value)
390
+ when :does_not_match_all
391
+ klass.arel_table[column_name].does_not_match_all(value)
392
+ when :does_not_match_any
393
+ klass.arel_table[column_name].does_not_match_any(value)
229
394
  else
395
+ # Handle custom lambda predicates
230
396
  unless predicate.respond_to?(:call)
231
397
  raise ArgumentError,
232
398
  "unsupported predicate `#{predicate}`"
233
399
  end
234
400
 
401
+ # Call the custom predicate with collection and value
235
402
  predicate.call(collection, value)
236
403
  end
237
404
  end
238
405
 
239
- # Types allowed by default for filter action.
406
+ # Apply format conversion to values based on the specified format.
407
+ # This method handles value transformation for different data types,
408
+ # particularly datetime conversion using the foa_string_to_datetime method.
409
+ #
410
+ # @param values [Object] The values to convert (String, Array, etc.)
411
+ # @param format [Symbol] The format to apply (:string, :array, :datetime)
412
+ # @return [Object] The converted values
413
+ # @private
414
+ def apply_format_conversion(values, format)
415
+ case format
416
+ when :datetime
417
+ if values.is_a?(Array)
418
+ values.map { |value| foa_string_to_datetime(value.to_s) }
419
+ elsif values.is_a?(String)
420
+ foa_string_to_datetime(values)
421
+ else
422
+ values
423
+ end
424
+ when :array
425
+ # Array format is handled in parameter parsing, not value conversion
426
+ values
427
+ else
428
+ # :string or any other format - no conversion needed
429
+ values
430
+ end
431
+ end
432
+
433
+ # Override the default permitted types to allow Arrays for filter parameters.
434
+ # Filtering supports more flexible parameter types compared to sorting/pagination
435
+ # since filter values can be arrays of values for certain predicates.
436
+ #
437
+ # @return [Array<Class>] Array of permitted parameter types for filtering
438
+ # @private
240
439
  def foa_default_permitted_types
241
440
  [ActionController::Parameters, Hash, Array]
242
441
  end