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,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]
@@ -1,113 +1,274 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetcheableOnApi
4
- # Sortable implements `pagination` support.
4
+ # Sortable implements support for JSONAPI-compliant sorting via `sort` query parameters.
5
+ #
6
+ # This module enables controllers to process sort parameters in the format:
7
+ # `sort=field1,-field2,+field3` where:
8
+ # - No prefix or `+` prefix means ascending order
9
+ # - `-` prefix means descending order
10
+ # - Multiple fields are comma-separated and applied in order
11
+ #
12
+ # It supports:
13
+ # - Single and multiple field sorting
14
+ # - Ascending and descending sort directions
15
+ # - Association sorting with custom class names
16
+ # - Case-insensitive sorting with the `lower` option
17
+ # - Field aliasing for different database column names
18
+ #
19
+ # @example Basic sorting setup
20
+ # class UsersController < ApplicationController
21
+ # sort_by :name, :email, :created_at
22
+ #
23
+ # def index
24
+ # users = apply_fetcheable(User.all)
25
+ # render json: users
26
+ # end
27
+ # end
28
+ #
29
+ # # GET /users?sort=name,-created_at (name asc, created_at desc)
30
+ #
31
+ # @example Association sorting
32
+ # class PostsController < ApplicationController
33
+ # sort_by :title, :created_at
34
+ # sort_by :author, class_name: User, as: 'name'
35
+ #
36
+ # def index
37
+ # posts = apply_fetcheable(Post.joins(:author))
38
+ # render json: posts
39
+ # end
40
+ # end
41
+ #
42
+ # # GET /posts?sort=author,-created_at (by author name asc, then created_at desc)
43
+ #
44
+ # @example Case-insensitive sorting
45
+ # class UsersController < ApplicationController
46
+ # sort_by :name, lower: true # Sort by lowercase name
47
+ # sort_by :email, :created_at
48
+ #
49
+ # def index
50
+ # users = apply_fetcheable(User.all)
51
+ # render json: users
52
+ # end
53
+ # end
54
+ #
55
+ # # GET /users?sort=name (sorts by LOWER(users.name))
56
+ #
57
+ # @see https://jsonapi.org/format/#fetching-sorting JSONAPI Sorting Specification
5
58
  module Sortable
59
+ # Maps sort direction prefixes to Arel sort methods.
60
+ # Used to parse the sort parameter and determine ascending vs descending order.
6
61
  #
7
- # Map of symbol to sorting direction supported by the module.
8
- #
62
+ # @example
63
+ # # "+name" or "name" -> :asc (ascending)
64
+ # # "-name" -> :desc (descending)
9
65
  SORT_ORDER = {
10
- "+" => :asc,
11
- "-" => :desc,
66
+ '+' => :asc, # Explicit ascending (same as no prefix)
67
+ '-' => :desc, # Explicit descending
12
68
  }.freeze
13
69
 
70
+ # Hook called when Sortable is included in a class.
71
+ # Sets up the class to support sort configuration and provides
72
+ # the sort_by class method.
14
73
  #
15
- # Public class methods
16
- #
74
+ # @param base [Class] The class including this module
75
+ # @private
17
76
  def self.included(base)
18
77
  base.class_eval do
19
78
  extend ClassMethods
79
+ # Store sort configurations per class to avoid conflicts between controllers
20
80
  class_attribute :sorts_configuration, instance_writer: false
21
81
  self.sorts_configuration = {}
22
82
  end
23
83
  end
24
84
 
25
- # Class methods made available to your controllers.
85
+ # Class methods made available to controllers when Sortable is included.
26
86
  module ClassMethods
27
- # Define one ore more sortable attribute configurations.
87
+ # Define one or more sortable attributes for the controller.
88
+ #
89
+ # This method configures which model attributes can be sorted via query parameters
90
+ # and how those sorts should be processed.
91
+ #
92
+ # @param attrs [Array<Symbol>] List of attribute names to make sortable
93
+ # @param options [Hash] Configuration options for the sorts
94
+ # @option options [String, Symbol] :as Alias for the database column name
95
+ # @option options [Boolean] :lower Whether to sort on the lowercase version of the attribute
96
+ # @option options [Class] :class_name Model class for association sorting (defaults to collection class)
97
+ # @option options [Symbol] :association Association name when different from inferred name
98
+ #
99
+ # @example Basic attribute sorting
100
+ # sort_by :name, :email, :created_at
101
+ # # Allows: sort=name,-email,created_at
102
+ #
103
+ # @example Case-insensitive sorting
104
+ # sort_by :name, lower: true
105
+ # # Generates: ORDER BY LOWER(users.name)
106
+ #
107
+ # @example Association sorting
108
+ # sort_by :author, class_name: User, as: 'name'
109
+ # # Allows: sort=author (sorts by users.name)
28
110
  #
