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 +4 -4
- data/README.md +88 -3
- data/lib/filterameter/configuration.rb +68 -10
- data/lib/filterameter/declarative_filters.rb +19 -9
- data/lib/filterameter/filter_coordinator.rb +7 -3
- data/lib/filterameter/filter_declaration.rb +3 -2
- data/lib/filterameter/filter_factory.rb +16 -10
- data/lib/filterameter/filters/arel_filter.rb +2 -1
- data/lib/filterameter/filters/attribute_filter.rb +3 -1
- data/lib/filterameter/filters/conditional_scope_filter.rb +3 -1
- data/lib/filterameter/filters/matches_filter.rb +3 -1
- data/lib/filterameter/filters/maximum_filter.rb +1 -0
- data/lib/filterameter/filters/minimum_filter.rb +1 -0
- data/lib/filterameter/filters/scope_filter.rb +3 -1
- data/lib/filterameter/helpers/sort_normalizer.rb +19 -0
- data/lib/filterameter/helpers/sort_serializer.rb +60 -0
- data/lib/filterameter/query_builder.rb +1 -1
- data/lib/filterameter/query_parameters.rb +120 -0
- data/lib/filterameter/request_parameters.rb +106 -0
- data/lib/filterameter/requested_sort.rb +19 -0
- data/lib/filterameter/sort_strategies/replacement_sort_strategy.rb +35 -0
- data/lib/filterameter/version.rb +1 -1
- metadata +11 -4
- data/lib/filterameter/helpers/requested_sort.rb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4184e23f30b4d9771a19a4a5c48bff17fb32b8dabc7d9ed3895beadcfb859d14
|
|
4
|
+
data.tar.gz: b62d21e81555a74e9f7e151355093ac9f77f44b774a880a5061832b2c1411fee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: da210337a494a23ff4779a935e1ff1c281894b9e021c395658e9204ca71c5358f77ccbe6e7623e8afea03c82e6077d8a7df0b3b49df9ae47addb271795fd17f8
|
|
7
|
+
data.tar.gz: 3264ea77862d726d0b31ec2802089f627be2187290dce49704995684549dc1afdaa956f1775369bca2872a02cdc33184278c27f198b0edb8fdd5d7d7ebabc698
|
data/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
[](https://badge.fury.io/rb/filterameter)
|
|
2
2
|
[](https://github.com/RockSolt/filterameter/actions?query=workflow%3ARuboCop)
|
|
3
3
|
[](https://github.com/RockSolt/filterameter/actions?query=workflow%3ARSpec)
|
|
4
|
+
[](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
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
@@ -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
|
data/lib/filterameter/version.rb
CHANGED
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
|
|
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-
|
|
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/
|
|
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
|