filterameter 0.6.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9240312d328da15d2d007226b2c19f29cb9de9ec0a19ebd7cc6551cad64cabf2
4
- data.tar.gz: fa0044f0983f298258451fda21c68388af52a45d9dc62e631d18ee910334eef3
3
+ metadata.gz: c02c45f5366abfe01e6858a4d70d932e585b219a27680f799f5b53056202322f
4
+ data.tar.gz: acff8ae1523f525d3f7d9415c32dd5d3fd9b707118498b38d78eff4ca05ccc97
5
5
  SHA512:
6
- metadata.gz: 29a0f5344ea78d56cf4984ea9de64d587b541ffe3b3e94eca85234f3c004d6c9eaa4c055d15351f180fd2791172075b8a5fdef21a3a139cb4d909da385f638ac
7
- data.tar.gz: 277e0d817280074f8e4725d321e09a5b819be0031fccbd7382b2700928919151e3f5a4ad72618a5155d6cb1a57cafb22b507983382ae1db9bdb452ad439809cc
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
- ### Controllers
111
+ ### Scope Filters
101
112
 
102
- Include module `Filterameter::DeclarativeFilters` in the controller. Add before action callback `build_filtered_query` for controller actions that should build the query.
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
- Rails conventions are used to determine the controller's model as well as the name of the instance variable to apply the filters to. For example, the PhotosController will use the variable `@photos` to store a query against the Photo model. **If the conventions do not provide the correct info**, they can be overridden with the following two methods:
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
- #### filter_model
107
- Provide the name of the model. This method also allows the variable name to be optionally provided as the second parameter.
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
- #### filter_query_var_name
114
- Provide the name of the instance variable. For example, if the query is stored as `@data`, use the following:
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
- #### Example
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 WidgetController < ApplicationController
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
- ## Running Tests
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 can included in controllers to enable the filter DSL.
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
- instance_variable_set(
31
- var_name,
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 minimum?
62
+ def min_only?
55
63
  @raw_range == :min_only
56
64
  end
57
65
 
58
- def maximum?
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
- Filterameter::Filters::ScopeFilter.new(declaration.name)
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.minimum?
36
+ elsif declaration.minimum_range?
37
37
  Filterameter::Filters::MinimumFilter.new(model, declaration.name)
38
- elsif declaration.maximum?
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,23 +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.minimum?
43
- add_range_maximum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.maximum?
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_range = options.with_defaults(name: parameter_name)
50
- .merge(range: :min_only)
51
- @declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min, options_with_range)
49
+ options_with_name = options.with_defaults(name: parameter_name)
50
+ @declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min,
51
+ options_with_name,
52
+ range_type: :minimum)
52
53
  end
53
54
 
54
55
  def add_range_maximum(parameter_name, options)
55
56
  parameter_name_max = "#{parameter_name}_max"
56
- options_with_range = options.with_defaults(name: parameter_name)
57
- .merge(range: :max_only)
58
- @declarations[parameter_name_max] = Filterameter::FilterDeclaration.new(parameter_name_max, options_with_range)
57
+ options_with_name = options.with_defaults(name: parameter_name)
58
+ @declarations[parameter_name_max] = Filterameter::FilterDeclaration.new(parameter_name_max,
59
+ options_with_name,
60
+ range_type: :maximum)
59
61
  end
60
62
 
61
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 = build_joins_values_argument(association_names)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Filterameter
4
- VERSION = '0.6.1'
4
+ VERSION = '0.7.0'
5
5
  end
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.6.1
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-05-30 00:00:00.000000000 Z
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.4.19
223
+ rubygems_version: 3.5.11
224
224
  signing_key:
225
225
  specification_version: 4
226
226
  summary: Declarative Filter Parameters