29
- # @param attrs [Array] options to define one or more sorting
30
- # configurations.
31
- # @option attrs [String, nil] :as Alias the sorted attribute
32
- # @option attrs [true, false, nil] :with Wether to sort on the lowercase
33
- # attribute value.
111
+ # @example Association sorting with custom association name
112
+ # sort_by :author_name, class_name: User, as: 'name', association: :author
113
+ # # Allows: sort=author_name (sorts by users.name via author association)
114
+ # # Note: Make sure your collection is joined: Book.joins(:author)
115
+ #
116
+ # @example Field aliasing
117
+ # sort_by :full_name, as: 'name'
118
+ # # Maps sort=full_name to ORDER BY users.name
34
119
  def sort_by(*attrs)
35
120
  options = attrs.extract_options!
36
121
  options.symbolize_keys!
37
122
 
123
+ # Validate that only supported options are provided
124
+ options.assert_valid_keys(:as, :class_name, :lower, :association)
125
+
126
+ # Create a new configuration hash to avoid modifying parent class config
38
127
  self.sorts_configuration = sorts_configuration.dup
39
128
 
40
129
  attrs.each do |attr|
130
+ # Initialize default configuration for this attribute
41
131
  sorts_configuration[attr] ||= {
42
- as: attr,
132
+ as: attr
43
133
  }
44
134
 
135
+ # Merge in the provided options, overriding defaults
45
136
  sorts_configuration[attr] = sorts_configuration[attr].merge(options)
46
137
  end
47
138
  end
48
139
  end
49
140
 
50
- #
51
- # Public instance methods
52
- #
141
+ # Protected instance methods for sorting functionality
53
142
 
54
- #
55
- # Protected instance methods
56
- #
57
143
  protected
58
144
 
145
+ # Apply sorting to the collection based on sort query parameters.
146
+ # This is the main method that processes the sort parameter and
147
+ # applies ordering to the ActiveRecord relation.
148
+ #
149
+ # @param collection [ActiveRecord::Relation] The collection to sort
150
+ # @return [ActiveRecord::Relation] The sorted collection
151
+ # @raise [FetcheableOnApi::ArgumentError] When sort parameters are invalid
152
+ #
153
+ # @example
154
+ # # With params: { sort: 'name,-created_at' }
155
+ # sorted_users = apply_sort(User.all)
156
+ # # Generates: ORDER BY users.name ASC, users.created_at DESC
59
157
  def apply_sort(collection)
158
+ # Return early if no sort parameters are provided
60
159
  return collection if params[:sort].blank?
61
160
 
161
+ # Validate that sort parameter is a string
62
162
  foa_valid_parameters!(:sort, foa_permitted_types: [String])
63
163
 
164
+ # Parse the sort parameter and build Arel ordering expressions
64
165
  ordering = format_params(params[:sort]).map do |attr_name, sort_method|
65
166
  arel_sort(attr_name, sort_method, collection)
66
167
  end
67
168
 
169
+ # Apply the ordering, filtering out any nil values (unconfigured sorts)
68
170
  collection.order(ordering.compact)
69
171
  end
70
172
 
71
173
  private
72
174
 
175
+ # Build an Arel ordering expression for the given attribute and sort direction.
176
+ # Returns nil if the attribute is not configured for sorting or doesn't exist on the model.
177
+ #
178
+ # @param attr_name [Symbol] The attribute name to sort by
179
+ # @param sort_method [Symbol] The sort direction (:asc or :desc)
180
+ # @param collection [ActiveRecord::Relation] The collection being sorted
181
+ # @return [Arel::Node, nil] An Arel ordering node or nil if invalid
182
+ # @private
73
183
  def arel_sort(attr_name, sort_method, collection)
184
+ # Skip if this attribute is not configured for sorting
74
185
  return if sorts_configuration[attr_name].blank?
75
186
 
76
187
  klass = class_for(attr_name, collection)
