filterameter 1.0.3 → 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: 50c824121830196b05ccb71f43495ad4d5197f85fe9d31456edc549fd2462a59
4
- data.tar.gz: 4bb268b89c285e6542b0347bb3c52ca903f4d9be517f33b6303a76200b36f6c6
3
+ metadata.gz: 4184e23f30b4d9771a19a4a5c48bff17fb32b8dabc7d9ed3895beadcfb859d14
4
+ data.tar.gz: b62d21e81555a74e9f7e151355093ac9f77f44b774a880a5061832b2c1411fee
5
5
  SHA512:
6
- metadata.gz: 21e92b0610890ed0ccbd8e3bdba68864de0c28d1c1ea42af40e51e696b0950c0ed6dd2d085673e493f400ca875d0307eeaaf9b844d021f1ae0d2fe11d53420c7
7
- data.tar.gz: 77edd1277a98e49ab6c6425fe387404176477afc9050bafbb4d3d0eb34268d0e0e4a1f2983fb3b4951a0c9e7c88a0309464ab11112d67ae401ebcb75f7a74617
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)
@@ -42,13 +45,16 @@ Simplify and speed development of Rails controllers by making filter parameters
42
45
  - [Partial](#partial)
43
46
  - [Range](#range)
44
47
  - [Sortable](#sortable)
48
+ - [Converters](#converters)
45
49
  - [Scope Filters](#scope-filters)
46
50
  - [Sorting](#sorting)
47
51
  - [Building the Query](#building-the-query)
52
+ - [Building Links](#building-links)
48
53
  - [Specifying the Model](#specifying-the-model)
49
54
  - [Configuration](#configuration)
50
55
  - [Testing Declarations](#testing-declarations)
51
56
  - [Forms and Query Parameters](#forms-and-query-parameters)
57
+ - [Community](#community)
52
58
  - [Contribute](#contribute)
53
59
  - [License](#license)
54
60
 
@@ -57,6 +63,7 @@ Simplify and speed development of Rails controllers by making filter parameters
57
63
  This gem requires Rails 6.1+, and works with ActiveRecord.
58
64
 
59
65
  ### Installation
66
+
60
67
  Add this line to your application's Gemfile:
61
68
 
62
69
  ```ruby
@@ -64,16 +71,19 @@ gem 'filterameter'
64
71
  ```
65
72
 
66
73
  And then execute:
74
+
67
75
  ```bash
68
76
  $ bundle install
69
77
  ```
70
78
 
71
79
  Or install it yourself as:
80
+
72
81
  ```bash
73
82
  $ gem install filterameter
74
83
  ```
75
84
 
76
85
  ## Usage
86
+
77
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.
78
88
 
79
89
  ```ruby
@@ -82,6 +92,9 @@ Include module `Filterameter::DeclarativeFilters` in the controller to provide t
82
92
  filter :brand_name, association: :brand, name: :name
83
93
  filter :on_sale, association: :price, validates: [{ numericality: { greater_than: 0 } },
84
94
  { numericality: { less_than: 100 } }]
95
+ filter :amount do |value|
96
+ value.is_a?(String) ? value.delete(",") : value
97
+ end
85
98
  ```
86
99
 
87
100
  Filters without options can be declared all at once with `filters`:
@@ -97,6 +110,7 @@ filters :color,
97
110
  The following options can be specified for each filter.
98
111
 
99
112
  #### name
113
+
100
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:
101
115
 
102
116
  ```ruby
@@ -106,6 +120,7 @@ filter :status, name: :current_status
106
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.
107
121
 
108
122
  #### association
123
+
109
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:
110
125
 
111
126
  ```ruby
@@ -123,6 +138,7 @@ If an association is a `has_many` [the distinct method](https://api.rubyonrails.
123
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.
124
139
 
125
140
  #### validates
141
+
126
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:
127
143
 
128
144
  ```ruby
@@ -135,9 +151,10 @@ The `inclusion` validator has been overridden to provide the additional option `
135
151
  filter :size, validates: { inclusion: { in: %w[Small Medium Large], allow_multiple_values: true } }
136
152
  ```
137
153
 
138
-
139
154
  #### partial
155
+
140
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
+
141
158
  - match: anywhere (default), from_start, dynamic
142
159
  - case_sensitive: true, false (default)
143
160
 
@@ -150,14 +167,17 @@ filter :reason, partial: { match: :dynamic, case_sensitive: true }
150
167
  ```
151
168
 
152
169
  The `match` options defines where you are searching (which then controls where the wildcard(s) appear):
170
+
153
171
  - anywhere: adds wildcards at the start and end, for example '%blue%'
154
172
  - from_start: adds a wildcard at the end, for example 'blue%'
155
173
  - dynamic: adds no wildcards; this enables the client to fully control the search string
156
174
 
157
175
  #### range
176
+
158
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.)
159
178
 
160
179
  Here are the available options:
180
+
161
181
  - true: enable ranges, minimum values, and/or maximum values
162
182
  - min_only: enables minimum values
163
183
  - max_only: enables maximum values
@@ -185,6 +205,17 @@ The following filters are not sortable:
185
205
  - scope filters (see [_Sorting with a Scope_](#sorting-with-a-scope))
186
206
  - filters with collection associations
187
207
 
208
+ #### Converters
209
+
210
+ If the filter value needs to be converted before being applied to the query, a converter block can be provided. The block should take the parameter value as an argument and return the converted value.
211
+
212
+ For example, if the amount filter should remove commas from the value before applying it to the query, the declaration would look like this:
213
+
214
+ ```ruby
215
+ filter :amount do |value|
216
+ value.is_a?(String) ? value.delete(",") : value
217
+ end
218
+ ```
188
219
 
189
220
  ### Scope Filters
190
221
 
@@ -261,7 +292,6 @@ There are two ways to apply the filters and build the query, depending on how mu
261
292
  - Use the `build_filtered_query` before action callback
262
293
  - Manually call `build_query_from_filters`
263
294
 
264
-
265
295
  #### Use the `build_filtered_query` before action callback
266
296
 
267
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.
@@ -335,6 +365,35 @@ The starting query is also a good place to provide any includes to enable eager
335
365
 
336
366
  Note that the starting query provides the model, so the model is not looked up and the `model_name` declaration in not needed.
337
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
+
338
397
  ### Specifying the Model
339
398
 
340
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.
@@ -349,11 +408,13 @@ _Important:_ If the `filter_model` declaration is used, it must be before any fi
349
408
 
350
409
  ## Configuration
351
410
 
352
- There are three configuration options:
411
+ The following configuration options are available:
353
412
 
354
413
  - action_on_undeclared_parameters
355
414
  - action_on_validation_failure
356
415
  - filter_key
416
+ - pagination_page_param
417
+ - pagination_size_param
357
418
 
358
419
  The configuration options can be set in an initializer, an environment file, or in `application.rb`.
359
420
 
@@ -368,6 +429,8 @@ Filterameter.configure do |config|
368
429
  config.action_on_undeclared_parameters = :log
369
430
  config.action_on_validation_failure = :log
370
431
  config.filter_key = :f
432
+ config.pagination_page_param = %i[page number]
433
+ config.pagination_size_param = %i[page size]
371
434
  end
372
435
  ```
373
436
 
@@ -387,6 +450,23 @@ If the filter parameters are NOT nested, set this to false. Doing so will restri
387
450
  those that have been declared, meaning undeclared parameters are ignored (and the action_on_undeclared_parameters
388
451
  configuration option does not come into play).
389
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
+
390
470
  ## Testing Declarations
391
471
 
392
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.
@@ -443,6 +523,10 @@ For example, the following sorts by size descending:
443
523
 
444
524
  `/widgets?filter[sort]=-size`
445
525
 
526
+ ## Community
527
+
528
+ Join us in the `filterameter` category on the [Ruby Users Forum](https://www.rubyforum.org/tag/filterameter).
529
+
446
530
  ## Contribute
447
531
 
448
532
  Feedback, feature requests, and proposed changes are welcomed. Please use the [issue tracker](https://github.com/RockSolt/filterameter/issues)
@@ -474,4 +558,5 @@ bundle exec appraisal rspec
474
558
  ```
475
559
 
476
560
  ## License
561
+
477
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
@@ -61,8 +61,8 @@ module Filterameter
61
61
  # filter :department_name, partial: :from_start
62
62
  # filter :reason, partial: { match: :dynamic, case_sensitive: true }
63
63
  # filter :price, range: true
64
- def filter(name, options = {})
65
- filter_coordinator.add_filter(name, options)
64
+ def filter(name, options = {}, &converter)
65
+ filter_coordinator.add_filter(name, options.merge(converter:))
66
66
  end
67
67
 
68
68
  # Declares a list of filters without options. Filters that require options must be declared with `filter`.
@@ -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
@@ -16,7 +16,7 @@ module Filterameter
16
16
  class FilterDeclaration
17
17
  VALID_RANGE_OPTIONS = [true, :min_only, :max_only].freeze
18
18
 
19
- attr_reader :name, :parameter_name, :association, :validations
19
+ attr_reader :name, :parameter_name, :association, :validations, :converter
20
20
 
21
21
  def initialize(parameter_name, options, range_type: nil)
22
22
  @parameter_name = parameter_name.to_s
@@ -29,6 +29,7 @@ module Filterameter
29
29
  @raw_range = options[:range]
30
30
  @range_type = range_type
31
31
  @sortable = options.fetch(:sortable, true)
32
+ @converter = options[:converter]
32
33
  end
33
34
 
34
35
  def nested?
@@ -88,7 +89,7 @@ module Filterameter
88
89
  private
89
90
 
90
91
  def validate_options(options)
91
- options.assert_valid_keys(:name, :association, :validates, :partial, :range, :sortable)
92
+ options.assert_valid_keys(:name, :association, :validates, :partial, :range, :sortable, :converter)
92
93
  validate_range(options[:range]) if options.key?(:range)
93
94
  end
94
95
 
@@ -15,7 +15,7 @@ module Filterameter
15
15
  if declaration.nested?
16
16
  build_nested_filter(declaration, context)
17
17
  else
18
- build_filter(@model_class, declaration, context.scope?)
18
+ build_filter_or_scope_filter(@model_class, declaration, context.scope?)
19
19
  end
20
20
  end
21
21
 
@@ -23,23 +23,29 @@ module Filterameter
23
23
 
24
24
  def build_nested_filter(declaration, context)
25
25
  model = context.model_from_association
26
- filter = build_filter(model, declaration, context.scope?)
26
+ filter = build_filter_or_scope_filter(model, declaration, context.scope?)
27
27
  nested_filter_class = context.any_collections? ? Filters::NestedCollectionFilter : Filters::NestedFilter
28
28
 
29
29
  nested_filter_class.new(declaration.association, model, filter)
30
30
  end
31
31
 
32
- def build_filter(model, declaration, declaration_is_a_scope) # rubocop:disable Metrics/MethodLength
32
+ def build_filter_or_scope_filter(model, declaration, declaration_is_a_scope)
33
33
  if declaration_is_a_scope
34
34
  build_scope_filter(model, declaration)
35
- elsif declaration.partial_search?
36
- Filterameter::Filters::MatchesFilter.new(declaration.name, declaration.partial_options)
35
+ else
36
+ build_filter(model, declaration)
37
+ end
38
+ end
39
+
40
+ def build_filter(model, declaration)
41
+ if declaration.partial_search?
42
+ Filterameter::Filters::MatchesFilter.new(declaration.name, declaration.partial_options, &declaration.converter)
37
43
  elsif declaration.minimum_range?
38
- Filterameter::Filters::MinimumFilter.new(model, declaration.name)
44
+ Filterameter::Filters::MinimumFilter.new(model, declaration.name, &declaration.converter)
39
45
  elsif declaration.maximum_range?
40
- Filterameter::Filters::MaximumFilter.new(model, declaration.name)
46
+ Filterameter::Filters::MaximumFilter.new(model, declaration.name, &declaration.converter)
41
47
  else
42
- Filterameter::Filters::AttributeFilter.new(declaration.name)
48
+ Filterameter::Filters::AttributeFilter.new(declaration.name, &declaration.converter)
43
49
  end
44
50
  end
45
51
 
@@ -48,9 +54,9 @@ module Filterameter
48
54
  def build_scope_filter(model, declaration)
49
55
  number_of_arguments = model.method(declaration.name).arity
50
56
  if number_of_arguments < 1
51
- Filterameter::Filters::ConditionalScopeFilter.new(declaration.name)
57
+ Filterameter::Filters::ConditionalScopeFilter.new(declaration.name, &declaration.converter)
52
58
  elsif number_of_arguments == 1
53
- Filterameter::Filters::ScopeFilter.new(declaration.name)
59
+ Filterameter::Filters::ScopeFilter.new(declaration.name, &declaration.converter)
54
60
  else
55
61
  raise Filterameter::DeclarationErrors::FilterScopeArgumentError.new(model.name, declaration.name)
56
62
  end
@@ -10,9 +10,10 @@ module Filterameter
10
10
  include Filterameter::Errors
11
11
  include Filterameter::Filters::AttributeValidator
12
12
 
13
- def initialize(model, attribute_name)
13
+ def initialize(model, attribute_name, &converter)
14
14
  @attribute_name = attribute_name
15
15
  @arel_attribute = model.arel_table[attribute_name]
16
+ @converter = converter
16
17
  end
17
18
  end
18
19
  end
@@ -9,11 +9,13 @@ module Filterameter
9
9
  include Filterameter::Errors
10
10
  include AttributeValidator
11
11
 
12
- def initialize(attribute_name)
12
+ def initialize(attribute_name, &converter)
13
13
  @attribute_name = attribute_name
14
+ @converter = converter
14
15
  end
15
16
 
16
17
  def apply(query, value)
18
+ value = @converter.call(value) if @converter
17
19
  query.where(@attribute_name => value)
18
20
  end
19
21
  end
@@ -8,11 +8,13 @@ module Filterameter
8
8
  class ConditionalScopeFilter
9
9
  include Filterameter::Errors
10
10
 
11
- def initialize(scope_name)
11
+ def initialize(scope_name, &converter)
12
12
  @scope_name = scope_name
13
+ @converter = converter
13
14
  end
14
15
 
15
16
  def apply(query, value)
17
+ value = @converter.call(value) if @converter
16
18
  return query unless ActiveModel::Type::Boolean.new.cast(value)
17
19
 
18
20
  query.public_send(@scope_name)
@@ -9,14 +9,16 @@ module Filterameter
9
9
  include Filterameter::Errors
10
10
  include Filterameter::Filters::AttributeValidator
11
11
 
12
- def initialize(attribute_name, options)
12
+ def initialize(attribute_name, options, &converter)
13
13
  @attribute_name = attribute_name
14
14
  @prefix = options.match_anywhere? ? '%' : nil
15
15
  @suffix = options.match_anywhere? || options.match_from_start? ? '%' : nil
16
16
  @case_sensitive = options.case_sensitive?
17
+ @converter = converter
17
18
  end
18
19
 
19
20
  def apply(query, value)
21
+ value = @converter.call(value) if @converter
20
22
  arel = query.arel_table[@attribute_name].matches("#{@prefix}#{value}#{@suffix}", false, @case_sensitive)
21
23
  query.where(arel)
22
24
  end
@@ -7,6 +7,7 @@ module Filterameter
7
7
  # Class MaximumFilter adds criteria for all values greater than or equal to a maximum.
8
8
  class MaximumFilter < ArelFilter
9
9
  def apply(query, value)
10
+ value = @converter.call(value) if @converter
10
11
  query.where(@arel_attribute.lteq(value))
11
12
  end
12
13
  end
@@ -7,6 +7,7 @@ module Filterameter
7
7
  # Class MinimumFilter adds criteria for all values greater than or equal to a minimum.
8
8
  class MinimumFilter < ArelFilter
9
9
  def apply(query, value)
10
+ value = @converter.call(value) if @converter
10
11
  query.where(@arel_attribute.gteq(value))
11
12
  end
12
13
  end
@@ -8,11 +8,13 @@ module Filterameter
8
8
  class ScopeFilter
9
9
  include Filterameter::Errors
10
10
 
11
- def initialize(scope_name)
11
+ def initialize(scope_name, &converter)
12
12
  @scope_name = scope_name
13
+ @converter = converter
13
14
  end
14
15
 
15
16
  def apply(query, value)
17
+ value = @converter.call(value) if @converter
16
18
  query.public_send(@scope_name, value)
17
19
  end
18
20
 
@@ -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.0.3'
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.0.3
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-03-31 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
@@ -196,7 +201,9 @@ files:
196
201
  homepage: https://github.com/RockSolt/filterameter
197
202
  licenses:
198
203
  - MIT
199
- metadata: {}
204
+ metadata:
205
+ rubygems_mfa_required: 'true'
206
+ mailing_list_uri: https://www.rubyforum.org/tag/filterameter
200
207
  rdoc_options: []
201
208
  require_paths:
202
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