filterameter 0.6.0 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +118 -10
- data/lib/filterameter/declarative_filters.rb +11 -6
- data/lib/filterameter/filter_declaration.rb +19 -3
- data/lib/filterameter/filter_factory.rb +13 -3
- data/lib/filterameter/filter_registry.rb +8 -4
- data/lib/filterameter/filters/nested_filter.rb +25 -16
- data/lib/filterameter/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c02c45f5366abfe01e6858a4d70d932e585b219a27680f799f5b53056202322f
|
4
|
+
data.tar.gz: acff8ae1523f525d3f7d9415c32dd5d3fd9b707118498b38d78eff4ca05ccc97
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2923df8284998b1129cb3b66bb7166cd08ef8708b21a8a6af666c393c219178f38111802294bc051434cd5a33cf6e6853f593abd6415fa031329bc09fbab8a92
|
7
|
+
data.tar.gz: 7fc9483785d7226ce14d9d3eae2f04b6d99c9f3bbfecdcd886b10cb9302e2ad119958e8ee1092e566e64eacc7d218b0bf754a0eeabb8463525858dc9cfaf5ed2
|
data/README.md
CHANGED
@@ -9,6 +9,9 @@ Declarative filter parameters provide clean and clear filters for Rails controll
|
|
9
9
|
## Usage
|
10
10
|
Declare filters in controllers to increase readability and reduce boilerplate code. Filters can be declared for attributes or scopes, either directly on the model or on an associated model. Validations can also be assigned.
|
11
11
|
|
12
|
+
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.
|
13
|
+
|
14
|
+
|
12
15
|
```ruby
|
13
16
|
filter :color
|
14
17
|
filter :size, validates: { inclusion: { in: %w[Small Medium Large], allow_multiple_values: true } }
|
@@ -17,6 +20,14 @@ Declare filters in controllers to increase readability and reduce boilerplate co
|
|
17
20
|
{ numericality: { less_than: 100 } }]
|
18
21
|
```
|
19
22
|
|
23
|
+
Filters without options can be declared all at once with `filters`:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
filters :color,
|
27
|
+
:size,
|
28
|
+
:name
|
29
|
+
```
|
30
|
+
|
20
31
|
### Filtering Options
|
21
32
|
|
22
33
|
The following options can be specified for each filter.
|
@@ -97,32 +108,61 @@ filter :sale_price, range: :max_only
|
|
97
108
|
|
98
109
|
In the first example, query parameters could include <tt>price</tt>, <tt>price_min</tt>, and <tt>price_max</tt>.
|
99
110
|
|
100
|
-
###
|
111
|
+
### Scope Filters
|
101
112
|
|
102
|
-
|
113
|
+
For scopes that do not take arguments, the filter should provide a boolean that indicates whether or not the scope should be invoked. For example, imagine a scope called `high_priority` with criteria that identifies high priority records. The scope would be invoked by the query parameters `high_priority=true`.
|
103
114
|
|
104
|
-
|
115
|
+
Passing `high_priority=false` will not invoke the scope. This makes it easy to include a filter with a check box UI.
|
105
116
|
|
106
|
-
|
107
|
-
|
117
|
+
Scopes that do take arguments [must be written as class methods, not inline scopes.](https://guides.rubyonrails.org/active_record_querying.html#passing-in-arguments) For example, imagine a scope called `recent` that takes an as of date as an argument. Here is what that might look like:
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
def self.recent(as_of_date)
|
121
|
+
where('created_at > ?', as_of_date)
|
122
|
+
end
|
123
|
+
```
|
124
|
+
|
125
|
+
### Specifying the Model
|
126
|
+
|
127
|
+
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.
|
128
|
+
|
129
|
+
**If the conventions do not provide the correct model**, the model can be named explicitly with the following:
|
108
130
|
|
109
131
|
```ruby
|
110
132
|
filter_model 'Picture'
|
111
133
|
```
|
112
134
|
|
113
|
-
|
114
|
-
|
135
|
+
|
136
|
+
### Building the Query
|
137
|
+
|
138
|
+
There are two ways to apply the filters and build the query, depending on how much control and/or visibility is desired:
|
139
|
+
|
140
|
+
- Use the `build_filtered_query` before action callback
|
141
|
+
- Manually call `build_query_from_filters`
|
142
|
+
|
143
|
+
|
144
|
+
#### Use the `build_filtered_query` before action callback
|
145
|
+
|
146
|
+
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.
|
147
|
+
|
148
|
+
When using the callback, the variable name is the pluralized model name. For example, the Photo model will use the variable `@photos` to store the query. The variable name can be explicitly specified with with `filter_query_var_name`. For example, if the query is stored as `@data`, use the following:
|
115
149
|
|
116
150
|
```ruby
|
117
151
|
filter_query_var_name :data
|
118
152
|
```
|
119
153
|
|
120
|
-
|
154
|
+
Additionally, the `filter_model` command takes an optional second parameter to specify the variable name. Both the model and the variable name can be specified with this short-cut. For example, to use the Picture model and store the results as `@data`, use the following:
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
filter_model 'Picture', :data
|
158
|
+
```
|
159
|
+
|
160
|
+
##### Example
|
121
161
|
|
122
162
|
In the happy path, the WidgetsController serves Widgets and can filter on size and color. Here's what the controller might look like:
|
123
163
|
|
124
164
|
```ruby
|
125
|
-
class
|
165
|
+
class WidgetsController < ApplicationController
|
126
166
|
include Filterameter::DeclarativeFilters
|
127
167
|
before_action :build_filtered_query, only: :index
|
128
168
|
|
@@ -135,6 +175,61 @@ class WidgetController < ApplicationController
|
|
135
175
|
end
|
136
176
|
```
|
137
177
|
|
178
|
+
#### Manually call `build_query_from_filters`
|
179
|
+
|
180
|
+
To generate the query manually, you can call `build_query_from_filters` directly _instead of using the callback_.
|
181
|
+
|
182
|
+
###### Example
|
183
|
+
|
184
|
+
Here's the Widgets controller again, this time building the query manually:
|
185
|
+
|
186
|
+
```ruby
|
187
|
+
class WidgetsController < ApplicationController
|
188
|
+
include Filterameter::DeclarativeFilters
|
189
|
+
|
190
|
+
filter :size
|
191
|
+
filter :color
|
192
|
+
|
193
|
+
def index
|
194
|
+
@widgets = build_query_from_filters
|
195
|
+
render json: @widgets
|
196
|
+
end
|
197
|
+
end
|
198
|
+
```
|
199
|
+
|
200
|
+
This method optionally takes a starting query. If there was a controller for Active Widgets that should only return active widgets, the following could be passed into the method as the starting point:
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
def index
|
204
|
+
@widgets = build_query_from_filters(Widget.where(active: true))
|
205
|
+
end
|
206
|
+
```
|
207
|
+
|
208
|
+
Note that the starting query provides the model, so the model is not looked up and any `model_name` declaration is ignored.
|
209
|
+
|
210
|
+
### Query Parameters
|
211
|
+
|
212
|
+
The query parameters are pulled from the controller parameters, nested under the key `filter`. For example a request for large, blue widgets might have the following url:
|
213
|
+
|
214
|
+
`/widgets?filter[size]=large&filter[color]=blue`
|
215
|
+
|
216
|
+
To change the source of the query parameters, override the `filter_parameters` method. Here is another way to provide a default filter:
|
217
|
+
|
218
|
+
```ruby
|
219
|
+
def filter_parameters
|
220
|
+
super.with_defaults(active: true)
|
221
|
+
end
|
222
|
+
```
|
223
|
+
|
224
|
+
This also provides an easy way to nest the criteria under a key other than `filter`:
|
225
|
+
|
226
|
+
```ruby
|
227
|
+
def filter_parameters
|
228
|
+
params.to_unsafe_h.fetch(:criteria, {})
|
229
|
+
end
|
230
|
+
```
|
231
|
+
|
232
|
+
|
138
233
|
## Installation
|
139
234
|
Add this line to your application's Gemfile:
|
140
235
|
|
@@ -172,8 +267,21 @@ On [a generic search form](https://guides.rubyonrails.org/form_helpers.html#a-ge
|
|
172
267
|
<% end %>
|
173
268
|
```
|
174
269
|
|
270
|
+
## Contributions
|
271
|
+
|
272
|
+
Feedback, feature requests, and proposed changes are welcomed. Please use the [issue tracker](https://github.com/RockSolt/filterameter/issues)
|
273
|
+
for feedback and feature requests. To propose a change directly, please fork the repo and open a pull request. Keep an eye on the actions to make
|
274
|
+
sure the tests and Rubocop are passing. [Code Climate](https://codeclimate.com/github/RockSolt/filterameter) is also used manually to assess the codeline.
|
275
|
+
|
276
|
+
To report a bug, please use the [issue tracker](https://github.com/RockSolt/filterameter/issues) and provide the following information:
|
277
|
+
|
278
|
+
- the version in use
|
279
|
+
- the filter declarations
|
280
|
+
- the SQL generated (for invalid / incorrect queries)
|
281
|
+
|
282
|
+
Gold stars will be awarded if you are able to [replicate the issue with a test](spec/README.md).
|
175
283
|
|
176
|
-
|
284
|
+
### Running Tests
|
177
285
|
|
178
286
|
Tests are written in RSpec and the dummy app uses a docker database. The script `bin/start_db.sh` starts and prepares the test
|
179
287
|
database. It is a one-time step before running the tests.
|
@@ -3,7 +3,7 @@
|
|
3
3
|
module Filterameter
|
4
4
|
# = Declarative Controller Filters
|
5
5
|
#
|
6
|
-
# Mixin DeclarativeFilters
|
6
|
+
# Mixin DeclarativeFilters should be included in controllers to enable the filter DSL.
|
7
7
|
module DeclarativeFilters
|
8
8
|
extend ActiveSupport::Concern
|
9
9
|
include Filterameter::Filterable
|
@@ -23,15 +23,20 @@ module Filterameter
|
|
23
23
|
end
|
24
24
|
end
|
25
25
|
|
26
|
+
def build_query_from_filters(starting_query = nil)
|
27
|
+
self.class.filter_coordinator.build_query(filter_parameters, starting_query)
|
28
|
+
end
|
29
|
+
|
30
|
+
def filter_parameters
|
31
|
+
params.to_unsafe_h.fetch(:filter, {})
|
32
|
+
end
|
33
|
+
|
26
34
|
private
|
27
35
|
|
28
36
|
def build_filtered_query
|
29
37
|
var_name = "@#{self.class.filter_coordinator.query_variable_name}"
|
30
|
-
|
31
|
-
|
32
|
-
self.class.filter_coordinator.build_query(params.to_unsafe_h.fetch(:filter, {}),
|
33
|
-
instance_variable_get(var_name))
|
34
|
-
)
|
38
|
+
starting_query = instance_variable_get(var_name)
|
39
|
+
instance_variable_set(var_name, build_query_from_filters(starting_query))
|
35
40
|
end
|
36
41
|
end
|
37
42
|
end
|
@@ -6,12 +6,19 @@ module Filterameter
|
|
6
6
|
# = Filter Declaration
|
7
7
|
#
|
8
8
|
# Class FilterDeclaration captures the filter declaration within the controller.
|
9
|
+
#
|
10
|
+
# When the min_only or max_only range option is specified, in addition to the attribute filter which carries that
|
11
|
+
# option, the registry builds a duplicate declaration that also carries the range_type flag (as either :minimum
|
12
|
+
# or :maximum).
|
13
|
+
#
|
14
|
+
# The predicate methods `min_only?` and `max_only?` answer what was declared; the predicate methods `minimum_range?`
|
15
|
+
# and `maximum_range?` answer what type of filter should be built.
|
9
16
|
class FilterDeclaration
|
10
17
|
VALID_RANGE_OPTIONS = [true, :min_only, :max_only].freeze
|
11
18
|
|
12
19
|
attr_reader :name, :parameter_name, :association, :validations
|
13
20
|
|
14
|
-
def initialize(parameter_name, options)
|
21
|
+
def initialize(parameter_name, options, range_type: nil)
|
15
22
|
@parameter_name = parameter_name.to_s
|
16
23
|
|
17
24
|
validate_options(options)
|
@@ -21,6 +28,7 @@ module Filterameter
|
|
21
28
|
@validations = Array.wrap(options[:validates])
|
22
29
|
@raw_partial_options = options.fetch(:partial, false)
|
23
30
|
@raw_range = options[:range]
|
31
|
+
@range_type = range_type
|
24
32
|
end
|
25
33
|
|
26
34
|
def nested?
|
@@ -51,14 +59,22 @@ module Filterameter
|
|
51
59
|
@raw_range == true
|
52
60
|
end
|
53
61
|
|
54
|
-
def
|
62
|
+
def min_only?
|
55
63
|
@raw_range == :min_only
|
56
64
|
end
|
57
65
|
|
58
|
-
def
|
66
|
+
def max_only?
|
59
67
|
@raw_range == :max_only
|
60
68
|
end
|
61
69
|
|
70
|
+
def minimum_range?
|
71
|
+
@range_type == :minimum
|
72
|
+
end
|
73
|
+
|
74
|
+
def maximum_range?
|
75
|
+
@range_type == :maximum
|
76
|
+
end
|
77
|
+
|
62
78
|
private
|
63
79
|
|
64
80
|
def validate_options(options)
|
@@ -30,18 +30,28 @@ module Filterameter
|
|
30
30
|
def build_filter(model, declaration) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
31
31
|
# checking dangerous_class_method? excludes any names that cannot be scope names, such as "name"
|
32
32
|
if model.respond_to?(declaration.name) && !model.dangerous_class_method?(declaration.name)
|
33
|
-
|
33
|
+
build_scope_filter(model, declaration)
|
34
34
|
elsif declaration.partial_search?
|
35
35
|
Filterameter::Filters::MatchesFilter.new(declaration.name, declaration.partial_options)
|
36
|
-
elsif declaration.
|
36
|
+
elsif declaration.minimum_range?
|
37
37
|
Filterameter::Filters::MinimumFilter.new(model, declaration.name)
|
38
|
-
elsif declaration.
|
38
|
+
elsif declaration.maximum_range?
|
39
39
|
Filterameter::Filters::MaximumFilter.new(model, declaration.name)
|
40
40
|
else
|
41
41
|
Filterameter::Filters::AttributeFilter.new(declaration.name)
|
42
42
|
end
|
43
43
|
end
|
44
44
|
|
45
|
+
# Inline scopes return an arity of -1 regardless of arguments, so those will always be assumed to be
|
46
|
+
# conditional scopes. To have a filter that passes a value to a scope, it must be a class method.
|
47
|
+
def build_scope_filter(model, declaration)
|
48
|
+
if model.method(declaration.name).arity == 1
|
49
|
+
Filterameter::Filters::ScopeFilter.new(declaration.name)
|
50
|
+
else
|
51
|
+
Filterameter::Filters::ConditionalScopeFilter.new(declaration.name)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
45
55
|
def filter_class(association_names)
|
46
56
|
if any_collections?(association_names)
|
47
57
|
Filters::NestedCollectionFilter
|
@@ -39,21 +39,25 @@ module Filterameter
|
|
39
39
|
|
40
40
|
# if range is enabled, then in addition to the attribute filter this also adds min and/or max filters
|
41
41
|
def add_declarations_for_range(attribute_declaration, options, parameter_name)
|
42
|
-
add_range_minimum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.
|
43
|
-
add_range_maximum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.
|
42
|
+
add_range_minimum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.min_only?
|
43
|
+
add_range_maximum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.max_only?
|
44
44
|
capture_range_declaration(parameter_name) if attribute_declaration.range?
|
45
45
|
end
|
46
46
|
|
47
47
|
def add_range_minimum(parameter_name, options)
|
48
48
|
parameter_name_min = "#{parameter_name}_min"
|
49
|
+
options_with_name = options.with_defaults(name: parameter_name)
|
49
50
|
@declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min,
|
50
|
-
|
51
|
+
options_with_name,
|
52
|
+
range_type: :minimum)
|
51
53
|
end
|
52
54
|
|
53
55
|
def add_range_maximum(parameter_name, options)
|
54
56
|
parameter_name_max = "#{parameter_name}_max"
|
57
|
+
options_with_name = options.with_defaults(name: parameter_name)
|
55
58
|
@declarations[parameter_name_max] = Filterameter::FilterDeclaration.new(parameter_name_max,
|
56
|
-
|
59
|
+
options_with_name,
|
60
|
+
range_type: :maximum)
|
57
61
|
end
|
58
62
|
|
59
63
|
def capture_range_declaration(name)
|
@@ -6,29 +6,38 @@ module Filterameter
|
|
6
6
|
#
|
7
7
|
# Class NestedFilter joins the nested table(s) then merges the filter to the association's model.
|
8
8
|
class NestedFilter
|
9
|
+
# = Joins Values Builder
|
10
|
+
#
|
11
|
+
# Class JoinsValuesBuilder evaluates an array of names to return either the single entry when there is only
|
12
|
+
# one element in the array or a nested hash when there is more than one element. This is the argument that is
|
13
|
+
# passed into the ActiveRecord query method `joins`.
|
14
|
+
class JoinsValuesBuilder
|
15
|
+
def self.build(association_names)
|
16
|
+
return association_names.first if association_names.size == 1
|
17
|
+
|
18
|
+
new(association_names).to_h
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(association_names)
|
22
|
+
@association_names = association_names
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_h
|
26
|
+
{}.tap do |nested_hash|
|
27
|
+
@association_names.reduce(nested_hash) { |memo, name| memo.store(name, {}) }
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
9
32
|
def initialize(association_names, association_model, attribute_filter)
|
10
|
-
@joins_values =
|
33
|
+
@joins_values = JoinsValuesBuilder.build(association_names)
|
11
34
|
@association_model = association_model
|
12
35
|
@attribute_filter = attribute_filter
|
13
36
|
end
|
14
37
|
|
15
38
|
def apply(query, value)
|
16
39
|
query.joins(@joins_values)
|
17
|
-
.merge(@attribute_filter.apply(@association_model, value))
|
18
|
-
end
|
19
|
-
|
20
|
-
private
|
21
|
-
|
22
|
-
def build_joins_values_argument(association_names)
|
23
|
-
return association_names.first if association_names.size == 1
|
24
|
-
|
25
|
-
convert_to_nested_hash(association_names)
|
26
|
-
end
|
27
|
-
|
28
|
-
def convert_to_nested_hash(association_names)
|
29
|
-
{}.tap do |nested_hash|
|
30
|
-
association_names.reduce(nested_hash) { |memo, name| memo.store(name, {}) }
|
31
|
-
end
|
40
|
+
.merge(@attribute_filter.apply(@association_model.all, value))
|
32
41
|
end
|
33
42
|
end
|
34
43
|
end
|
data/lib/filterameter/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: filterameter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Todd Kummer
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-06-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -220,7 +220,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
220
220
|
- !ruby/object:Gem::Version
|
221
221
|
version: '0'
|
222
222
|
requirements: []
|
223
|
-
rubygems_version: 3.
|
223
|
+
rubygems_version: 3.5.11
|
224
224
|
signing_key:
|
225
225
|
specification_version: 4
|
226
226
|
summary: Declarative Filter Parameters
|