filterameter 0.1.4 → 0.2.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: e753f63174bbf6eb6a0ad352fc518782bc092f5e9075bcd596d540c76ceb6ab1
4
+ data.tar.gz: 14f544522f30cbf4d2d6fe54fb5e8a5e0310172fcc75007c087e9402359d6ae3
5
5
  SHA512:
6
- metadata.gz: 24ce1e8ca752befea919c57f5e79af75d80f77569d0171a052b1b1a14c65af5d0e6ad234127767e3f42bddb4d058feafdee58130cf38102cd4b10eef2bdc3ca9
7
- data.tar.gz: 5e0ad2d3348d84df2d7198bc38611f0a3bb8ea52ce4882c61ca0289f48239afc11ec49f4b293fc79810cda25a92f8e236ad00b73bb3d4769e4d4912b99939651
6
+ metadata.gz: 13b2457bf17e0cdd1d9b11075d4a5df015179b46000a2b3418d10ab5885bb5d2fc0435c59bfdae7668c2cf165973084ccd2a1403e95f32e1e40cef1a67849dfb
7
+ data.tar.gz: 26b14da4bdc7e786dbd025af086dc9aadac131306c3ef82ea7ef8f73e72e7518e4de825d34ab80b51ed2b3052705060ebf87b6ae5dce56a8adc697f051928258
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,62 @@
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
+ @declarations[parameter_name.to_s] = Filterameter::FilterDeclaration.new(parameter_name, options).tap do |fd|
20
+ add_declarations_for_range(fd, options, parameter_name) if fd.range_enabled?
21
+ end
22
+ end
23
+
24
+ def fetch(name)
25
+ name = name.to_s
26
+ @filters.fetch(name) do
27
+ raise Filterameter::Exceptions::UndeclaredParameterError, name unless @declarations.keys.include?(name)
28
+
29
+ @filters[name] = @filter_factory.build(@declarations[name])
30
+ end
31
+ end
32
+
33
+ def filter_declarations
34
+ @declarations.values
35
+ end
36
+
37
+ private
38
+
39
+ # if range is enabled, then in addition to the attribute filter this also adds min and/or max filters
40
+ def add_declarations_for_range(attribute_declaration, options, parameter_name)
41
+ add_range_minimum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.minimum?
42
+ add_range_maximum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.maximum?
43
+ capture_range_declaration(parameter_name) if attribute_declaration.range?
44
+ end
45
+
46
+ def add_range_minimum(parameter_name, options)
47
+ parameter_name_min = "#{parameter_name}_min"
48
+ @declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min,
49
+ options.merge(range: :min_only))
50
+ end
51
+
52
+ def add_range_maximum(parameter_name, options)
53
+ parameter_name_min = "#{parameter_name}_max"
54
+ @declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min,
55
+ options.merge(range: :max_only))
56
+ end
57
+
58
+ def capture_range_declaration(name)
59
+ @ranges[name] = { min: "#{name}_min", max: "#{name}_max" }
60
+ end
61
+ end
62
+ 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,74 @@
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)
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(*validator.errors.attribute_names.map(&:to_s))
68
+ end
69
+
70
+ def validator_class
71
+ @validator_class ||= Filterameter::ParametersBase.build_sub_class(@registry.filter_declarations)
72
+ end
73
+ end
74
+ 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.2.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.2.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: 2021-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: 1.3.0
61
+ version: 1.5.0
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 1.3.0
68
+ version: 1.5.0
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: pg
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -100,14 +100,28 @@ dependencies:
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: '0.77'
103
+ version: 1.22.3
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: '0.77'
110
+ version: 1.22.3
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-rails
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 2.8.1
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 2.8.1
111
125
  - !ruby/object:Gem::Dependency
112
126
  name: simplecov
113
127
  requirement: !ruby/object:Gem::Requirement
@@ -134,7 +148,10 @@ files:
134
148
  - Rakefile
135
149
  - lib/filterameter.rb
136
150
  - lib/filterameter/configuration.rb
137
- - lib/filterameter/controller_filters.rb
151
+ - lib/filterameter/coordinators/base.rb
152
+ - lib/filterameter/coordinators/controller_coordinator.rb
153
+ - lib/filterameter/coordinators/query_coordinator.rb
154
+ - lib/filterameter/declarative_controller_filters.rb
138
155
  - lib/filterameter/declarative_filters.rb
139
156
  - lib/filterameter/exceptions.rb
140
157
  - lib/filterameter/exceptions/cannot_determine_model_error.rb
@@ -142,6 +159,8 @@ files:
142
159
  - lib/filterameter/exceptions/validation_error.rb
143
160
  - lib/filterameter/filter_declaration.rb
144
161
  - lib/filterameter/filter_factory.rb
162
+ - lib/filterameter/filter_registry.rb
163
+ - lib/filterameter/filterable.rb
145
164
  - lib/filterameter/filters/arel_filter.rb
146
165
  - lib/filterameter/filters/attribute_filter.rb
147
166
  - lib/filterameter/filters/conditional_scope_filter.rb
@@ -153,6 +172,8 @@ files:
153
172
  - lib/filterameter/log_subscriber.rb
154
173
  - lib/filterameter/options/partial_options.rb
155
174
  - lib/filterameter/parameters_base.rb
175
+ - lib/filterameter/query_builder.rb
176
+ - lib/filterameter/validators/inclusion_validator.rb
156
177
  - lib/filterameter/version.rb
157
178
  homepage: https://github.com/RockSolt/filterameter
158
179
  licenses:
@@ -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