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 +4 -4
- data/README.md +64 -7
- data/lib/filterameter/coordinators/base.rb +43 -0
- data/lib/filterameter/coordinators/controller_coordinator.rb +42 -0
- data/lib/filterameter/coordinators/query_coordinator.rb +18 -0
- data/lib/filterameter/declarative_controller_filters.rb +40 -0
- data/lib/filterameter/declarative_filters.rb +12 -60
- data/lib/filterameter/exceptions/cannot_determine_model_error.rb +1 -1
- data/lib/filterameter/exceptions/undeclared_parameter_error.rb +5 -4
- data/lib/filterameter/exceptions/validation_error.rb +1 -0
- data/lib/filterameter/filter_registry.rb +62 -0
- data/lib/filterameter/filterable.rb +55 -0
- data/lib/filterameter/options/partial_options.rb +4 -3
- data/lib/filterameter/parameters_base.rb +2 -0
- data/lib/filterameter/query_builder.rb +74 -0
- data/lib/filterameter/validators/inclusion_validator.rb +29 -0
- data/lib/filterameter/version.rb +1 -1
- data/lib/filterameter.rb +1 -0
- metadata +28 -7
- data/lib/filterameter/controller_filters.rb +0 -148
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e753f63174bbf6eb6a0ad352fc518782bc092f5e9075bcd596d540c76ceb6ab1
|
4
|
+
data.tar.gz: 14f544522f30cbf4d2d6fe54fb5e8a5e0310172fcc75007c087e9402359d6ae3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
7
|
+
Declarative filter parameters provide provide clean and clear filters for queries.
|
6
8
|
|
7
9
|
## Usage
|
8
|
-
Declare filters
|
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]
|
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] }
|
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
|
-
###
|
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
|
-
|
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/
|
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
|
-
#
|
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
|
-
|
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
|
-
|
23
|
-
|
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
|
60
|
-
|
22
|
+
def default_query(query)
|
23
|
+
filter_coordinator.default_query = query
|
61
24
|
end
|
62
25
|
|
63
|
-
def
|
64
|
-
@
|
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
|
-
|
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 :
|
10
|
+
attr_reader :key
|
11
11
|
|
12
|
-
def initialize(
|
13
|
-
|
12
|
+
def initialize(key)
|
13
|
+
super
|
14
|
+
@key = key
|
14
15
|
end
|
15
16
|
|
16
17
|
def message
|
17
|
-
"The following filter parameter
|
18
|
+
"The following filter parameter has not been declared: #{key}"
|
18
19
|
end
|
19
20
|
end
|
20
21
|
end
|
@@ -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
|
-
|
25
|
+
case options
|
26
|
+
when TrueClass
|
26
27
|
nil
|
27
|
-
|
28
|
+
when Hash
|
28
29
|
evaluate_hash(options)
|
29
|
-
|
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
|
data/lib/filterameter/version.rb
CHANGED
data/lib/filterameter.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: filterameter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
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:
|
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.
|
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.
|
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:
|
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:
|
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/
|
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
|