filterameter 0.4.2 → 0.6.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: fab4750d809945619906678860278bb1266e20f3167a7e1cf15ca2cfcdb2404d
4
- data.tar.gz: da0d8f1d87b5aa8393f0283ce92442b968a4f388f6aa86eaf65c0740f3da8a7f
3
+ metadata.gz: 8b2ac8a6c211408669122ab5bf2a131ade58736fa145508305f5ebe3dc9c0bdb
4
+ data.tar.gz: 9216ddecfedab830f9a3a82cf9c1936f040e64664b23b2d16a4ccd71f1f4923e
5
5
  SHA512:
6
- metadata.gz: 6c4c735e792c7da17e8753e760e2e75143e1e959f0000fb2367246dc280a57fc3709d47ac063a8261a3429727ac178d41c18fc4a0fc2abcec8417e2ec0c28240
7
- data.tar.gz: 2a92f39bcd0af14ff33f3efc35f65def9d882a3caf01b18a04ec0c31ece6977966962ce88d893d0efb62c2b4492babe2d90b02fde8f730a76121c869d2ca3c3c
6
+ metadata.gz: ad640c098660b98a33f54378f16dced7d3c2dd278c6d6b6db120d2026cb7f03bda5f0d5033653e697af153eff4bdb3c9c84e39d263a18eedba5075098b7354ef
7
+ data.tar.gz: 3e775bbe1d6ed769bdfb1a83ac3f4a74c69128517a5bf6ea3fc140e50a8b025214d87d544c9263ad4bf0495f34d477f7a2128836f81e7b6a82d4ec037b9df938
data/README.md CHANGED
@@ -4,10 +4,10 @@
4
4
  [![Maintainability](https://api.codeclimate.com/v1/badges/d9d87f9ce8020eb6e656/maintainability)](https://codeclimate.com/github/RockSolt/filterameter/maintainability)
5
5
 
6
6
  # Filterameter
7
- Declarative filter parameters provide clean and clear filters for queries.
7
+ Declarative filter parameters provide clean and clear filters for Rails controllers.
8
8
 
9
9
  ## Usage
10
- Declare filters in query classes or controllers to increase readability and reduce boilerplate code. Filters can be declared for attributes, scopes, or attributes from singular associations (`belongs_to` or `has_one`). Validations can also be assigned.
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
12
  ```ruby
13
13
  filter :color
@@ -28,13 +28,25 @@ If the name of the parameter is different than the name of the attribute or scop
28
28
  filter :status, name: :current_status
29
29
  ```
30
30
 
31
+ 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.
32
+
31
33
  #### association
32
- If the attribute or scope is nested, it can be referenced by naming the association. Only singular associations are valid. For example, if the manager_id attribute lives on an employee's department record, use the following:
34
+ 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:
33
35
 
34
36
  ```ruby
35
37
  filter :manager_id, association: :department
36
38
  ```
37
39
 
40
+ The attribute or scope can be nested more than one level. Declare the filter with an array specifying the associations in order. For example, if an employee belongs to a department and a department belongs to a business unit, use the following to query on the business unit name:
41
+
42
+ ```ruby
43
+ filter :business_unit_name, name: :name, association: [:department, :business_unit]
44
+ ```
45
+
46
+ If an association is a `has_many` [the distinct method](https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-distinct) is called on the query.
47
+
48
+ _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.
49
+
38
50
  #### validates
39
51
  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:
40
52
 
@@ -85,39 +97,9 @@ filter :sale_price, range: :max_only
85
97
 
86
98
  In the first example, query parameters could include <tt>price</tt>, <tt>price_min</tt>, and <tt>price_max</tt>.
87
99
 
88
- ### Query Classes
89
-
90
- Include module `Filterameter::DeclarativeFilters` in the query class. The model must be declared using `model`, and a default query can optionally be declared using `default_query`. If no default query is provided, then the default is `.all`.
91
-
92
- #### Example
93
-
94
- Here's what a query class for the Widgets model with filters on size and color might look like:
95
-
96
- ```ruby
97
- class WidgetQuery
98
- include Filterameter::DeclarativeFilters
99
-
100
- model Widget
101
- filter :size
102
- filter :color
103
- end
104
- ```
105
-
106
- Build the query using class method `build_query`. The method takes two parameters:
107
-
108
- - filter: the hash of filter parameters
109
- - starting_query: any scope to build on (if not provided, the default query is the starting point)
110
-
111
- Here's how the query might be invoked:
112
-
113
- ```ruby
114
- filters = { size: 'large', color: 'blue' }
115
- widgets = WidgetQuery.build_query(filters, Widget.limit(10))
116
- ```
117
-
118
100
  ### Controllers
119
101
 
120
- Include module `Filterameter::DeclarativeControllerFilters` in the controller. Add before action callback `build_filtered_query` for controller actions that should build the query.
102
+ Include module `Filterameter::DeclarativeFilters` in the controller. Add before action callback `build_filtered_query` for controller actions that should build the query.
121
103
 
122
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:
123
105
 
@@ -141,7 +123,7 @@ In the happy path, the WidgetsController serves Widgets and can filter on size a
141
123
 
142
124
  ```ruby
143
125
  class WidgetController < ApplicationController
144
- include Filterameter::DeclarativeControllerFilters
126
+ include Filterameter::DeclarativeFilters
145
127
  before_action :build_filtered_query, only: :index
146
128
 
147
129
  filter :size
@@ -1,27 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Filterameter
4
- # = Declarative Filters
4
+ # = Declarative Controller Filters
5
5
  #
6
- # Mixin DeclarativeFilters is included to build query classes using the filter DSL.
6
+ # Mixin DeclarativeFilters can included in controllers to enable the filter DSL.
7
7
  module DeclarativeFilters
8
8
  extend ActiveSupport::Concern
9
9
  include Filterameter::Filterable
10
10
 
11
11
  class_methods do
12
- delegate :build_query, to: :filter_coordinator
13
-
14
- def model(model_class)
12
+ def filter_model(model_class, query_var_name = nil)
15
13
  filter_coordinator.model_class = model_class
14
+ filter_query_var_name(query_var_name) if query_var_name.present?
16
15
  end
17
16
 
18
- def default_query(query)
19
- filter_coordinator.default_query = query
17
+ def filter_query_var_name(query_variable_name)
18
+ filter_coordinator.query_variable_name = query_variable_name
20
19
  end
21
20
 
22
21
  def filter_coordinator
23
- @filter_coordinator ||= Filterameter::Coordinators::QueryCoordinator.new
22
+ @filter_coordinator ||= Filterameter::FilterCoordinator.new(controller_name, controller_path)
24
23
  end
25
24
  end
25
+
26
+ private
27
+
28
+ def build_filtered_query
29
+ 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
+ )
35
+ end
26
36
  end
27
37
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/inflector'
4
+
5
+ require 'active_support/rails'
6
+ require 'action_dispatch'
7
+ require 'action_controller/metal/live'
8
+ require 'action_controller/metal/strong_parameters'
9
+
10
+ module Filterameter
11
+ # = Filter Coordinator
12
+ #
13
+ # Class FilterCoordinator stores the configuration declared via class-level method calls such as the list of
14
+ # filters and the optionally declared model class. Each controller will have one instance of the coordinator
15
+ # stored as a class variable.
16
+ #
17
+ # The coordinators encapsulate references to the Query Builder and Filter Registry to keep the namespace clean for
18
+ # controllers that implement filter parameters.
19
+ class FilterCoordinator
20
+ attr_writer :query_variable_name
21
+
22
+ delegate :add_filter, to: :registry
23
+ delegate :build_query, to: :query_builder
24
+
25
+ def initialize(controller_name, controller_path)
26
+ @controller_name = controller_name
27
+ @controller_path = controller_path
28
+ end
29
+
30
+ def model_class=(model_class)
31
+ @model_class = model_class.is_a?(String) ? model_class.constantize : model_class
32
+ end
33
+
34
+ def query_builder
35
+ @query_builder ||= Filterameter::QueryBuilder.new(default_query, registry)
36
+ end
37
+
38
+ def query_variable_name
39
+ @query_variable_name ||= model_class.model_name.plural
40
+ end
41
+
42
+ private
43
+
44
+ def model_class
45
+ @model_class ||= @controller_name.classify.safe_constantize ||
46
+ @controller_path.classify.safe_constantize ||
47
+ raise(Filterameter::Exceptions::CannotDetermineModelError.new(@controller_name,
48
+ @controller_path))
49
+ end
50
+
51
+ def default_query
52
+ model_class.all
53
+ end
54
+
55
+ # lazy so that model_class can be optionally set
56
+ def registry
57
+ @registry ||= Filterameter::FilterRegistry.new(Filterameter::FilterFactory.new(model_class))
58
+ end
59
+ end
60
+ end
@@ -16,7 +16,7 @@ module Filterameter
16
16
 
17
17
  validate_options(options)
18
18
  @name = options.fetch(:name, parameter_name).to_s
19
- @association = options[:association]
19
+ @association = Array.wrap(options[:association]).presence
20
20
  @filter_on_empty = options.fetch(:filter_on_empty, false)
21
21
  @validations = Array.wrap(options[:validates])
22
22
  @raw_partial_options = options.fetch(:partial, false)
@@ -24,7 +24,7 @@ module Filterameter
24
24
  end
25
25
 
26
26
  def nested?
27
- @association.present?
27
+ !@association.nil?
28
28
  end
29
29
 
30
30
  def validations?
@@ -10,14 +10,23 @@ module Filterameter
10
10
  end
11
11
 
12
12
  def build(declaration)
13
- model = declaration.nested? ? model_from_association(declaration.association) : @model_class
14
- filter = build_filter(model, declaration)
15
-
16
- declaration.nested? ? Filterameter::Filters::NestedFilter.new(declaration.association, model, filter) : filter
13
+ if declaration.nested?
14
+ build_nested_filter(declaration)
15
+ else
16
+ build_filter(@model_class, declaration)
17
+ end
17
18
  end
18
19
 
19
20
  private
20
21
 
22
+ def build_nested_filter(declaration)
23
+ model = model_from_association(declaration.association)
24
+ filter = build_filter(model, declaration)
25
+ clazz = filter_class(declaration.association)
26
+
27
+ clazz.new(declaration.association, model, filter)
28
+ end
29
+
21
30
  def build_filter(model, declaration) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
22
31
  # checking dangerous_class_method? excludes any names that cannot be scope names, such as "name"
23
32
  if model.respond_to?(declaration.name) && !model.dangerous_class_method?(declaration.name)
@@ -33,9 +42,28 @@ module Filterameter
33
42
  end
34
43
  end
35
44
 
45
+ def filter_class(association_names)
46
+ if any_collections?(association_names)
47
+ Filters::NestedCollectionFilter
48
+ else
49
+ Filters::NestedFilter
50
+ end
51
+ end
52
+
53
+ def any_collections?(association_names)
54
+ association_names.reduce(@model_class) do |model, name|
55
+ association = model.reflect_on_association(name)
56
+ return true if association.collection?
57
+
58
+ association.klass
59
+ end
60
+
61
+ false
62
+ end
63
+
36
64
  # TODO: rescue then raise custom error with cause
37
65
  def model_from_association(association)
38
- [association].flatten.reduce(@model_class) { |memo, name| memo.reflect_on_association(name).klass }
66
+ association.flatten.reduce(@model_class) { |memo, name| memo.reflect_on_association(name).klass }
39
67
  # rescue StandardError => e
40
68
  end
41
69
  end
@@ -51,8 +51,8 @@ module Filterameter
51
51
  end
52
52
 
53
53
  def add_range_maximum(parameter_name, options)
54
- parameter_name_min = "#{parameter_name}_max"
55
- @declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min,
54
+ parameter_name_max = "#{parameter_name}_max"
55
+ @declarations[parameter_name_max] = Filterameter::FilterDeclaration.new(parameter_name_max,
56
56
  options.merge(range: :max_only))
57
57
  end
58
58
 
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module Filters
5
+ # = Nested Collection Filter
6
+ #
7
+ # Class NestedCollectionFilter joins the nested table(s), merges the filter to the association's model, then makes
8
+ # the results distinct.
9
+ class NestedCollectionFilter < NestedFilter
10
+ def apply(*)
11
+ super.distinct
12
+ end
13
+ end
14
+ end
15
+ end
@@ -6,8 +6,8 @@ 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
- def initialize(joins_values, association_model, attribute_filter)
10
- @joins_values = joins_values
9
+ def initialize(association_names, association_model, attribute_filter)
10
+ @joins_values = build_joins_values_argument(association_names)
11
11
  @association_model = association_model
12
12
  @attribute_filter = attribute_filter
13
13
  end
@@ -16,6 +16,20 @@ module Filterameter
16
16
  query.joins(@joins_values)
17
17
  .merge(@attribute_filter.apply(@association_model, value))
18
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
32
+ end
19
33
  end
20
34
  end
21
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Filterameter
4
- VERSION = '0.4.2'
4
+ VERSION = '0.6.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.4.2
4
+ version: 0.6.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-13 00:00:00.000000000 Z
11
+ date: 2024-05-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -114,14 +114,14 @@ dependencies:
114
114
  requirements:
115
115
  - - "~>"
116
116
  - !ruby/object:Gem::Version
117
- version: 1.60.2
117
+ version: '1.64'
118
118
  type: :development
119
119
  prerelease: false
120
120
  version_requirements: !ruby/object:Gem::Requirement
121
121
  requirements:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
- version: 1.60.2
124
+ version: '1.64'
125
125
  - !ruby/object:Gem::Dependency
126
126
  name: rubocop-packaging
127
127
  requirement: !ruby/object:Gem::Requirement
@@ -142,14 +142,14 @@ dependencies:
142
142
  requirements:
143
143
  - - "~>"
144
144
  - !ruby/object:Gem::Version
145
- version: 2.23.1
145
+ version: '2.25'
146
146
  type: :development
147
147
  prerelease: false
148
148
  version_requirements: !ruby/object:Gem::Requirement
149
149
  requirements:
150
150
  - - "~>"
151
151
  - !ruby/object:Gem::Version
152
- version: 2.23.1
152
+ version: '2.25'
153
153
  - !ruby/object:Gem::Dependency
154
154
  name: simplecov
155
155
  requirement: !ruby/object:Gem::Requirement
@@ -164,7 +164,8 @@ dependencies:
164
164
  - - "~>"
165
165
  - !ruby/object:Gem::Version
166
166
  version: '0.18'
167
- description: Enable filter parameters to be declared in query classes or controllers.
167
+ description: Declare filters in Rails controllers to increase readability and reduce
168
+ boilerplate code.
168
169
  email:
169
170
  - todd@rockridgesolutions.com
170
171
  executables: []
@@ -175,15 +176,12 @@ files:
175
176
  - Rakefile
176
177
  - lib/filterameter.rb
177
178
  - lib/filterameter/configuration.rb
178
- - lib/filterameter/coordinators/base.rb
179
- - lib/filterameter/coordinators/controller_coordinator.rb
180
- - lib/filterameter/coordinators/query_coordinator.rb
181
- - lib/filterameter/declarative_controller_filters.rb
182
179
  - lib/filterameter/declarative_filters.rb
183
180
  - lib/filterameter/exceptions.rb
184
181
  - lib/filterameter/exceptions/cannot_determine_model_error.rb
185
182
  - lib/filterameter/exceptions/undeclared_parameter_error.rb
186
183
  - lib/filterameter/exceptions/validation_error.rb
184
+ - lib/filterameter/filter_coordinator.rb
187
185
  - lib/filterameter/filter_declaration.rb
188
186
  - lib/filterameter/filter_factory.rb
189
187
  - lib/filterameter/filter_registry.rb
@@ -194,6 +192,7 @@ files:
194
192
  - lib/filterameter/filters/matches_filter.rb
195
193
  - lib/filterameter/filters/maximum_filter.rb
196
194
  - lib/filterameter/filters/minimum_filter.rb
195
+ - lib/filterameter/filters/nested_collection_filter.rb
197
196
  - lib/filterameter/filters/nested_filter.rb
198
197
  - lib/filterameter/filters/scope_filter.rb
199
198
  - lib/filterameter/log_subscriber.rb
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Filterameter
4
- module Coordinators
5
- # = Coordinators Base
6
- #
7
- # The main responsibility of the Coordinators classes is to keep the namespace clean for controllers and query
8
- # objects that implement filter parameters. The coordinators encapsulate references to the Query Builder and
9
- # Filter Registry.
10
- class Base
11
- attr_reader :model_class
12
-
13
- delegate :add_filter, to: :registry
14
- delegate :build_query, to: :query_builder
15
-
16
- def model_class=(model_class)
17
- @model_class = model_class.is_a?(String) ? model_class.constantize : model_class
18
- end
19
-
20
- def query_builder
21
- @query_builder ||= Filterameter::QueryBuilder.new(default_query, registry)
22
- end
23
-
24
- private
25
-
26
- def default_query
27
- model_class.all
28
- end
29
-
30
- # lazy so that model_class can be optionally set
31
- def registry
32
- @registry ||= Filterameter::FilterRegistry.new(Filterameter::FilterFactory.new(model_class))
33
- end
34
- end
35
- end
36
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'active_support/inflector'
4
-
5
- require 'active_support/rails'
6
- require 'action_dispatch'
7
- require 'action_controller/metal/live'
8
- require 'action_controller/metal/strong_parameters'
9
-
10
- module Filterameter
11
- module Coordinators
12
- # = Controller Filters
13
- #
14
- # Class ControllerFilters stores the configuration declared via class-level method calls such as the list of
15
- # filters and the optionally declared model class. Each controller will have one instance of the controller
16
- # declarations stored as a class variable.
17
- class ControllerCoordinator < Filterameter::Coordinators::Base
18
- attr_writer :query_variable_name
19
-
20
- def initialize(controller_name, controller_path)
21
- @controller_name = controller_name
22
- @controller_path = controller_path
23
- super()
24
- end
25
-
26
- def query_variable_name
27
- @query_variable_name ||= model_class.model_name.plural
28
- end
29
-
30
- private
31
-
32
- def model_class
33
- @model_class ||= @controller_name.classify.safe_constantize ||
34
- @controller_path.classify.safe_constantize ||
35
- raise(Filterameter::Exceptions::CannotDetermineModelError.new(@controller_name,
36
- @controller_path))
37
- end
38
- end
39
- end
40
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Filterameter
4
- module Coordinators
5
- # = Query Coordinator
6
- #
7
- # Class QueryCoordinator coordinates the filter logic for query classes.
8
- class QueryCoordinator < Filterameter::Coordinators::Base
9
- attr_writer :default_query
10
-
11
- private
12
-
13
- def default_query
14
- @default_query || super
15
- end
16
- end
17
- end
18
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Filterameter
4
- # = Declarative Controller Filters
5
- #
6
- # Mixin DeclarativeControllerFilters can included in controllers to enable the filter DSL.
7
- module DeclarativeControllerFilters
8
- extend ActiveSupport::Concern
9
- include Filterameter::Filterable
10
-
11
- class_methods do
12
- def filter_model(model_class, query_var_name = nil)
13
- filter_coordinator.model_class = model_class
14
- filter_query_var_name(query_var_name) if query_var_name.present?
15
- end
16
-
17
- def filter_query_var_name(query_variable_name)
18
- filter_coordinator.query_variable_name = query_variable_name
19
- end
20
-
21
- def filter_coordinator
22
- @filter_coordinator ||= Filterameter::Coordinators::ControllerCoordinator.new(controller_name, controller_path)
23
- end
24
- end
25
-
26
- private
27
-
28
- def build_filtered_query
29
- 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
- )
35
- end
36
- end
37
- end