filterameter 1.1.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02daf1402e83a27c8b13ed7e548dfe269b7a1e6c7c5085f9aad2220509793253
4
- data.tar.gz: 1ddcce714232b71be40272ac3e84f116e38af380928407936a082ade5395c743
3
+ metadata.gz: 4184e23f30b4d9771a19a4a5c48bff17fb32b8dabc7d9ed3895beadcfb859d14
4
+ data.tar.gz: b62d21e81555a74e9f7e151355093ac9f77f44b774a880a5061832b2c1411fee
5
5
  SHA512:
6
- metadata.gz: b33c74905694f9a1682a4c7faefb6e11cbeff81a7c2bae32dd3a39f25393d50a7eb86fc766825f9296c2ce19440aed296eab17bebfd6f1eac77a41aa749dbeeb
7
- data.tar.gz: 36f85f875a0c15258988cbbf97062caa75e1e47de1142484eaa96b8bae7f757d5a94e5fd5ac07b2137db7f1d8cb146adb76b3d6aa2849ad3507707e22b87b783
6
+ metadata.gz: da210337a494a23ff4779a935e1ff1c281894b9e021c395658e9204ca71c5358f77ccbe6e7623e8afea03c82e6077d8a7df0b3b49df9ae47addb271795fd17f8
7
+ data.tar.gz: 3264ea77862d726d0b31ec2802089f627be2187290dce49704995684549dc1afdaa956f1775369bca2872a02cdc33184278c27f198b0edb8fdd5d7d7ebabc698
data/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/filterameter.svg)](https://badge.fury.io/rb/filterameter)
2
2
  [![RuboCop](https://github.com/RockSolt/filterameter/workflows/RuboCop/badge.svg)](https://github.com/RockSolt/filterameter/actions?query=workflow%3ARuboCop)
3
3
  [![RSpec](https://github.com/RockSolt/filterameter/workflows/RSpec/badge.svg)](https://github.com/RockSolt/filterameter/actions?query=workflow%3ARSpec)
4
+ [![Ruby Users Forum](https://img.shields.io/discourse/topics?server=https%3A%2F%2Fwww.rubyforum.org&style=flat&logo=discourse&label=Ruby%20Users%20Forum)](https://www.rubyforum.org/tag/filterameter)
4
5
 
5
6
  # Filterameter
7
+
6
8
  Filterameter provides declarative filters for Rails controllers to reduce boilerplate code and increase readability. How many times have you seen (or written) this controller action?
7
9
 
8
10
  ```ruby
@@ -33,6 +35,7 @@ It's redundant code and a bit of a pain to write and maintain. Not to mention wh
33
35
  Simplify and speed development of Rails controllers by making filter parameters declarative with Filterameter.
34
36
 
35
37
  ## Table of Contents
38
+
36
39
  - [Getting Started](#getting-started)
37
40
  - [Usage](#usage)
38
41
  - [Filtering Options](#filtering-options)
@@ -46,10 +49,12 @@ Simplify and speed development of Rails controllers by making filter parameters
46
49
  - [Scope Filters](#scope-filters)
47
50
  - [Sorting](#sorting)
48
51
  - [Building the Query](#building-the-query)
52
+ - [Building Links](#building-links)
49
53
  - [Specifying the Model](#specifying-the-model)
50
54
  - [Configuration](#configuration)
51
55
  - [Testing Declarations](#testing-declarations)
52
56
  - [Forms and Query Parameters](#forms-and-query-parameters)
57
+ - [Community](#community)
53
58
  - [Contribute](#contribute)
54
59
  - [License](#license)
55
60
 
@@ -58,6 +63,7 @@ Simplify and speed development of Rails controllers by making filter parameters
58
63
  This gem requires Rails 6.1+, and works with ActiveRecord.
59
64
 
60
65
  ### Installation
66
+
61
67
  Add this line to your application's Gemfile:
62
68
 
63
69
  ```ruby
@@ -65,16 +71,19 @@ gem 'filterameter'
65
71
  ```
66
72
 
67
73
  And then execute:
74
+
68
75
  ```bash
69
76
  $ bundle install
70
77
  ```
71
78
 
72
79
  Or install it yourself as:
80
+
73
81
  ```bash
74
82
  $ gem install filterameter
75
83
  ```
76
84
 
77
85
  ## Usage
86
+
78
87
  Include module `Filterameter::DeclarativeFilters` in the controller to provide the filter DSL. It can be included in the `ApplicationController` to make the functionality available to all controllers or it can be mixed in on a case-by-case basis.
79
88
 
80
89
  ```ruby
@@ -101,6 +110,7 @@ filters :color,
101
110
  The following options can be specified for each filter.
102
111
 
103
112
  #### name
113
+
104
114
  If the name of the parameter is different than the name of the attribute or scope, then use the name parameter to specify the name of the attribute or scope. For example, if the attribute name is `current_status` but the filter is exposed simply as `status` use the following:
105
115
 
106
116
  ```ruby
@@ -110,6 +120,7 @@ filter :status, name: :current_status
110
120
  This option can also be helpful with nested filters so that the query parameter can be prefixed with the model name. See the `association` option for an example.
111
121
 
112
122
  #### association
123
+
113
124
  If the attribute or scope is nested, it can be referenced by naming the association. For example, if the manager_id attribute lives on an employee's department record, use the following:
114
125
 
115
126
  ```ruby
@@ -127,6 +138,7 @@ If an association is a `has_many` [the distinct method](https://api.rubyonrails.
127
138
  _Limitation:_ If there is more than one association to the same table _and_ both associations can be part of the query, then you cannot use a nested filter directly. Instead, build a scope that disambiguates the associations then build a filter against that scope.
128
139
 
129
140
  #### validates
141
+
130
142
  If the filter value should be validated, use the `validates` option along with [ActiveModel validations](https://api.rubyonrails.org/classes/ActiveModel/Validations/ClassMethods.html#method-i-validates). Here's an example of the inclusion validator being used to restrict sizes:
131
143
 
132
144
  ```ruby
@@ -139,9 +151,10 @@ The `inclusion` validator has been overridden to provide the additional option `
139
151
  filter :size, validates: { inclusion: { in: %w[Small Medium Large], allow_multiple_values: true } }
140
152
  ```
141
153
 
142
-
143
154
  #### partial
155
+
144
156
  Specify the partial option if the filter should do a partial search (SQL's `LIKE`). The partial option accepts a hash to specify the search behavior. Here are the available options:
157
+
145
158
  - match: anywhere (default), from_start, dynamic
146
159
  - case_sensitive: true, false (default)
147
160
 
@@ -154,14 +167,17 @@ filter :reason, partial: { match: :dynamic, case_sensitive: true }
154
167
  ```
155
168
 
156
169
  The `match` options defines where you are searching (which then controls where the wildcard(s) appear):
170
+
157
171
  - anywhere: adds wildcards at the start and end, for example '%blue%'
158
172
  - from_start: adds a wildcard at the end, for example 'blue%'
159
173
  - dynamic: adds no wildcards; this enables the client to fully control the search string
160
174
 
161
175
  #### range
176
+
162
177
  Specify the range option to enable searches by ranges, minimum values, or maximum values. (All of these are inclusive. A search for a minimum value of $10.00 would include all items priced at $10.00.)
163
178
 
164
179
  Here are the available options:
180
+
165
181
  - true: enable ranges, minimum values, and/or maximum values
166
182
  - min_only: enables minimum values
167
183
  - max_only: enables maximum values
@@ -276,7 +292,6 @@ There are two ways to apply the filters and build the query, depending on how mu
276
292
  - Use the `build_filtered_query` before action callback
277
293
  - Manually call `build_query_from_filters`
278
294
 
279
-
280
295
  #### Use the `build_filtered_query` before action callback
281
296
 
282
297
  Add before action callback `build_filtered_query` for controller actions that should build the query. This can be done either in the `ApplicationController` or on a case-by-case basis.
@@ -350,6 +365,35 @@ The starting query is also a good place to provide any includes to enable eager
350
365
 
351
366
  Note that the starting query provides the model, so the model is not looked up and the `model_name` declaration in not needed.
352
367
 
368
+ ### Building Links
369
+
370
+ Because Filterameter knows all about the filter and sort parameters in the query string, it is also able to build similar links. For example, sorting by a different column or direction should still carry all the same filters and page parameters; or pagination might require links with the same filtering and sorting but a different page number or page size.
371
+
372
+ The `DeclarativeFilters` mixin exposes method `query_parameters`, which returns an instance of QueryParameters. The object can be passed to views to build pagination and sort links.
373
+
374
+ The following methods are available:
375
+
376
+ | Method | Updates | Preserves | Resets |
377
+ | ---------- | --------- | ---------------------------- | ------ |
378
+ | `for_page` | Page | Filters, sort, and page size | — |
379
+ | `for_size` | Page size | Filters and sort | Page |
380
+ | `for_sort` | Sort | Filters and page size | Page |
381
+
382
+ ```ruby
383
+ products_path(query_parameters.for_page(2))
384
+ products_path(query_parameters.for_size(100))
385
+ products_path(query_parameters.for_sort(:name, initial_direction: :asc))
386
+ ```
387
+
388
+ `for_sort` cycles a field from its initial direction to the opposite direction and then removes the explicit sort. When a controller default sort is active, it removes the explicit sort only when doing so changes the effective ordering; otherwise it returns the field to the initial direction so the link is not a no-op.
389
+
390
+ #### Sort Helpers
391
+
392
+ The `query_parameters` object also provides helpers to provide info on the current sort for each field name. The following methods are available:
393
+
394
+ - `sorted_by?(name)` - returns true if the current sort includes the specified name
395
+ - `sort_direction(name)` - returns the current sort direction for the given name, or nil if the name is not part of the current sort
396
+
353
397
  ### Specifying the Model
354
398
 
355
399
  Rails conventions are used to determine the controller's model. For example, the PhotosController builds a query against the Photo model. If a controller is namespaced, the model will first be looked up without the namespace, then with the namespace.
@@ -364,11 +408,13 @@ _Important:_ If the `filter_model` declaration is used, it must be before any fi
364
408
 
365
409
  ## Configuration
366
410
 
367
- There are three configuration options:
411
+ The following configuration options are available:
368
412
 
369
413
  - action_on_undeclared_parameters
370
414
  - action_on_validation_failure
371
415
  - filter_key
416
+ - pagination_page_param
417
+ - pagination_size_param
372
418
 
373
419
  The configuration options can be set in an initializer, an environment file, or in `application.rb`.
374
420
 
@@ -383,6 +429,8 @@ Filterameter.configure do |config|
383
429
  config.action_on_undeclared_parameters = :log
384
430
  config.action_on_validation_failure = :log
385
431
  config.filter_key = :f
432
+ config.pagination_page_param = %i[page number]
433
+ config.pagination_size_param = %i[page size]
386
434
  end
387
435
  ```
388
436
 
@@ -402,6 +450,23 @@ If the filter parameters are NOT nested, set this to false. Doing so will restri
402
450
  those that have been declared, meaning undeclared parameters are ignored (and the action_on_undeclared_parameters
403
451
  configuration option does not come into play).
404
452
 
453
+ #### Pagination Parameters
454
+
455
+ `pagination_page_param` and `pagination_size_param` are arrays describing the parameter paths used for the page
456
+ number and page size. They default to `%i[page number]` and `%i[page size]`, which produce `page[number]` and
457
+ `page[size]`. Page-size values are preserved when generating page and sort links.
458
+
459
+ For top-level parameters, such as the common Pagy or Kaminari convention, configure single-key paths (which do not need to be arrays):
460
+
461
+ ```ruby
462
+ Filterameter.configure do |config|
463
+ config.pagination_page_param = :page
464
+ config.pagination_size_param = :per_page
465
+ end
466
+ ```
467
+
468
+ This produces `page=2&per_page=50`. Parameter paths may use any nesting or key names, except that they cannot be rooted at the configured `filter_key`. The filter namespace is reserved for filter and sort parameters.
469
+
405
470
  ## Testing Declarations
406
471
 
407
472
  The declarations can be tested for each controller, catching typos, incorrectly defined scopes, or any other issues. Method `declarations_validator` is added to each controller, and a single controller test can be added to validate all the declarations for that controller.
@@ -458,6 +523,10 @@ For example, the following sorts by size descending:
458
523
 
459
524
  `/widgets?filter[sort]=-size`
460
525
 
526
+ ## Community
527
+
528
+ Join us in the `filterameter` category on the [Ruby Users Forum](https://www.rubyforum.org/tag/filterameter).
529
+
461
530
  ## Contribute
462
531
 
463
532
  Feedback, feature requests, and proposed changes are welcomed. Please use the [issue tracker](https://github.com/RockSolt/filterameter/issues)
@@ -489,4 +558,5 @@ bundle exec appraisal rspec
489
558
  ```
490
559
 
491
560
  ## License
561
+
492
562
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -7,6 +7,8 @@ module Filterameter
7
7
  # * action_on_undeclared_parameters
8
8
  # * action_on_validation_failure
9
9
  # * filter_key
10
+ # * pagination_page_param
11
+ # * pagination_size_param
10
12
  #
11
13
  # ## Action on Undeclared Parameters
12
14
  #
@@ -29,21 +31,77 @@ module Filterameter
29
31
  # restrict the filter parameters to only those that have been declared, meaning
30
32
  # undeclared parameters are ignored (and the action_on_undeclared_parameters
31
33
  # configuration option does not come into play).
34
+ #
35
+ # ## Pagination Parameters
36
+ #
37
+ # Pagination parameter paths are arrays of keys. They default to
38
+ # `[:page, :number]` and `[:page, :size]`, producing the nested parameters
39
+ # `page[number]` and `page[size]`. Use a single-key path, such as `[:page]`
40
+ # and `[:per_page]`, for top-level pagination parameters. Pagination paths
41
+ # cannot be rooted at the configured filter key.
32
42
  class Configuration
33
- attr_accessor :action_on_undeclared_parameters, :action_on_validation_failure, :filter_key
43
+ attr_accessor :action_on_undeclared_parameters, :action_on_validation_failure
44
+ attr_reader :pagination_page_param, :pagination_size_param, :filter_key
34
45
 
35
46
  def initialize
36
- @action_on_undeclared_parameters =
37
- @action_on_validation_failure =
38
- if Rails.env.development?
39
- :log
40
- elsif Rails.env.test?
41
- :raise
42
- else
43
- false
44
- end
47
+ @action_on_undeclared_parameters = @action_on_validation_failure = default_action
45
48
 
46
49
  @filter_key = :filter
50
+ configure_pagination
51
+ end
52
+
53
+ def filter_key=(key)
54
+ validate_pagination_paths(key)
55
+ @filter_key = key
56
+ end
57
+
58
+ def pagination_page_param=(param)
59
+ path = pagination_param(param)
60
+ validate_pagination_path(path, :pagination_page_param)
61
+ @pagination_page_param = path
62
+ end
63
+
64
+ def pagination_size_param=(param)
65
+ path = pagination_param(param)
66
+ validate_pagination_path(path, :pagination_size_param)
67
+ @pagination_size_param = path
68
+ end
69
+
70
+ private
71
+
72
+ def default_action
73
+ if Rails.env.development?
74
+ :log
75
+ elsif Rails.env.test?
76
+ :raise
77
+ else
78
+ false
79
+ end
80
+ end
81
+
82
+ def configure_pagination
83
+ self.pagination_page_param = %i[page number]
84
+ self.pagination_size_param = %i[page size]
85
+ end
86
+
87
+ def pagination_param(param)
88
+ path = Array(param).map(&:to_sym)
89
+ raise ArgumentError, 'pagination parameter path cannot be empty' if path.empty?
90
+
91
+ path
92
+ end
93
+
94
+ def validate_pagination_paths(filter_key)
95
+ return unless filter_key
96
+
97
+ validate_pagination_path(@pagination_page_param, :pagination_page_param, filter_key)
98
+ validate_pagination_path(@pagination_size_param, :pagination_size_param, filter_key)
99
+ end
100
+
101
+ def validate_pagination_path(path, name, filter_key = @filter_key)
102
+ return unless filter_key && path&.first == filter_key.to_sym
103
+
104
+ raise ArgumentError, "#{name} cannot be nested under filter_key (#{filter_key.inspect})"
47
105
  end
48
106
  end
49
107
  end
@@ -161,18 +161,28 @@ module Filterameter
161
161
  self.class.filter_coordinator.build_query(filter_parameters, starting_query)
162
162
  end
163
163
 
164
- def filter_parameters
165
- filter_key = Filterameter.configuration.filter_key
164
+ # Returns the current Filterameter query state for pagination and sortable links.
165
+ # Its sort is the requested sort, or this controller's declared default when none was requested.
166
+ def query_parameters
167
+ Filterameter::QueryParameters.new(
168
+ filterameter_request_parameters,
169
+ default_sort: self.class.filter_coordinator.default_sort_parameters
170
+ )
171
+ end
166
172
 
167
- if filter_key
168
- params.to_unsafe_h.fetch(filter_key, {})
169
- else
170
- params.to_unsafe_h.slice(*self.class.filter_coordinator.filter_parameter_names, :sort)
171
- end
173
+ def filter_parameters
174
+ filterameter_request_parameters.filter_and_sort_params
172
175
  end
173
176
 
174
177
  private
175
178
 
179
+ def filterameter_request_parameters
180
+ @filterameter_request_parameters ||= Filterameter::RequestParameters.new(
181
+ params,
182
+ declared_filter_parameter_names: self.class.filter_coordinator.filter_parameter_names
183
+ )
184
+ end
185
+
176
186
  def build_filtered_query
177
187
  var_name = "@#{self.class.filter_coordinator.query_variable_name}"
178
188
  starting_query = instance_variable_get(var_name)
@@ -17,6 +17,7 @@ module Filterameter
17
17
  # The coordinators encapsulate references to the Query Builder and Filter Registry to keep the namespace clean for
18
18
  # controllers that implement filter parameters.
19
19
  class FilterCoordinator
20
+ attr_reader :default_sort_parameters
20
21
  attr_writer :query_variable_name
21
22
 
22
23
  delegate :add_filter, :add_sort, :filter_parameter_names, to: :registry
@@ -40,9 +41,12 @@ module Filterameter
40
41
  end
41
42
 
42
43
  def default_sort=(sort_and_direction_pairs)
43
- @default_sort = sort_and_direction_pairs.map do |name, direction|
44
- Filterameter::Helpers::RequestedSort.new(name, direction)
45
- end
44
+ normalized = Helpers::SortNormalizer.normalize(sort_and_direction_pairs).freeze
45
+
46
+ @default_sort_parameters = normalized
47
+ @default_sort = normalized.map do |name, direction|
48
+ RequestedSort.new(name, direction)
49
+ end.freeze
46
50
  end
47
51
 
48
52
  def declarations_validator
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module Helpers
5
+ # Normalizes the hash representation used for declared and current sorts.
6
+ module SortNormalizer
7
+ module_function
8
+
9
+ def normalize(sort)
10
+ return if sort.nil?
11
+ raise ArgumentError, 'default_sort must be a hash of sort names and directions' unless sort.is_a?(Hash)
12
+
13
+ sort.each_with_object({}) do |(name, direction), result|
14
+ result[name.to_sym] = SortSerializer.normalize_direction(direction)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module Helpers
5
+ # # Sort Serializer
6
+ #
7
+ # Handles conversion between sort string representation and hash representation.
8
+ #
9
+ # String format follows standard conventions:
10
+ # - "name" or "+name" for ascending
11
+ # - "-name" for descending
12
+ #
13
+ # Hash format:
14
+ # - { name: :asc } for ascending
15
+ # - { name: :desc } for descending
16
+ module SortSerializer
17
+ module_function
18
+
19
+ # Converts a sort string to a hash with name and direction
20
+ #
21
+ # @param sort_string [String] the sort string (e.g., "name", "-name")
22
+ # @return [Hash] hash with :name and :direction keys
23
+ #
24
+ # @example
25
+ # deserialize("name") #=> { name: "name", direction: :asc }
26
+ # deserialize("-name") #=> { name: "name", direction: :desc }
27
+ def deserialize(sort_string)
28
+ parsed = sort_string.to_s.match(/(?<sign>[+|-]?)(?<name>\w+)/)
29
+ {
30
+ name: parsed['name'],
31
+ direction: parsed['sign'] == '-' ? :desc : :asc
32
+ }
33
+ end
34
+
35
+ # Converts a name and direction to a sort string
36
+ #
37
+ # @param name [String, Symbol] the sort field name
38
+ # @param direction [Symbol, String] the sort direction (:asc or :desc)
39
+ # @return [String] the serialized sort string
40
+ # @raise [ArgumentError] if direction is not :asc or :desc
41
+ #
42
+ # @example
43
+ # serialize("name", :asc) #=> "name"
44
+ # serialize("name", :desc) #=> "-name"
45
+ def serialize(name, direction)
46
+ case normalize_direction(direction)
47
+ when :asc then name.to_s
48
+ when :desc then "-#{name}"
49
+ end
50
+ end
51
+
52
+ def normalize_direction(direction)
53
+ normalized = direction.to_sym if direction.respond_to?(:to_sym)
54
+ return normalized if %i[asc desc].include?(normalized)
55
+
56
+ raise ArgumentError, 'direction must be :asc or :desc'
57
+ end
58
+ end
59
+ end
60
+ end
@@ -36,7 +36,7 @@ module Filterameter
36
36
  end
37
37
 
38
38
  def parse_sorts(sorts)
39
- Array.wrap(sorts).map { |sort| Helpers::RequestedSort.parse(sort) }
39
+ Array.wrap(sorts).map { |sort| RequestedSort.parse(sort) }
40
40
  end
41
41
 
42
42
  def apply_filters(query, filters)
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ # # Query Parameters
5
+ #
6
+ # Because Filterameter knows all about the filter and sort parameters in the query string, it is also able to
7
+ # generate query parameters for similar links. For example, sorting by a different column or direction should still
8
+ # carry all the same filters and page parameters; or pagination might require links with the same filtering and
9
+ # sorting but a different page number or page size.
10
+ #
11
+ # QueryParameters is a value object containing the current query parameters. It provides helpers for generating
12
+ # updated parameters:
13
+ #
14
+ # - `for_sort`: updates sorting, preserves filters and page size, and resets the page
15
+ # - `for_page`: updates the page, preserves filters, sorting, and page size
16
+ # - `for_size`: updates page size, preserves filters and sorting, and resets the page
17
+ #
18
+ # The return of each method are the arguments that can be passed to Rails path builders.
19
+ class QueryParameters
20
+ attr_reader :filter_params, :sort_order, :requested_sort_order
21
+
22
+ def initialize(request_parameters, default_sort: nil,
23
+ sort_strategy: SortStrategies::ReplacementSortStrategy.new)
24
+ @request_params = request_parameters
25
+ @sort_strategy = sort_strategy
26
+ @default_sort = Helpers::SortNormalizer.normalize(default_sort) || {}
27
+ @filter_params = @request_params.filter_params
28
+ raw_sort = @request_params.sort_params
29
+ @sort_requested = raw_sort.present?
30
+ @requested_sort_order = @sort_requested ? parse_sort(raw_sort) : {}
31
+ @sort_order = @sort_requested ? @requested_sort_order : @default_sort
32
+ end
33
+
34
+ # Returns the current filter and sort state with the page number replaced.
35
+ def for_page(page_number)
36
+ write_at_path(@request_params.filter_sort_and_pagination_params, @request_params.pagination_page_path,
37
+ page_number)
38
+ end
39
+
40
+ def for_size(page_size)
41
+ write_at_path(current_query_params_without_page, @request_params.pagination_size_path, page_size)
42
+ end
43
+
44
+ # Returns the current filter state with the sort updated according to the sort strategy. The page number is
45
+ # omitted, allowing the pagination library to use its configured first page.
46
+ def for_sort(name, initial_direction: :asc)
47
+ normalized_direction = Helpers::SortSerializer.normalize_direction(initial_direction)
48
+ result = @sort_strategy.call(self, name, normalized_direction)
49
+ override_sort(current_query_params_without_page, result)
50
+ end
51
+
52
+ def sorted_by?(name)
53
+ @sort_order.key?(name.to_sym)
54
+ end
55
+
56
+ def sort_requested?
57
+ @sort_requested
58
+ end
59
+
60
+ def default_sort_order
61
+ @default_sort
62
+ end
63
+
64
+ def sort_direction(name)
65
+ @sort_order[name.to_sym]
66
+ end
67
+
68
+ private
69
+
70
+ def current_query_params_without_page
71
+ delete_at_path(@request_params.filter_sort_and_pagination_params, @request_params.pagination_page_path)
72
+ end
73
+
74
+ # Methods `write_at_path` and `delete_at_path` introduce a fair amount of the complexity here. If this could assume
75
+ # how the pagination parameters are stored, it could be much simpler. But the configuration allows for params at the
76
+ # root (such as `page` and `per_page`) or nested under a key (such as `page[number]` and `page[size]`).
77
+ #
78
+ # All of which is to say, you can ignore these methods for the most part. The write method is also used to override
79
+ # the sort, since that can also optionally be nested.
80
+
81
+ def write_at_path(params, path, value)
82
+ container = path[0...-1].reduce(params) do |hash, key|
83
+ hash[key] = {} unless hash[key].is_a?(Hash)
84
+ hash[key]
85
+ end
86
+ container[path.last] = value
87
+ params
88
+ end
89
+
90
+ def delete_at_path(params, path)
91
+ return params unless params.is_a?(Hash)
92
+
93
+ key = path.first
94
+ if path.one?
95
+ params.delete(key)
96
+ elsif (nested_params = params[key]).is_a?(Hash)
97
+ delete_at_path(nested_params, path.drop(1))
98
+ params.delete(key) if nested_params.empty?
99
+ end
100
+ params
101
+ end
102
+
103
+ def override_sort(params, sort)
104
+ path = @request_params.sort_path
105
+ if sort.empty?
106
+ delete_at_path(params, path)
107
+ else
108
+ serialized = sort.map { |name, dir| Helpers::SortSerializer.serialize(name, dir) }
109
+ write_at_path(params, path, serialized.one? ? serialized.first : serialized)
110
+ end
111
+ end
112
+
113
+ def parse_sort(sort)
114
+ Array.wrap(sort).each_with_object({}) do |s, hash|
115
+ parsed = RequestedSort.parse(s.to_s)
116
+ hash[parsed.name.to_sym] = parsed.direction
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ # # Request Parameters
5
+ #
6
+ # Value object that normalizes incoming request params and provides structured access to
7
+ # Filterameter filters, sorting, and pagination.
8
+ #
9
+ # By default, filter and sort params are read from the configured filter-key namespace, while
10
+ # pagination params remain at the top level. When that namespace is disabled, all parameter types
11
+ # are read from the top level.
12
+ #
13
+ # Exposes the configured pagination and sort parameter paths for link generation.
14
+ class RequestParameters
15
+ attr_reader :pagination_page_path, :pagination_size_path, :sort_path
16
+
17
+ def initialize(params, declared_filter_parameter_names: nil)
18
+ @params = normalize(params)
19
+ @declared_filter_parameter_names = normalize_declared_names(declared_filter_parameter_names)
20
+ config = Filterameter.configuration
21
+ @filter_key = config.filter_key
22
+ @sort_path = sort_parameter_path.freeze
23
+ @pagination_page_path = config.pagination_page_param.dup.freeze
24
+ @pagination_size_path = config.pagination_size_param.dup.freeze
25
+ @pagination_roots = pagination_roots.freeze
26
+ end
27
+
28
+ # Returns the filter params, excluding sort and pagination.
29
+ def filter_params
30
+ filter_and_sort_params.except(:sort)
31
+ end
32
+
33
+ # Returns the raw sort value from the request (a string, array of strings, or nil).
34
+ def sort_params
35
+ filter_and_sort_params[:sort]
36
+ end
37
+
38
+ # Returns the filter and sort params, excluding pagination.
39
+ def filter_and_sort_params
40
+ params = if @filter_key
41
+ @params.fetch(filter_key) { @params.fetch(filter_key.to_s, {}) }
42
+ else
43
+ flat_filter_and_sort_params
44
+ end
45
+
46
+ params.deep_dup
47
+ end
48
+
49
+ # Returns the filter, sort, and pagination params — the full set needed for link generation.
50
+ def filter_sort_and_pagination_params
51
+ return flat_query_params.deep_dup unless @filter_key
52
+
53
+ @params.slice(filter_key, *@pagination_roots).deep_dup
54
+ end
55
+
56
+ private
57
+
58
+ def normalize(params)
59
+ hash = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
60
+ hash.deep_symbolize_keys
61
+ end
62
+
63
+ def filter_key
64
+ @filter_key.to_sym
65
+ end
66
+
67
+ def normalize_declared_names(names)
68
+ names&.map(&:to_sym)
69
+ end
70
+
71
+ def sort_parameter_path
72
+ @filter_key ? [@filter_key.to_sym, :sort] : [:sort]
73
+ end
74
+
75
+ def pagination_roots
76
+ [@pagination_page_path.first, @pagination_size_path.first].uniq
77
+ end
78
+
79
+ def flat_filter_and_sort_params
80
+ params = @params.except(*@pagination_roots)
81
+ return params unless @declared_filter_parameter_names
82
+
83
+ params.slice(*@declared_filter_parameter_names, :sort)
84
+ end
85
+
86
+ def flat_query_params
87
+ return @params unless @declared_filter_parameter_names
88
+
89
+ result = @params.slice(*@declared_filter_parameter_names, :sort)
90
+ [@pagination_page_path, @pagination_size_path].each do |path|
91
+ value = value_at_path(@params, path)
92
+ write_at_path(result, path, value) unless value.nil?
93
+ end
94
+ result
95
+ end
96
+
97
+ def value_at_path(params, path)
98
+ path.reduce(params) { |value, key| value.is_a?(Hash) ? value[key] : nil }
99
+ end
100
+
101
+ def write_at_path(params, path, value)
102
+ container = path[0...-1].reduce(params) { |hash, key| hash[key] ||= {} }
103
+ container[path.last] = value
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ # A parsed sort request containing a field name and direction.
5
+ class RequestedSort
6
+ attr_reader :name, :direction
7
+
8
+ def self.parse(sort)
9
+ result = Helpers::SortSerializer.deserialize(sort)
10
+ new(result[:name], result[:direction])
11
+ end
12
+
13
+ def initialize(name, direction)
14
+ @name = name
15
+ @direction = direction
16
+ freeze
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module SortStrategies
5
+ # # Replacement Sort Strategy
6
+ #
7
+ # The ReplacementSortStrategy replaces the current sort with the requested sort. If the field is not currently being
8
+ # sorted, it will be added in the initial direction.If the field is currently being sorted in the initial
9
+ # sorted in the initial direction, the direction will be toggled; if the field is currently being sorted in the
10
+ # opposite direction, the sort will be removed unless doing so would immediately restore the same default sort, in
11
+ # which case it returns to the initial direction instead.
12
+ class ReplacementSortStrategy
13
+ def call(query_params, sort_field, initial_direction)
14
+ return { sort_field => initial_direction } unless query_params.sorted_by?(sort_field)
15
+
16
+ if query_params.sort_direction(sort_field) == initial_direction
17
+ { sort_field => opposite_direction(initial_direction) }
18
+ else
19
+ return { sort_field => initial_direction } unless query_params.sort_requested?
20
+ if query_params.requested_sort_order == query_params.default_sort_order
21
+ return { sort_field => initial_direction }
22
+ end
23
+
24
+ {}
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def opposite_direction(direction)
31
+ direction == :asc ? :desc : :asc
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Filterameter
4
- VERSION = '1.1.0'
4
+ VERSION = '1.2.0'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: filterameter
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Todd Kummer
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-19 00:00:00.000000000 Z
10
+ date: 2026-09-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -178,17 +178,22 @@ files:
178
178
  - lib/filterameter/filters/scope_filter.rb
179
179
  - lib/filterameter/helpers/declaration_with_model.rb
180
180
  - lib/filterameter/helpers/joins_values_builder.rb
181
- - lib/filterameter/helpers/requested_sort.rb
181
+ - lib/filterameter/helpers/sort_normalizer.rb
182
+ - lib/filterameter/helpers/sort_serializer.rb
182
183
  - lib/filterameter/log_subscriber.rb
183
184
  - lib/filterameter/options/partial_options.rb
184
185
  - lib/filterameter/parameters_base.rb
185
186
  - lib/filterameter/query_builder.rb
187
+ - lib/filterameter/query_parameters.rb
186
188
  - lib/filterameter/registries/filter_registry.rb
187
189
  - lib/filterameter/registries/registry.rb
188
190
  - lib/filterameter/registries/sort_registry.rb
189
191
  - lib/filterameter/registries/sub_registry.rb
192
+ - lib/filterameter/request_parameters.rb
193
+ - lib/filterameter/requested_sort.rb
190
194
  - lib/filterameter/sort_declaration.rb
191
195
  - lib/filterameter/sort_factory.rb
196
+ - lib/filterameter/sort_strategies/replacement_sort_strategy.rb
192
197
  - lib/filterameter/sorts/attribute_sort.rb
193
198
  - lib/filterameter/sorts/scope_sort.rb
194
199
  - lib/filterameter/validators/inclusion_validator.rb
@@ -198,6 +203,7 @@ licenses:
198
203
  - MIT
199
204
  metadata:
200
205
  rubygems_mfa_required: 'true'
206
+ mailing_list_uri: https://www.rubyforum.org/tag/filterameter
201
207
  rdoc_options: []
202
208
  require_paths:
203
209
  - lib
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Filterameter
4
- module Helpers
5
- # # Reqested Sort
6
- #
7
- # Class RequestedSort parses the name and direction from a sort segment.
8
- class RequestedSort
9
- SIGN_AND_NAME = /(?<sign>[+|-]?)(?<name>\w+)/
10
- attr_reader :name, :direction
11
-
12
- def self.parse(sort)
13
- parsed = sort.match SIGN_AND_NAME
14
-
15
- new(parsed['name'],
16
- parsed['sign'] == '-' ? :desc : :asc)
17
- end
18
-
19
- def initialize(name, direction)
20
- @name = name
21
- @direction = direction
22
- end
23
- end
24
- end
25
- end