filterameter 0.1.1 → 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: 6a523b898a73ce9f67d723e9dcbf1131748bf7d431c8cab11dc6faa00ac9409a
4
- data.tar.gz: 428a21db90c7823118037fe7fd698dda522644a3cf47bcb1b5c3e0b0d8a7418e
3
+ metadata.gz: e753f63174bbf6eb6a0ad352fc518782bc092f5e9075bcd596d540c76ceb6ab1
4
+ data.tar.gz: 14f544522f30cbf4d2d6fe54fb5e8a5e0310172fcc75007c087e9402359d6ae3
5
5
  SHA512:
6
- metadata.gz: 82db4c9d6db4b7bf27815e97ff3479fe2e0afc85ec66ea69db6807ba2ff0a98b2b61fe82975b61164c22eed53b07e59678dcd0098912d89f02909a4276ba44f9
7
- data.tar.gz: 53401b585ec11bc664c8268425720e890bbce6197606366059d46c9eb78fd72350217860fb2cf54f1c16fd055a2c88b3fe81789e4a45cf97c0d618b89be400ef
6
+ metadata.gz: 13b2457bf17e0cdd1d9b11075d4a5df015179b46000a2b3418d10ab5885bb5d2fc0435c59bfdae7668c2cf165973084ccd2a1403e95f32e1e40cef1a67849dfb
7
+ data.tar.gz: 26b14da4bdc7e786dbd025af086dc9aadac131306c3ef82ea7ef8f73e72e7518e4de825d34ab80b51ed2b3052705060ebf87b6ae5dce56a8adc697f051928258
data/README.md CHANGED
@@ -1,12 +1,17 @@
1
+ [![Gem Version](https://badge.fury.io/rb/filterameter.svg)](https://badge.fury.io/rb/filterameter)
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)
5
+
1
6
  # Filterameter
2
- Declarative Filter Parameters for Rails Controllers.
7
+ Declarative filter parameters provide provide clean and clear filters for queries.
3
8
 
4
9
  ## Usage
5
- 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.
6
11
 
7
12
  ```ruby
8
13
  filter :color
9
- 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 } }
10
15
  filter :brand_name, association: :brand, name: :name
11
16
  filter :on_sale, association: :price, validates: [{ numericality: { greater_than: 0 } },
12
17
  { numericality: { less_than: 100 } }]
@@ -34,14 +39,87 @@ filter :manager_id, association: :department
34
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:
35
40
 
36
41
  ```ruby
37
- 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 } }
49
+ ```
50
+
51
+
52
+ #### partial
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:
54
+ - match: anywhere (default), from_start, dynamic
55
+ - case_sensitive: true, false (default)
56
+
57
+ There are two shortcuts: : the partial option can be declared with `true`, which just uses the defaults; or the partial option can be declared with the match option directly, such as `partial: :from_start`.
58
+
59
+ ```ruby
60
+ filter :description, partial: true
61
+ filter :department_name, partial: :from_start
62
+ filter :reason, partial: { match: :dynamic, case_sensitive: true }
63
+ ```
64
+
65
+ The `match` options defines where you are searching (which then controls where the wildcard(s) appear):
66
+ - anywhere: adds wildcards at the start and end, for example '%blue%'
67
+ - from_start: adds a wildcard at the end, for example 'blue%'
68
+ - dynamic: adds no wildcards; this enables the client to fully control the search string
69
+
70
+ #### range
71
+ Specify the range option to enable searches by ranges, minimum values, or maximum values. (All of these are inclusive. A search for a minimum value of $10.00 would include all items priced at $10.00.)
72
+
73
+ Here are the available options:
74
+ - true: enable ranges, minimum values, and/or maximum values
75
+ - min_only: enables minimum values
76
+ - max_only: enables maximum values
77
+
78
+ Using the range option means that _in addition to the attribute filter_ minimum and maximum query parameters may also be specified. The parameter names are the attribute name plus the suffix <tt>_min</tt> or <tt>_max</tt>.
79
+
80
+ ```ruby
81
+ filter :price, range: true
82
+ filter :approved_at, range: :min_only
83
+ filter :sale_price, range: :max_only
84
+ ```
85
+
86
+ In the first example, query parameters could include <tt>price</tt>, <tt>price_min</tt>, and <tt>price_max</tt>.
87
+
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
38
104
  ```