77
188
  field = field_for(attr_name)
189
+
190
+ # Skip if the field doesn't exist on the model
78
191
  return unless belong_to_attributes_for?(klass, field)
79
192
 
193
+ # Build the Arel attribute reference using the appropriate table
80
194
  attribute = klass.arel_table[field]
81
- attribute = attribute.lower if sorts_configuration[attr_name].fetch(:lower, false)
82
195
 
196
+ # Apply lowercase transformation if configured
197
+ config = sorts_configuration[attr_name] || {}
198
+ attribute = attribute.lower if config.fetch(:lower, false)
199
+
200
+ # Apply the sort direction (asc or desc)
83
201
  attribute.send(sort_method)
84
202
  end
85
203
 
204
+ # Determine the model class to use for this sort attribute.
205
+ # Uses the configured class_name or falls back to the collection's class.
206
+ #
207
+ # @param attr_name [Symbol] The attribute name
208
+ # @param collection [ActiveRecord::Relation] The collection being sorted
209
+ # @return [Class] The model class to use
210
+ # @private
86
211
  def class_for(attr_name, collection)
87
- sorts_configuration[attr_name].fetch(:class_name, collection.klass)
212
+ config = sorts_configuration[attr_name] || {}
213
+ config.fetch(:class_name, collection.klass)
88
214
  end
89
215
 
216
+ # Get the database field name for this sort attribute.
217
+ # Uses the configured alias (:as option) or the attribute name itself.
218
+ #
219
+ # @param attr_name [Symbol] The attribute name
220
+ # @return [String] The database column name
221
+ # @private
90
222
  def field_for(attr_name)
91
- sorts_configuration[attr_name].fetch(:as, attr_name).to_s
223
+ config = sorts_configuration[attr_name] || {}
224
+ config.fetch(:as, attr_name).to_s
92
225
  end
93
226
 
227
+ # Check if the given field exists as an attribute on the model class.
228
+ # This prevents SQL errors from trying to sort by non-existent columns.
229
+ #
230
+ # @param klass [Class] The model class
231
+ # @param field [String] The field name to check
232
+ # @return [Boolean] True if the field exists on the model
233
+ # @private
94
234
  def belong_to_attributes_for?(klass, field)
95
235
  klass.attribute_names.include?(field)
96
236
  end
97
237
 
238
+ # Parse the sort parameter string into a hash of attributes and directions.
98
239
  #
99
- # input: "-email,first_name"
100
- # return: { email: :desc, first_name: :asc }
240
+ # This method takes a comma-separated string of sort fields (with optional
241
+ # direction prefixes) and converts it into a hash mapping field names to
242
+ # sort directions.
101
243
  #
244
+ # @param params [String] The sort parameter string
245
+ # @return [Hash{Symbol => Symbol}] Hash mapping attribute names to sort directions
246
+ #
247
+ # @example
248
+ # format_params("-email,first_name,+last_name")
249
+ # # => { email: :desc, first_name: :asc, last_name: :asc }
250
+ #
251
+ # format_params("name")
252
+ # # => { name: :asc }
253
+ #
254
+ # @private
102
255
  def format_params(params)
103
- res = {}
256
+ result = {}
257
+
104
258
  params
105
- .split(",")
259
+ .split(',') # Split on commas to get individual fields
106
260
  .each do |attribute|
107
- sort_sign = attribute =~ /\A[+-]/ ? attribute.slice!(0) : "+"
108
- res[attribute.to_sym] = SORT_ORDER[sort_sign]
261
+ # Trim whitespace
262
+ attribute = attribute.strip
263
+
264
+ # Extract the direction prefix (+ or -) or default to +
265
+ sort_sign = attribute =~ /\A[+-]/ ? attribute.slice!(0) : '+'
266
+
267
+ # Map the field name to its sort direction
268
+ result[attribute.to_sym] = SORT_ORDER[sort_sign]
109
269
  end
110
- res
270
+
271
+ result
111
272
  end
112
273
  end
113
274
  end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FetcheableOnApi
4
- VERSION = '0.4.1'.freeze
4
+ # Current version of the FetcheableOnApi gem.
5
+ # Follows semantic versioning (semver.org) principles.
6
+ #
7
+ # @return [String] The version string in format "MAJOR.MINOR.PATCH"
8
+ VERSION = '0.6.1'.freeze
5
9
  end