filterameter 0.1.4 → 0.3.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: 0baada12bc6ad382e146635629a89c47f935e92f1e4c72597889440305c46ebf
4
- data.tar.gz: 427ecf17bd6458fbe76af104ab5b1eb32a4edb2adc3c0b73ac4fb329f1eba0f9
3
+ metadata.gz: c5ff39d6e3e772f3943678ecc748ca3f9d9234417958fc9254071ee14764ed31
4
+ data.tar.gz: 22e0d442436d0cae884e60a60066eea859c7de22251d6471e05c289af50f3da1
5
5
  SHA512:
6
- metadata.gz: 24ce1e8ca752befea919c57f5e79af75d80f77569d0171a052b1b1a14c65af5d0e6ad234127767e3f42bddb4d058feafdee58130cf38102cd4b10eef2bdc3ca9
7
- data.tar.gz: 5e0ad2d3348d84df2d7198bc38611f0a3bb8ea52ce4882c61ca0289f48239afc11ec49f4b293fc79810cda25a92f8e236ad00b73bb3d4769e4d4912b99939651
6
+ metadata.gz: 67cd4b48cf4f50e0681e34003f0f9ebe7ae753b703f83121d017daaee7185c846c4e2e467d2b140fc14ced8e11b07f88b155a1dda4b6e0a9104906348f077a53
7
+ data.tar.gz: f80c40fcd3a438682bd95c7b3c282dae7cc199c57375372314d0720c234c53aa0b4609c47cccc2dcb85505a023f74e9ba6c6e2125714a5d48710c87bff20a6d5
data/README.md CHANGED
@@ -1,15 +1,17 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/filterameter.svg)](https://badge.fury.io/rb/filterameter)
2
2
  [![RuboCop](https://github.com/RockSolt/filterameter/workflows/RuboCop/badge.svg)](https://github.com/RockSolt/filterameter/actions?query=workflow%3ARuboCop)
3
+ [![RSpec](https://github.com/RockSolt/filterameter/workflows/RSpec/badge.svg)](https://github.com/RockSolt/filterameter/actions?query=workflow%3ARSpec)
4
+ [![Maintainability](https://api.codeclimate.com/v1/badges/d9d87f9ce8020eb6e656/maintainability)](https://codeclimate.com/github/RockSolt/filterameter/maintainability)
3
5
 
4
6
  # Filterameter
5
- Declarative Filter Parameters for Rails Controllers.
7
+ Declarative filter parameters provide provide clean and clear filters for queries.
6
8
 
7
9
  ## Usage
8
- Declare filters at the top of 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 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.
9
11
 
10
12
  ```ruby
11
13
  filter :color
12
- filter :size, validates: { inclusion: { in: %w[Small Medium Large] }, unless: -> { size.is_a? Array } }
14
+ filter :size, validates: { inclusion: { in: %w[Small Medium Large], allow_multiple_values: true } }
13
15
  filter :brand_name, association: :brand, name: :name
14
16
  filter :on_sale, association: :price, validates: [{ numericality: { greater_than: 0 } },
15
17
  { numericality: { less_than: 100 } }]
@@ -37,10 +39,15 @@ filter :manager_id, association: :department
37
39
  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:
38
40
 
39
41
  ```ruby
40
- filter :size, validates: { inclusion: { in: %w[Small Medium Large] }, unless: -> { size.is_a? Array } }
42
+ filter :size, validates: { inclusion: { in: %w[Small Medium Large] } }
43
+ ```
44
+
45
+ The `inclusion` validator has been overridden to provide the additional option `allow_multiple_values`. When true, the value can be an array and each entry in the array will be validated. Use this when the filter can specify one or more values.
46
+
47
+ ```ruby
48
+ filter :size, validates: { inclusion: { in: %w[Small Medium Large], allow_multiple_values: true } }
41
49
  ```
42
50
 
43
- Note that the `inclusion` validator does not allow arrays to be specified. If the filter should allow multiple values to be specified, then the validation needs to be disabled when the value an array.
44
51
 
45
52
  #### partial
46
53
  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:
@@ -78,9 +85,41 @@ filter :sale_price, range: :max_only
78
85
 
79
86
  In the first example, query parameters could include <tt>price</tt>, <tt>price_min</tt>, and <tt>price_max</tt>.
80
87
 
81
- ### Configuring Controllers
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)
82
110
 
83
- 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:
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
+ ### Controllers
119
+
120
+ Include module `Filterameter::DeclarativeControllerFilters` in the controller. Add before action callback `build_filtered_query` for controller actions that should build the query.
121
+
122
+ 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:
84
123
 
85
124
  #### filter_model
86
125
  Provide the name of the model. This method also allows the variable name to be optionally provided as the second parameter.
@@ -96,6 +135,24 @@ Provide the name of the instance variable. For example, if the query is stored a
96
135
  filter_query_var_name :data
97
136
  ```
98
137
 
138
+ #### Example
139
+
140
+ In the happy path, the WidgetsController serves Widgets and can filter on size and color. Here's what the controller might look like:
141
+
142
+ ```ruby
143
+ class WidgetController < ApplicationController
144
+ include Filterameter::DeclarativeControllerFilters
145
+ before_action :build_filtered_query, only: :index
146
+
147
+ filter :size
148
+ filter :color
149
+
150
+ def index
151
+ render json: @widgets
152
+ end
153
+ end
154
+ ```
155
+
99
156
  ## Installation
100
157
  Add this line to your application's Gemfile:
101
158
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'filterameter/filter_declaration'
4
+ require 'filterameter/filter_factory'
5
+ require 'filterameter/filter_registry'
6
+ require 'filterameter/log_subscriber'
7
+ require 'filterameter/parameters_base'
8
+ require 'filterameter/query_builder'
9
+
10
+ module Filterameter
11
+ module Coordinators
12
+ # = Coordinators Base
13
+ #
14
+ # The main responsibility of the Coordinators classes is to keep the namespace clean for controllers and query
15
+ # objects that implement filter parameters. The coordinators encapsulate references to the Query Builder and
16
+ # Filter Registry.
17
+ class Base
18
+ attr_reader :model_class
19
+
20
+ delegate :add_filter, to: :registry
21
+ delegate :build_query, to: :query_builder
22
+
23
+ def model_class=(model_class)
24
+ @model_class = model_class.is_a?(String) ? model_class.constantize : model_class
25
+ end
26
+
27
+ def query_builder
28
+ @query_builder ||= Filterameter::QueryBuilder.new(default_query, registry)
29
+ end
30
+
31
+ private
32
+
33
+ def default_query
34
+ model_class.all
35
+ end
36
+
37
+ # lazy so that model_class can be optionally set
38
+ def registry
39
+ @registry ||= Filterameter::FilterRegistry.new(Filterameter::FilterFactory.new(model_class))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,42 @@
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
+ require 'filterameter/coordinators/base'
11
+
12
+ module Filterameter
13
+ module Coordinators
14
+ # = Controller Filters
15
+ #
16
+ # Class ControllerFilters stores the configuration declared via class-level method calls such as the list of
17
+ # filters and the optionally declared model class. Each controller will have one instance of the controller
18
+ # declarations stored as a class variable.
19
+ class ControllerCoordinator < Filterameter::Coordinators::Base
20
+ attr_writer :query_variable_name
21
+
22
+ def initialize(controller_name, controller_path)
23
+ @controller_name = controller_name
24
+ @controller_path = controller_path
25
+ super()
26
+ end
27
+
28
+ def query_variable_name
29
+ @query_variable_name ||= model_class.model_name.plural
30
+ end
31
+
32
+ private
33
+
34
+ def model_class
35
+ @model_class ||= @controller_name.classify.safe_constantize ||
36
+ @controller_path.classify.safe_constantize ||
37
+ raise(Filterameter::Exceptions::CannotDetermineModelError.new(@controller_name,
38
+ @controller_path))
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'filterameter/filterable'
4
+ require 'filterameter/coordinators/controller_coordinator'
5
+
6
+ module Filterameter
7
+ # = Declarative Controller Filters
8
+ #
9
+ # Mixin DeclarativeControllerFilters can included in controllers to enable the filter DSL.
10
+ module DeclarativeControllerFilters
11
+ extend ActiveSupport::Concern
12
+ include Filterameter::Filterable
13
+
14
+ class_methods do
15
+ def filter_model(model_class, query_var_name = nil)
16
+ filter_coordinator.model_class = model_class
17
+ filter_query_var_name(query_var_name) if query_var_name.present?
18
+ end
19
+
20
+ def filter_query_var_name(query_variable_name)
21
+ filter_coordinator.query_variable_name = query_variable_name
22
+ end
23
+
24
+ def filter_coordinator
25
+ @filter_coordinator ||= Filterameter::Coordinators::ControllerCoordinator.new(controller_name, controller_path)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def build_filtered_query
32
+ var_name = "@#{self.class.filter_coordinator.query_variable_name}"
33
+ instance_variable_set(
34
+ var_name,
35
+ self.class.filter_coordinator.build_query(params.to_unsafe_h.fetch(:filter, {}),
36
+ instance_variable_get(var_name))
37
+ )
38
+ end
39
+ end
40
+ end
@@ -1,79 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'filterameter/controller_filters'
3
+ require 'filterameter/coordinators/query_coordinator'
4
+ require 'filterameter/filterable'
5
+ require 'filterameter/query_builder'
4
6
 
5
7
  module Filterameter
6
8
  # = Declarative Filters
7
9
  #
8
- # module DeclarativeFilters provides a controller DSL to declare filters along with any validations.
10
+ # Mixin DeclarativeFilters is included to build query classes using the filter DSL.
9
11
  module DeclarativeFilters
10
12
  extend ActiveSupport::Concern
13
+ include Filterameter::Filterable
11
14
 
12
15
  class_methods do
13
- def filter_model(model_class, query_var_name = nil)
14
- controller_filters.model_class = model_class
15
- filter_query_var_name(query_var_name) if query_var_name.present?
16
- end
17
-
18
- def filter_query_var_name(query_variable_name)
19
- controller_filters.query_variable_name = query_variable_name
20
- end
16
+ delegate :build_query, to: :filter_coordinator
21
17
 
22
- # Declares a filter that can be read from the parameters and applied to the ActiveRecord query. The <tt>name</tt>
23
- # identifies the name of the parameter and is the default value to determine the criteria to be applied. The name
24
- # can be either an attribute or a scope.
25
- #
26
- # === Options
27
- #
28
- # [:name]
29
- # Specify the attribute or scope name if the parameter name is not the same. The default value
30
- # is the parameter name, so if the two match this can be left out.
31
- #
32
- # [:association]
33
- # Specify the name of the association if the attribute or scope is nested.
34
- #
35
- # [:validates]
36
- # Specify a validation if the parameter value should be validated. This uses ActiveModel validations;
37
- # please review those for types of validations and usage.
38
- #
39
- # [:partial]
40
- # Specify the partial option if the filter should do a partial search (SQL's `LIKE`). The partial
41
- # option accepts a hash to specify the search behavior. Here are the available options:
42
- # - match: anywhere (default), from_start, dynamic
43
- # - case_sensitive: true, false (default)
44
- #
45
- # There are two shortcuts: : the partial option can be declared with `true`, which just uses the
46
- # defaults; or the partial option can be declared with the match option directly,
47
- # such as `partial: :from_start`.
48
- #
49
- # [:range]
50
- # Specify a range option if the filter also allows ranges to be searched. The range option accepts
51
- # the following options:
52
- # - true: enables two additional parameters with attribute name plus suffixes <tt>_min</tt> and <tt>_max</tt>
53
- # - :min_only: enables additional parameter with attribute name plus suffix <tt>_min</tt>
54
- # - :max_only: enables additional parameter with attribute name plus suffix <tt>_max</tt>
55
- def filter(name, options = {})
56
- controller_filters.add_filter(name, options)
18
+ def model(model_class)
19
+ filter_coordinator.model_class = model_class
57
20
  end
58
21
 
59
- def filters(*names)
60
- names.each { |name| filter(name) }
22
+ def default_query(query)
23
+ filter_coordinator.default_query = query
61
24
  end
62
25
 
63
- def controller_filters
64
- @controller_filters ||= Filterameter::ControllerFilters.new(controller_name, controller_path)
26
+ def filter_coordinator
27
+ @filter_coordinator ||= Filterameter::Coordinators::QueryCoordinator.new
65
28
  end
66
29
  end
67
-
68
- private
69
-
70
- def build_filtered_query
71
- var_name = "@#{self.class.controller_filters.query_variable_name}"
72
- instance_variable_set(
73
- var_name,
74
- self.class.controller_filters.build_query(params.to_unsafe_h.fetch(:filter, {}),
75
- instance_variable_get(var_name))
76
- )
77
- end
78
30
  end
79
31
  end
@@ -10,7 +10,7 @@ module Filterameter
10
10
  class CannotDetermineModelError < FilterameterError
11
11
  def initialize(name, path)
12
12
  super "Cannot determine model name from controller name #{value_and_classify(name)} " \
13
- "or path #{value_and_classify(path)}. Declare the model explicitly with filter_model."
13
+ "or path #{value_and_classify(path)}. Declare the model explicitly with filter_model."
14
14
  end
15
15
 
16
16
  private
@@ -7,14 +7,15 @@ module Filterameter
7
7
  # Class UndeclaredParameterError is raised when a request contains filter parameters that have not been declared.
8
8
  # Configuration setting `action_on_undeclared_parameters` determines whether or not the exception is raised.
9
9
  class UndeclaredParameterError < FilterameterError
10
- attr_reader :keys
10
+ attr_reader :key
11
11
 
12
- def initialize(keys)
13
- @keys = keys
12
+ def initialize(key)
13
+ super
14
+ @key = key
14
15
  end
15
16
 
16
17
  def message
17
- "The following filter parameter(s) have not been declared: #{keys}"
18
+ "The following filter parameter has not been declared: #{key}"
18
19
  end
19
20
  end
20
21
  end
@@ -10,6 +10,7 @@ module Filterameter
10
10
  attr_reader :errors
11
11
 
12
12
  def initialize(errors)
13
+ super
13
14
  @errors = errors
14
15
  end
15
16
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ # Filters
5
+ #
6
+ # Class FilterRegistry is a collection of the filters. It captures the filter declarations when classes are loaded,
7
+ # then uses the injected FilterFactory to build the filters on demand as they are needed.
8
+ class FilterRegistry
9
+ attr_reader :ranges
10
+
11
+ def initialize(filter_factory)
12
+ @filter_factory = filter_factory
13
+ @declarations = {}
14
+ @ranges = {}
15
+ @filters = {}
16
+ end
17
+
18
+ def add_filter(parameter_name, options)
19
+ name = parameter_name.to_s
20
+ @declarations[name] = Filterameter::FilterDeclaration.new(name, options).tap do |fd|
21
+ add_declarations_for_range(fd, options, name) if fd.range_enabled?
22
+ end
23
+ end
24
+
25
+ def fetch(parameter_name)
26
+ name = parameter_name.to_s
27
+ @filters.fetch(name) do
28
+ raise Filterameter::Exceptions::UndeclaredParameterError, name unless @declarations.keys.include?(name)
29
+
30
+ @filters[name] = @filter_factory.build(@declarations[name])
31
+ end
32
+ end
33
+
34
+ def filter_declarations
35
+ @declarations.values
36
+ end
37
+
38
+ private
39
+
40
+ # if range is enabled, then in addition to the attribute filter this also adds min and/or max filters
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?
44
+ capture_range_declaration(parameter_name) if attribute_declaration.range?
45
+ end
46
+
47
+ def add_range_minimum(parameter_name, options)
48
+ parameter_name_min = "#{parameter_name}_min"
49
+ @declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min,
50
+ options.merge(range: :min_only))
51
+ end
52
+
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,
56
+ options.merge(range: :max_only))
57
+ end
58
+
59
+ def capture_range_declaration(name)
60
+ @ranges[name] = { min: "#{name}_min", max: "#{name}_max" }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ # = Declarative Filters
5
+ #
6
+ # Mixin Filterable provides class methods <tt>filter</tt> and <tt>filters</tt>.
7
+ module Filterable
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # Declares a filter that can be read from the parameters and applied to the ActiveRecord query. The <tt>name</tt>
12
+ # identifies the name of the parameter and is the default value to determine the criteria to be applied. The name
13
+ # can be either an attribute or a scope.
14
+ #
15
+ # === Options
16
+ #
17
+ # [:name]
18
+ # Specify the attribute or scope name if the parameter name is not the same. The default value
19
+ # is the parameter name, so if the two match this can be left out.
20
+ #
21
+ # [:association]
22
+ # Specify the name of the association if the attribute or scope is nested.
23
+ #
24
+ # [:validates]
25
+ # Specify a validation if the parameter value should be validated. This uses ActiveModel validations;
26
+ # please review those for types of validations and usage.
27
+ #
28
+ # [:partial]
29
+ # Specify the partial option if the filter should do a partial search (SQL's `LIKE`). The partial
30
+ # option accepts a hash to specify the search behavior. Here are the available options:
31
+ # - match: anywhere (default), from_start, dynamic
32
+ # - case_sensitive: true, false (default)
33
+ #
34
+ # There are two shortcuts: : the partial option can be declared with `true`, which just uses the
35
+ # defaults; or the partial option can be declared with the match option directly,
36
+ # such as `partial: :from_start`.
37
+ #
38
+ # [:range]
39
+ # Specify a range option if the filter also allows ranges to be searched. The range option accepts
40
+ # the following options:
41
+ # - true: enables two additional parameters with attribute name plus suffixes <tt>_min</tt> and <tt>_max</tt>
42
+ # - :min_only: enables additional parameter with attribute name plus suffix <tt>_min</tt>
43
+ # - :max_only: enables additional parameter with attribute name plus suffix <tt>_max</tt>
44
+ def filter(name, options = {})
45
+ filter_coordinator.add_filter(name, options)
46
+ end
47
+
48
+ # Declares a list of filters that can be read from the parameters and applied to the query. The name can be either
49
+ # an attribute or a scope. Declare filters individually with <tt>filter</tt> if more options are required.
50
+ def filters(*names)
51
+ names.each { |name| filter(name) }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -22,11 +22,12 @@ module Options
22
22
  @match = 'anywhere'
23
23
  @case_sensitive = false
24
24
 
25
- if options.is_a?(TrueClass)
25
+ case options
26
+ when TrueClass
26
27
  nil
27
- elsif options.is_a? Hash
28
+ when Hash
28
29
  evaluate_hash(options)
29
- elsif options.is_a?(String) || options.is_a?(Symbol)
30
+ when String, Symbol
30
31
  assign_match(options)
31
32
  end
32
33
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'active_model/attribute_assignment'
4
4
  require 'active_model/validations'
5
+ require 'filterameter/validators/inclusion_validator'
5
6
 
6
7
  module Filterameter
7
8
  # = Parameters
@@ -9,6 +10,7 @@ module Filterameter
9
10
  # Class Parameters is sub-classed to provide controller-specific validations.
10
11
  class ParametersBase
11
12
  include ActiveModel::Validations
13
+ include Filterameter::Validators
12
14
 
13
15
  def self.build_sub_class(declarations)
14
16
  Class.new(self).tap do |sub_class|
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ # = Query Builder
5
+ #
6
+ # Class Query Builder turns filter parameters into a query.
7
+ class QueryBuilder
8
+ def initialize(default_query, filter_registry)
9
+ @default_query = default_query
10
+ @registry = filter_registry
11
+ end
12
+
13
+ def build_query(filter_params, starting_query = nil)
14
+ valid_filters(filter_params.stringify_keys)
15
+ .tap { |parameters| convert_min_and_max_to_range(parameters) }
16
+ .reduce(starting_query || @default_query) do |query, (name, value)|
17
+ add_filter_parameter_to_query(query, name, value)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def add_filter_parameter_to_query(query, filter_name, parameter_value)
24
+ @registry.fetch(filter_name).apply(query, parameter_value)
25
+ rescue Filterameter::Exceptions::UndeclaredParameterError => e
26
+ handle_undeclared_parameter(e)
27
+ query
28
+ end
29
+
30
+ def valid_filters(filter_params)
31
+ remove_invalid_values(filter_params)
32
+ end
33
+
34
+ # if both min and max are present in the query parameters, replace with range
35
+ def convert_min_and_max_to_range(parameters)
36
+ @registry.ranges.each do |attribute_name, min_max_names|
37
+ next unless min_max_names.values.all? { |min_max_name| parameters[min_max_name].present? }
38
+
39
+ parameters[attribute_name] = Range.new(parameters.delete(min_max_names[:min]),
40
+ parameters.delete(min_max_names[:max]))
41
+ end
42
+ end
43
+
44
+ def handle_undeclared_parameter(exception)
45
+ action = Filterameter.configuration.action_on_undeclared_parameters
46
+ return unless action
47
+
48
+ case action
49
+ when :log
50
+ ActiveSupport::Notifications.instrument('undeclared_parameters.filterameter', key: exception.key)
51
+ when :raise
52
+ raise exception
53
+ end
54
+ end
55
+
56
+ def remove_invalid_values(filter_params)
57
+ validator = validator_class.new(filter_params)
58
+ return filter_params if validator.valid?
59
+
60
+ case Filterameter.configuration.action_on_validation_failure
61
+ when :log
62
+ ActiveSupport::Notifications.instrument('validation_failure.filterameter', errors: validator.errors)
63
+ when :raise
64
+ raise Filterameter::Exceptions::ValidationError, validator.errors
65
+ end
66
+
67
+ filter_params.except(*invalid_attributes(validator.errors).map(&:to_s))
68
+ end
69
+
70
+ def invalid_attributes(errors)
71
+ if errors.respond_to? :attribute_names
72
+ errors.attribute_names
73
+ else # pre rails 6.1
74
+ errors.keys
75
+ end
76
+ end
77
+
78
+ def validator_class
79
+ @validator_class ||= Filterameter::ParametersBase.build_sub_class(@registry.filter_declarations)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module Validators
5
+ # = Inclusion Validator
6
+ #
7
+ # Class InclusionValidator extends ActiveModel::Validations::InclusionValidator to enable validations of multiple
8
+ # values.
9
+ #
10
+ # == Example
11
+ #
12
+ # validates: { inclusion: { in: %w[Small Medium Large], allow_multiple_values: true } }
13
+ #
14
+ class InclusionValidator < ActiveModel::Validations::InclusionValidator
15
+ def validate_each(record, attribute, value)
16
+ return super unless allow_multiple_values?
17
+
18
+ # any? just provides a mechanism to stop after first error
19
+ Array.wrap(value).any? { |v| super(record, attribute, v) }
20
+ end
21
+
22
+ private
23
+
24
+ def allow_multiple_values?
25
+ @options.fetch(:allow_multiple_values, false)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Filterameter
4
- VERSION = '0.1.4'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/filterameter.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'filterameter/configuration'
4
+ require 'filterameter/declarative_controller_filters'
4
5
  require 'filterameter/declarative_filters'
5
6
  require 'filterameter/exceptions'
6
7
 
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.1.4
4
+ version: 0.3.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: 2020-07-14 00:00:00.000000000 Z
11
+ date: 2022-12-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: 5.2.2
27
+ - !ruby/object:Gem::Dependency
28
+ name: appraisal
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.4.1
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.4.1
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: guard
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -58,14 +72,14 @@ dependencies:
58
72
  requirements:
59
73
  - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: 1.3.0
75
+ version: 1.5.0
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: 1.3.0
82
+ version: 1.5.0
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: pg
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -100,14 +114,28 @@ dependencies:
100
114
  requirements:
101
115
  - - "~>"
102
116
  - !ruby/object:Gem::Version
103
- version: '0.77'
117
+ version: 1.31.1
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 1.31.1
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-rails
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 2.15.1
104
132
  type: :development
105
133
  prerelease: false
106
134
  version_requirements: !ruby/object:Gem::Requirement
107
135
  requirements:
108
136
  - - "~>"
109
137
  - !ruby/object:Gem::Version
110
- version: '0.77'
138
+ version: 2.15.1
111
139
  - !ruby/object:Gem::Dependency
112
140
  name: simplecov
113
141
  requirement: !ruby/object:Gem::Requirement
@@ -122,7 +150,7 @@ dependencies:
122
150
  - - "~>"
123
151
  - !ruby/object:Gem::Version
124
152
  version: '0.18'
125
- description: Enable filter parameters to be declared in controllers.
153
+ description: Enable filter parameters to be declared in query classes or controllers.
126
154
  email:
127
155
  - todd@rockridgesolutions.com
128
156
  executables: []
@@ -134,7 +162,10 @@ files:
134
162
  - Rakefile
135
163
  - lib/filterameter.rb
136
164
  - lib/filterameter/configuration.rb
137
- - lib/filterameter/controller_filters.rb
165
+ - lib/filterameter/coordinators/base.rb
166
+ - lib/filterameter/coordinators/controller_coordinator.rb
167
+ - lib/filterameter/coordinators/query_coordinator.rb
168
+ - lib/filterameter/declarative_controller_filters.rb
138
169
  - lib/filterameter/declarative_filters.rb
139
170
  - lib/filterameter/exceptions.rb
140
171
  - lib/filterameter/exceptions/cannot_determine_model_error.rb
@@ -142,6 +173,8 @@ files:
142
173
  - lib/filterameter/exceptions/validation_error.rb
143
174
  - lib/filterameter/filter_declaration.rb
144
175
  - lib/filterameter/filter_factory.rb
176
+ - lib/filterameter/filter_registry.rb
177
+ - lib/filterameter/filterable.rb
145
178
  - lib/filterameter/filters/arel_filter.rb
146
179
  - lib/filterameter/filters/attribute_filter.rb
147
180
  - lib/filterameter/filters/conditional_scope_filter.rb
@@ -153,6 +186,8 @@ files:
153
186
  - lib/filterameter/log_subscriber.rb
154
187
  - lib/filterameter/options/partial_options.rb
155
188
  - lib/filterameter/parameters_base.rb
189
+ - lib/filterameter/query_builder.rb
190
+ - lib/filterameter/validators/inclusion_validator.rb
156
191
  - lib/filterameter/version.rb
157
192
  homepage: https://github.com/RockSolt/filterameter
158
193
  licenses:
@@ -173,8 +208,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
173
208
  - !ruby/object:Gem::Version
174
209
  version: '0'
175
210
  requirements: []
176
- rubygems_version: 3.0.8
211
+ rubygems_version: 3.1.6
177
212
  signing_key:
178
213
  specification_version: 4
179
- summary: Declarative Filter Parameters for Rails Controllers
214
+ summary: Declarative Filter Parameters
180
215
  test_files: []
@@ -1,148 +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
- require 'filterameter/filter_factory'
11
- require 'filterameter/filter_declaration'
12
- require 'filterameter/log_subscriber'
13
- require 'filterameter/parameters_base'
14
-
15
- module Filterameter
16
- # = Controller Filters
17
- #
18
- # Class ControllerFilters stores the configuration declared via class-level method calls such as the list of
19
- # filters and the optionally declared model class. Each controller will have one instance of the controller
20
- # declarations stored as a class variable.
21
- class ControllerFilters
22
- attr_writer :query_variable_name
23
-
24
- def initialize(controller_name, controller_path)
25
- @controller_name = controller_name
26
- @controller_path = controller_path
27
- @declarations = {}
28
- @ranges = {}
29
- @filters = Hash.new { |hash, key| hash[key] = filter_factory.build(@declarations[key]) }
30
- end
31
-
32
- def model_class=(model_class)
33
- @model_class = model_class.is_a?(String) ? model_class.constantize : model_class
34
- end
35
-
36
- def add_filter(parameter_name, options)
37
- @declarations[parameter_name.to_s] =
38
- Filterameter::FilterDeclaration.new(parameter_name, options).tap do |fd|
39
- add_declarations_for_range(fd, options, parameter_name) if fd.range_enabled?
40
- end
41
- end
42
-
43
- def query_variable_name
44
- @query_variable_name ||= model_class.model_name.plural
45
- end
46
-
47
- def build_query(filter_params, starting_query)
48
- valid_filters(filter_params)
49
- .tap { |parameters| convert_min_and_max_to_range(parameters) }
50
- .reduce(starting_query || model_class.all) do |query, (name, value)|
51
- @filters[name].apply(query, value)
52
- end
53
- end
54
-
55
- private
56
-
57
- def model_class
58
- @model_class ||= @controller_name.classify.safe_constantize ||
59
- @controller_path.classify.safe_constantize ||
60
- raise(Filterameter::Exceptions::CannotDetermineModelError.new(@controller_name,
61
- @controller_path))
62
- end
63
-
64
- # lazy so that model_class can be optionally set
65
- def filter_factory
66
- @filter_factory ||= Filterameter::FilterFactory.new(model_class)
67
- end
68
-
69
- def valid_filters(filter_params)
70
- remove_invalid_values(
71
- remove_undeclared_filters(filter_params)
72
- )
73
- end
74
-
75
- # if both min and max are present in the query parameters, replace with range
76
- def convert_min_and_max_to_range(parameters)
77
- @ranges.each do |attribute_name, min_max_names|
78
- next unless min_max_names.values.all? { |min_max_name| parameters[min_max_name].present? }
79
-
80
- parameters[attribute_name] = Range.new(parameters.delete(min_max_names[:min]),
81
- parameters.delete(min_max_names[:max]))
82
- end
83
- end
84
-
85
- def remove_undeclared_filters(filter_params)
86
- filter_params.slice(*declared_parameter_names).tap do |declared_parameters|
87
- handle_undeclared_parameters(filter_params) if declared_parameters.size != filter_params.size
88
- end
89
- end
90
-
91
- def handle_undeclared_parameters(filter_params)
92
- action = Filterameter.configuration.action_on_undeclared_parameters
93
- return unless action
94
-
95
- undeclared_parameter_names = filter_params.keys - declared_parameter_names
96
- case action
97
- when :log
98
- ActiveSupport::Notifications.instrument('undeclared_parameters.filterameter', keys: undeclared_parameter_names)
99
- when :raise
100
- raise Filterameter::Exceptions::UndeclaredParameterError, undeclared_parameter_names
101
- end
102
- end
103
-
104
- def remove_invalid_values(filter_params)
105
- validator = validator_class.new(filter_params)
106
- return filter_params if validator.valid?
107
-
108
- case Filterameter.configuration.action_on_validation_failure
109
- when :log
110
- ActiveSupport::Notifications.instrument('validation_failure.filterameter', errors: validator.errors)
111
- when :raise
112
- raise Filterameter::Exceptions::ValidationError, validator.errors
113
- end
114
-
115
- filter_params.except(*validator.errors.keys.map(&:to_s))
116
- end
117
-
118
- def declared_parameter_names
119
- @declared_parameter_names ||= @declarations.keys
120
- end
121
-
122
- def validator_class
123
- @validator_class ||= Filterameter::ParametersBase.build_sub_class(@declarations.values)
124
- end
125
-
126
- # if range is enabled, then in addition to the attribute filter this also adds min and/or max filters
127
- def add_declarations_for_range(attribute_declaration, options, parameter_name)
128
- add_range_minimum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.minimum?
129
- add_range_maximum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.maximum?
130
- capture_range_declaration(parameter_name) if attribute_declaration.range?
131
- end
132
-
133
- def add_range_minimum(parameter_name, options)
134
- @declarations["#{parameter_name}_min"] = Filterameter::FilterDeclaration.new(parameter_name,
135
- options.merge(range: :min_only))
136
- end
137
-
138
- def add_range_maximum(parameter_name, options)
139
- @declarations["#{parameter_name}_max"] = Filterameter::FilterDeclaration.new(parameter_name,
140
- options.merge(range: :max_only))
141
- end
142
-
143
- # memoizing these makes it easier to spot and replace ranges in query parameters; see convert_min_and_max_to_range
144
- def capture_range_declaration(name)
145
- @ranges[name] = { min: "#{name}_min", max: "#{name}_max" }
146
- end
147
- end
148
- end