39
105
 
40
- 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.
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:
41
112
 
42
- ### Configuring Controllers
113
+ ```ruby
114
+ filters = { size: 'large', color: 'blue' }
115
+ widgets = WidgetQuery.build_query(filters, Widget.limit(10))
116
+ ```
43
117
 
44
- 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:
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:
45
123
 
46
124
  #### filter_model
47
125
  Provide the name of the model. This method also allows the variable name to be optionally provided as the second parameter.
@@ -57,6 +135,24 @@ Provide the name of the instance variable. For example, if the query is stored a
57
135
  filter_query_var_name :data
58
136
  ```
59
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
+
60
156
  ## Installation
61
157
  Add this line to your application's Gemfile:
62
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,50 +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
11
-
12
- included do
13
- before_action :build_filtered_query, only: :index
14
- end
13
+ include Filterameter::Filterable
15
14
 
16
15
  class_methods do
17
- def filter_model(model_class, query_var_name = nil)
18
- controller_filters.model_class = model_class
19
- filter_query_var_name(query_var_name) if query_var_name.present?
20
- end
21
-
22
- def filter_query_var_name(query_variable_name)
23
- controller_filters.query_variable_name = query_variable_name
24
- end
16
+ delegate :build_query, to: :filter_coordinator
25
17
 
26
- def filter(name, options = {})
27
- controller_filters.add_filter(name, options)
18
+ def model(model_class)
19
+ filter_coordinator.model_class = model_class
28
20
  end
29
21
 
30
- def filters(*names)
31
- names.each { |name| filter(name) }
22
+ def default_query(query)
23
+ filter_coordinator.default_query = query
32
24
  end
33
25
 
34
- def controller_filters
35
- @controller_filters ||= Filterameter::ControllerFilters.new(controller_name, controller_path)
26
+ def filter_coordinator
27
+ @filter_coordinator ||= Filterameter::Coordinators::QueryCoordinator.new
36
28
  end
37
29
  end
38
-
39
- private
40
-
41
- def build_filtered_query
42
- var_name = "@#{self.class.controller_filters.query_variable_name}"
43
- instance_variable_set(
44
- var_name,
45
- self.class.controller_filters.build_query(params.to_unsafe_h.fetch(:filter, {}),
46
- instance_variable_get(var_name))
47
- )
48
- end
49
30
  end
50
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
 
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/core_ext/array/wrap'
4
+ require 'filterameter/options/partial_options'
4
5
 
5
6
  module Filterameter
6
7
  # = Filter Declaration
7
8
  #
8
9
  # Class FilterDeclaration captures the filter declaration within the controller.
9
10
  class FilterDeclaration
11
+ VALID_RANGE_OPTIONS = [true, :min_only, :max_only].freeze
12
+
10
13
  attr_reader :name, :parameter_name, :association, :validations
11
14
 
12
15
  def initialize(parameter_name, options)
@@ -17,6 +20,8 @@ module Filterameter
17
20
  @association = options[:association]
18
21
  @filter_on_empty = options.fetch(:filter_on_empty, false)
19
22
  @validations = Array.wrap(options[:validates])
23
+ @raw_partial_options = options.fetch(:partial, false)
24
+ @raw_range = options[:range]
20
25
  end
21
26
 
22
27
  def nested?
@@ -31,10 +36,41 @@ module Filterameter
31
36
  @filter_on_empty
32
37
  end
33
38
 
39
+ def partial_search?
40
+ partial_options.present?
41
+ end
42
+
43
+ def partial_options
44
+ @partial_options ||= @raw_partial_options ? Options::PartialOptions.new(@raw_partial_options) : nil
45
+ end
46
+
47
+ def range_enabled?
48
+ @raw_range.present?
49
+ end
50
+
51
+ def range?
52
+ @raw_range == true
53
+ end
54
+
55
+ def minimum?
56
+ @raw_range == :min_only
57
+ end
58
+
59
+ def maximum?
60
+ @raw_range == :max_only
61
+ end
62
+
34
63
  private
35
64
 
36
65
  def validate_options(options)
37
- options.assert_valid_keys(:name, :association, :filter_on_empty, :validates)
66
+ options.assert_valid_keys(:name, :association, :filter_on_empty, :validates, :partial, :range)
67
+ validate_range(options[:range]) if options.key?(:range)
68
+ end
69
+
70
+ def validate_range(range)
71
+ return if VALID_RANGE_OPTIONS.include?(range)
72
+
73
+ raise ArgumentError, "Invalid range option: #{range}"
38
74
  end
39
75
  end
40
76
  end
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'filterameter/filters/arel_filter'
3
4
  require 'filterameter/filters/attribute_filter'
4
5
  require 'filterameter/filters/conditional_scope_filter'
6
+ require 'filterameter/filters/matches_filter'
7
+ require 'filterameter/filters/maximum_filter'
8
+ require 'filterameter/filters/minimum_filter'
5
9
  require 'filterameter/filters/nested_filter'
6
10
  require 'filterameter/filters/scope_filter'
7
11
 
@@ -16,19 +20,25 @@ module Filterameter
16
20
 
17
21
  def build(declaration)
18
22
  model = declaration.nested? ? model_from_association(declaration.association) : @model_class
19
- filter = build_filter(model, declaration.name)
23
+ filter = build_filter(model, declaration)
20
24
 
21
25
  declaration.nested? ? Filterameter::Filters::NestedFilter.new(declaration.association, model, filter) : filter
22
26
  end
23
27
 
24
28
  private
25
29
 
26
- def build_filter(model, name)
30
+ def build_filter(model, declaration) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
27
31
  # checking dangerous_class_method? excludes any names that cannot be scope names, such as "name"
28
- if model.respond_to?(name) && !model.dangerous_class_method?(name)
29
- Filterameter::Filters::ScopeFilter.new(name)
32
+ if model.respond_to?(declaration.name) && !model.dangerous_class_method?(declaration.name)
33
+ Filterameter::Filters::ScopeFilter.new(declaration.name)
34
+ elsif declaration.partial_search?
35
+ Filterameter::Filters::MatchesFilter.new(declaration.name, declaration.partial_options)
36
+ elsif declaration.minimum?
37
+ Filterameter::Filters::MinimumFilter.new(model, declaration.name)
38
+ elsif declaration.maximum?
39
+ Filterameter::Filters::MaximumFilter.new(model, declaration.name)
30
40
  else
31
- Filterameter::Filters::AttributeFilter.new(name)
41
+ Filterameter::Filters::AttributeFilter.new(declaration.name)
32
42
  end
33
43
  end
34
44
 
@@ -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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module Filters
5
+ # = Arel Filter
6
+ #
7
+ # Class ArelFilter is a base class for arel queries. It does not implement <tt>apply</tt>.
8
+ class ArelFilter
9
+ def initialize(model, attribute_name)
10
+ @arel_attribute = model.arel_table[attribute_name]
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module Filters
5
+ # = Matches Filter
6
+ #
7
+ # Class MatchesFilter uses arel's `matches` to generate a LIKE query.
8
+ class MatchesFilter
9
+ def initialize(attribute_name, options)
10
+ @attribute_name = attribute_name
11
+ @prefix = options.match_anywhere? ? '%' : nil
12
+ @suffix = options.match_anywhere? || options.match_from_start? ? '%' : nil
13
+ @case_sensitive = options.case_sensitive?
14
+ end
15
+
16
+ def apply(query, value)
17
+ arel = query.arel_table[@attribute_name].matches("#{@prefix}#{value}#{@suffix}", false, @case_sensitive)
18
+ query.where(arel)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module Filters
5
+ # = Maximum Filter
6
+ #
7
+ # Class MaximumFilter adds criteria for all values greater than or equal to a maximum.
8
+ class MaximumFilter < ArelFilter
9
+ def apply(query, value)
10
+ query.where(@arel_attribute.lteq(value))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Filterameter
4
+ module Filters
5
+ # = Minimum Filter
6
+ #
7
+ # Class MinimumFilter adds criteria for all values greater than or equal to a minimum.
8
+ class MinimumFilter < ArelFilter
9
+ def apply(query, value)
10
+ query.where(@arel_attribute.gteq(value))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Options
4
+ # = Partial Options
5
+ #
6
+ # Class PartialOptions parses the options passed in as partial, then exposes those. Here are the options along with
7
+ # their valid values:
8
+ # - match: anywhere (default), from_start, dynamic
9
+ # - case_sensitive: true, false (default)
10
+ #
11
+ # Options may be specified by passing a hash with the option keys:
12
+ #
13
+ # partial: { match: :from_start, case_sensitive: true }
14
+ #
15
+ # There are two shortcuts: the partial option can be declared with `true`, which just uses the defaults; or the
16
+ # partial option can be declared with the match option directly, such as partial: :from_start.
17
+ class PartialOptions
18
+ VALID_OPTIONS = %i[match case_sensitive].freeze
19
+ VALID_MATCH_OPTIONS = %w[anywhere from_start dynamic].freeze
20
+
21
+ def initialize(options)
22
+ @match = 'anywhere'
23
+ @case_sensitive = false
24
+
25
+ case options
26
+ when TrueClass
27
+ nil
28
+ when Hash
29
+ evaluate_hash(options)
30
+ when String, Symbol
31
+ assign_match(options)
32
+ end
33
+ end
34
+
35
+ def case_sensitive?
36
+ @case_sensitive
37
+ end
38
+
39
+ def match_anywhere?
40
+ @match == 'anywhere'
41
+ end
42
+
43
+ def match_from_start?
44
+ @match == 'from_start'
45
+ end
46
+
47
+ def match_dynamically?
48
+ @match == 'dynamic'
49
+ end
50
+
51
+ private
52
+
53
+ def evaluate_hash(options)
54
+ options.assert_valid_keys(:match, :case_sensitive)
55
+ assign_match(options[:match]) if options.key?(:match)
56
+ assign_case_sensitive(options[:case_sensitive]) if options.key?(:case_sensitive)
57
+ end
58
+
59
+ def assign_match(value)
60
+ validate_match(value)
61
+ @match = value.to_s
62
+ end
63
+
64
+ def validate_match(value)
65
+ return if VALID_MATCH_OPTIONS.include? value.to_s
66
+
67
+ raise ArgumentError,
68
+ "Invalid match option for partial: #{value}. Valid options are #{VALID_MATCH_OPTIONS.to_sentence}"
69
+ end
70
+
71
+ def assign_case_sensitive(value)
72
+ validate_case_sensitive(value)
73
+ @case_sensitive = value
74
+ end
75
+
76
+ def validate_case_sensitive(value)
77
+ return if value.is_a?(TrueClass) || value.is_a?(FalseClass)
78
+
79
+ raise ArgumentError, "Invalid case_sensitive option for partial: #{value}. Valid options are true and false."
80
+ end
81
+ end
82
+ 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.1'
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.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Todd Kummer
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-04-13 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
@@ -24,20 +24,62 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: 5.2.2
27
+ - !ruby/object:Gem::Dependency
28
+ name: guard
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.16'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.16'
41
+ - !ruby/object:Gem::Dependency
42
+ name: guard-rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '4.7'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '4.7'
55
+ - !ruby/object:Gem::Dependency
56
+ name: guard-rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.5.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.5.0
27
69
  - !ruby/object:Gem::Dependency
28
70
  name: pg
29
71
  requirement: !ruby/object:Gem::Requirement
30
72
  requirements:
31
- - - ">="
73
+ - - "~>"
32
74
  - !ruby/object:Gem::Version
33
- version: '0'
75
+ version: 1.1.4
34
76
  type: :development
35
77
  prerelease: false
36
78
  version_requirements: !ruby/object:Gem::Requirement
37
79
  requirements:
38
- - - ">="
80
+ - - "~>"
39
81
  - !ruby/object:Gem::Version
40
- version: '0'
82
+ version: 1.1.4
41
83
  - !ruby/object:Gem::Dependency
42
84
  name: rspec-rails
43
85
  requirement: !ruby/object:Gem::Requirement
@@ -58,28 +100,42 @@ dependencies:
58
100
  requirements:
59
101
  - - "~>"
60
102
  - !ruby/object:Gem::Version
61
- version: '0.77'
103
+ version: 1.22.3
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
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
62
118
  type: :development
63
119
  prerelease: false
64
120
  version_requirements: !ruby/object:Gem::Requirement
65
121
  requirements:
66
122
  - - "~>"
67
123
  - !ruby/object:Gem::Version
68
- version: '0.77'
124
+ version: 2.8.1
69
125
  - !ruby/object:Gem::Dependency
70
126
  name: simplecov
71
127
  requirement: !ruby/object:Gem::Requirement
72
128
  requirements:
73
- - - ">="
129
+ - - "~>"
74
130
  - !ruby/object:Gem::Version
75
- version: '0'
131
+ version: '0.18'
76
132
  type: :development
77
133
  prerelease: false
78
134
  version_requirements: !ruby/object:Gem::Requirement
79
135
  requirements:
80
- - - ">="
136
+ - - "~>"
81
137
  - !ruby/object:Gem::Version
82
- version: '0'
138
+ version: '0.18'
83
139
  description: Enable filter parameters to be declared in controllers.
84
140
  email:
85
141
  - todd@rockridgesolutions.com
@@ -92,7 +148,10 @@ files:
92
148
  - Rakefile
93
149
  - lib/filterameter.rb
94
150
  - lib/filterameter/configuration.rb
95
- - 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
96
155
  - lib/filterameter/declarative_filters.rb
97
156
  - lib/filterameter/exceptions.rb
98
157
  - lib/filterameter/exceptions/cannot_determine_model_error.rb
@@ -100,19 +159,27 @@ files:
100
159
  - lib/filterameter/exceptions/validation_error.rb
101
160
  - lib/filterameter/filter_declaration.rb
102
161
  - lib/filterameter/filter_factory.rb
162
+ - lib/filterameter/filter_registry.rb
163
+ - lib/filterameter/filterable.rb
164
+ - lib/filterameter/filters/arel_filter.rb
103
165
  - lib/filterameter/filters/attribute_filter.rb
104
166
  - lib/filterameter/filters/conditional_scope_filter.rb
167
+ - lib/filterameter/filters/matches_filter.rb
168
+ - lib/filterameter/filters/maximum_filter.rb
169
+ - lib/filterameter/filters/minimum_filter.rb
105
170
  - lib/filterameter/filters/nested_filter.rb
106
171
  - lib/filterameter/filters/scope_filter.rb
107
172
  - lib/filterameter/log_subscriber.rb
173
+ - lib/filterameter/options/partial_options.rb
108
174
  - lib/filterameter/parameters_base.rb
175
+ - lib/filterameter/query_builder.rb
176
+ - lib/filterameter/validators/inclusion_validator.rb
109
177
  - lib/filterameter/version.rb
110
- - lib/tasks/filterameter_tasks.rake
111
178
  homepage: https://github.com/RockSolt/filterameter
112
179
  licenses:
113
180
  - MIT
114
181
  metadata: {}
115
- post_install_message:
182
+ post_install_message:
116
183
  rdoc_options: []
117
184
  require_paths:
118
185
  - lib
@@ -128,7 +195,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
195
  version: '0'
129
196
  requirements: []
130
197
  rubygems_version: 3.0.8
131
- signing_key:
198
+ signing_key:
132
199
  specification_version: 4
133
200
  summary: Declarative Filter Parameters for Rails Controllers
134
201
  test_files: []
@@ -1,110 +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
- @filters = Hash.new { |hash, key| hash[key] = filter_factory.build(@declarations[key]) }
29
- end
30
-
31
- def model_class=(model_class)
32
- @model_class = model_class.is_a?(String) ? model_class.constantize : model_class
33
- end
34
-
35
- def add_filter(parameter_name, options)
36
- @declarations[parameter_name.to_s] = Filterameter::FilterDeclaration.new(parameter_name, options)
37
- end
38
-
39
- def query_variable_name
40
- @query_variable_name ||= model_class.model_name.plural
41
- end
42
-
43
- def build_query(filter_params, starting_query)
44
- valid_filters(filter_params).reduce(starting_query || model_class.all) do |query, (name, value)|
45
- @filters[name].apply(query, value)
46
- end
47
- end
48
-
49
- private
50
-
51
- def model_class
52
- @model_class ||= @controller_name.classify.safe_constantize ||
53
- @controller_path.classify.safe_constantize ||
54
- raise(Filterameter::Exceptions::CannotDetermineModelError.new(@controller_name,
55
- @controller_path))
56
- end
57
-
58
- # lazy so that model_class can be optionally set
59
- def filter_factory
60
- @filter_factory ||= Filterameter::FilterFactory.new(model_class)
61
- end
62
-
63
- def valid_filters(filter_params)
64
- remove_invalid_values(
65
- remove_undeclared_filters(filter_params)
66
- )
67
- end
68
-
69
- def remove_undeclared_filters(filter_params)
70
- filter_params.slice(*declared_parameter_names).tap do |declared_parameters|
71
- handle_undeclared_parameters(filter_params) if declared_parameters.size != filter_params.size
72
- end
73
- end
74
-
75
- def handle_undeclared_parameters(filter_params)
76
- action = Filterameter.configuration.action_on_undeclared_parameters
77
- return unless action
78
-
79
- undeclared_parameter_names = filter_params.keys - declared_parameter_names
80
- case action
81
- when :log
82
- ActiveSupport::Notifications.instrument('undeclared_parameters.filterameter', keys: undeclared_parameter_names)
83
- when :raise
84
- raise Filterameter::Exceptions::UndeclaredParameterError, undeclared_parameter_names
85
- end
86
- end
87
-
88
- def remove_invalid_values(filter_params)
89
- validator = validator_class.new(filter_params)
90
- return filter_params if validator.valid?
91
-
92
- case Filterameter.configuration.action_on_validation_failure
93
- when :log
94
- ActiveSupport::Notifications.instrument('validation_failure.filterameter', errors: validator.errors)
95
- when :raise
96
- raise Filterameter::Exceptions::ValidationError, validator.errors
97
- end
98
-
99
- filter_params.except(*validator.errors.keys.map(&:to_s))
100
- end
101
-
102
- def declared_parameter_names
103
- @declared_parameter_names ||= @declarations.keys
104
- end
105
-
106
- def validator_class
107
- @validator_class ||= Filterameter::ParametersBase.build_sub_class(@declarations.values)
108
- end
109
- end
110
- end
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
- # desc "Explaining what the task does"
3
- # task :filterameter do
4
- # # Task goes here
5
- # end