filterameter 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +90 -1
- data/lib/filterameter/declarative_filters.rb +1 -0
- data/lib/filterameter/exceptions/collection_association_sort_error.rb +15 -0
- data/lib/filterameter/exceptions/invalid_association_declaration_error.rb +14 -0
- data/lib/filterameter/filter_coordinator.rb +9 -3
- data/lib/filterameter/filter_declaration.rb +16 -6
- data/lib/filterameter/filter_factory.rb +11 -35
- data/lib/filterameter/filters/nested_filter.rb +1 -24
- data/lib/filterameter/helpers/declaration_with_model.rb +52 -0
- data/lib/filterameter/helpers/joins_values_builder.rb +28 -0
- data/lib/filterameter/helpers/requested_sort.rb +25 -0
- data/lib/filterameter/options/partial_options.rb +14 -0
- data/lib/filterameter/query_builder.rb +59 -10
- data/lib/filterameter/registries/filter_registry.rb +57 -0
- data/lib/filterameter/registries/registry.rb +34 -0
- data/lib/filterameter/registries/sort_registry.rb +17 -0
- data/lib/filterameter/registries/sub_registry.rb +33 -0
- data/lib/filterameter/sort_declaration.rb +38 -0
- data/lib/filterameter/sort_factory.rb +48 -0
- data/lib/filterameter/sortable.rb +40 -0
- data/lib/filterameter/sorts/attribute_sort.rb +18 -0
- data/lib/filterameter/version.rb +1 -1
- metadata +15 -3
- data/lib/filterameter/filter_registry.rb +0 -67
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: abb0f5a22c735d69e3e44625f0fd4a0cd451f35499677f4f0d51d5a7aff8ea3a
|
4
|
+
data.tar.gz: 359eefe9c128bd46651e475e6fc1620d145c68478429e8d49ab3d601fa95f2bb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3b11d1b2281e340ecc32ea75e37c857f888cce526fda98b4c2d96a5bbfd8a7ba0f2cbd901480c10eb8b6fc4ca4d48080e43f79f66e7bdb879819d407c896b237
|
7
|
+
data.tar.gz: 285384ed0b9506937406aa0d83744b99239666a4d84b7b0d6893bc1351ac26e227eb37cd1d9baa2c3e7be720fea36a9acc1a7b7d3979006fd8be0eaaa72dd21b
|
data/README.md
CHANGED
@@ -108,6 +108,20 @@ filter :sale_price, range: :max_only
|
|
108
108
|
|
109
109
|
In the first example, query parameters could include <tt>price</tt>, <tt>price_min</tt>, and <tt>price_max</tt>.
|
110
110
|
|
111
|
+
#### sortable
|
112
|
+
|
113
|
+
By default most filters are sortable. To prevent an attribute filter from being sortable, set the option to false.
|
114
|
+
|
115
|
+
```ruby
|
116
|
+
filter :price, sortable: false
|
117
|
+
```
|
118
|
+
|
119
|
+
The following filters are not sortable:
|
120
|
+
|
121
|
+
- scope filters (see [_Sorting with a Scope_](#sorting-with-a-scope))
|
122
|
+
- filters with collection associations
|
123
|
+
|
124
|
+
|
111
125
|
### Scope Filters
|
112
126
|
|
113
127
|
For scopes that do not take arguments, the filter should provide a boolean that indicates whether or not the scope should be invoked. For example, imagine a scope called `high_priority` with criteria that identifies high priority records. The scope would be invoked by the query parameters `high_priority=true`.
|
@@ -122,6 +136,60 @@ def self.recent(as_of_date)
|
|
122
136
|
end
|
123
137
|
```
|
124
138
|
|
139
|
+
### Sorting
|
140
|
+
|
141
|
+
As noted above, most attribute filters are sortable by default. If no filter has been declared for an attribute, the `sort` declaration can be used. Use the same `name` and `association` options as needed.
|
142
|
+
|
143
|
+
For example, the following declaration could be used on an activity controller to allow activities to be sorted by project created at.
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
sort :project_created_at, name: :created_at, association: :project
|
147
|
+
```
|
148
|
+
|
149
|
+
Sorts without options can be declared all at once with `sorts`:
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
sorts :created_at,
|
153
|
+
:updated_at,
|
154
|
+
:description
|
155
|
+
```
|
156
|
+
|
157
|
+
#### Sorting with a Scope
|
158
|
+
|
159
|
+
Scopes can be used for sorting, but must be declared with `sort` (or `sorts`). For example, if a model included a scope called `by_created_at` you could add the following to the controller to expose it.
|
160
|
+
|
161
|
+
```ruby
|
162
|
+
sort :by_created_at
|
163
|
+
```
|
164
|
+
|
165
|
+
The `name` and `association` options can also be used. For example, if the scope was on the Project model it could also be used on a child Activity controller using the `association` option:
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
sort :by_created_at, association: :project
|
169
|
+
```
|
170
|
+
|
171
|
+
Only singular associations are valid for sorting. A collection association could return multiple values, making the sort indeterminate.
|
172
|
+
|
173
|
+
A scope that is used for sorting must accept a single argument. It will be passed either `:asc` or `:desc` depending on the parameter.
|
174
|
+
|
175
|
+
The example scope above might be defined as follows:
|
176
|
+
|
177
|
+
```ruby
|
178
|
+
def self.by_created_at(dir)
|
179
|
+
order(created_at: dir)
|
180
|
+
end
|
181
|
+
```
|
182
|
+
|
183
|
+
#### Default Sort
|
184
|
+
|
185
|
+
A default sort can be declared using `default_sort`. The argument(s) should specify one or more of the declared sorts or sortable filters by name. By default, the order is ascending. If you want descending order, you can map the column name symbol to :desc.
|
186
|
+
|
187
|
+
```ruby
|
188
|
+
default_sort updated_at: :desc, :description
|
189
|
+
```
|
190
|
+
|
191
|
+
In order to provide consistent results, a sort is always applied. If no default is specified, it will use primary key descending.
|
192
|
+
|
125
193
|
### Specifying the Model
|
126
194
|
|
127
195
|
Rails conventions are used to determine the controller's model. For example, the PhotosController builds a query against the Photo model. If a controller is namespaced, the model will first be looked up without the namespace, then with the namespace.
|
@@ -192,7 +260,6 @@ class WidgetsController < ApplicationController
|
|
192
260
|
|
193
261
|
def index
|
194
262
|
@widgets = build_query_from_filters
|
195
|
-
render json: @widgets
|
196
263
|
end
|
197
264
|
end
|
198
265
|
```
|
@@ -213,6 +280,28 @@ The query parameters are pulled from the controller parameters, nested under the
|
|
213
280
|
|
214
281
|
`/widgets?filter[size]=large&filter[color]=blue`
|
215
282
|
|
283
|
+
#### Sort Parameters
|
284
|
+
|
285
|
+
The sort is also nested underneath the key `filter`.
|
286
|
+
|
287
|
+
`/widgets?filter[sort]=size`
|
288
|
+
|
289
|
+
Use an array to pass multiple sorts. The order of the parameters is the order the sorts will be applied. For example, the following sorts first by size then by color:
|
290
|
+
|
291
|
+
`/widgets?filter[sort]=size&filter[sort]=color`
|
292
|
+
|
293
|
+
Sorts are ascending by default, but can use a prefix can be added to control the sort:
|
294
|
+
|
295
|
+
- `+` ascending (the default)
|
296
|
+
- `-` descending
|
297
|
+
|
298
|
+
For example, the following sorts by size descending:
|
299
|
+
|
300
|
+
`/widgets?filter[sort]=-size`
|
301
|
+
|
302
|
+
|
303
|
+
#### Override the Filter Key
|
304
|
+
|
216
305
|
To change the source of the query parameters, override the `filter_parameters` method. Here is another way to provide a default filter:
|
217
306
|
|
218
307
|
```ruby
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Exceptions
|
5
|
+
# = Collection Association Sort Error
|
6
|
+
#
|
7
|
+
# Class CollectionAssociationSortError is raised when a sort is attempted on a collection association. (Sorting is
|
8
|
+
# only valid on _singular_ associations.)
|
9
|
+
class CollectionAssociationSortError < FilterameterError
|
10
|
+
def initialize(declaration)
|
11
|
+
super("Sorting is not allowed on collection associations: \n\t\t#{declaration}")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Exceptions
|
5
|
+
# = Invalid Association Declaration Error
|
6
|
+
#
|
7
|
+
# Class InvalidAssociationDeclarationError is raised when the declared association(s) are not valid.
|
8
|
+
class InvalidAssociationDeclarationError < FilterameterError
|
9
|
+
def initialize(name, model_name, associations)
|
10
|
+
super("The association(s) declared on filter #{name} are not valid for model #{model_name}: #{associations}")
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -19,7 +19,7 @@ module Filterameter
|
|
19
19
|
class FilterCoordinator
|
20
20
|
attr_writer :query_variable_name
|
21
21
|
|
22
|
-
delegate :add_filter, to: :registry
|
22
|
+
delegate :add_filter, :add_sort, to: :registry
|
23
23
|
delegate :build_query, to: :query_builder
|
24
24
|
|
25
25
|
def initialize(controller_name, controller_path)
|
@@ -32,13 +32,19 @@ module Filterameter
|
|
32
32
|
end
|
33
33
|
|
34
34
|
def query_builder
|
35
|
-
@query_builder ||= Filterameter::QueryBuilder.new(default_query, registry)
|
35
|
+
@query_builder ||= Filterameter::QueryBuilder.new(default_query, @default_sort, registry)
|
36
36
|
end
|
37
37
|
|
38
38
|
def query_variable_name
|
39
39
|
@query_variable_name ||= model_class.model_name.plural
|
40
40
|
end
|
41
41
|
|
42
|
+
def default_sort=(sort_and_direction_pairs)
|
43
|
+
@default_sort = sort_and_direction_pairs.map do |name, direction|
|
44
|
+
Filterameter::Helpers::RequestedSort.new(name, direction)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
42
48
|
private
|
43
49
|
|
44
50
|
def model_class
|
@@ -54,7 +60,7 @@ module Filterameter
|
|
54
60
|
|
55
61
|
# lazy so that model_class can be optionally set
|
56
62
|
def registry
|
57
|
-
@registry ||= Filterameter::
|
63
|
+
@registry ||= Filterameter::Registries::Registry.new(model_class)
|
58
64
|
end
|
59
65
|
end
|
60
66
|
end
|
@@ -24,11 +24,11 @@ module Filterameter
|
|
24
24
|
validate_options(options)
|
25
25
|
@name = options.fetch(:name, parameter_name).to_s
|
26
26
|
@association = Array.wrap(options[:association]).presence
|
27
|
-
@filter_on_empty = options.fetch(:filter_on_empty, false)
|
28
27
|
@validations = Array.wrap(options[:validates])
|
29
28
|
@raw_partial_options = options.fetch(:partial, false)
|
30
29
|
@raw_range = options[:range]
|
31
30
|
@range_type = range_type
|
31
|
+
@sortable = options.fetch(:sortable, true)
|
32
32
|
end
|
33
33
|
|
34
34
|
def nested?
|
@@ -39,10 +39,6 @@ module Filterameter
|
|
39
39
|
!@validations.empty?
|
40
40
|
end
|
41
41
|
|
42
|
-
def filter_on_empty?
|
43
|
-
@filter_on_empty
|
44
|
-
end
|
45
|
-
|
46
42
|
def partial_search?
|
47
43
|
partial_options.present?
|
48
44
|
end
|
@@ -75,10 +71,24 @@ module Filterameter
|
|
75
71
|
@range_type == :maximum
|
76
72
|
end
|
77
73
|
|
74
|
+
def sortable?
|
75
|
+
@sortable
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_s
|
79
|
+
options = {}
|
80
|
+
options[:name] = ":#{@name}" if @parameter_name != @name
|
81
|
+
options[:association] = @association if nested?
|
82
|
+
options[:partial] = partial_options if partial_options
|
83
|
+
|
84
|
+
(["filter :#{@parameter_name}"] + options.map { |k, v| "#{k}: #{v}" })
|
85
|
+
.join(', ')
|
86
|
+
end
|
87
|
+
|
78
88
|
private
|
79
89
|
|
80
90
|
def validate_options(options)
|
81
|
-
options.assert_valid_keys(:name, :association, :
|
91
|
+
options.assert_valid_keys(:name, :association, :validates, :partial, :range, :sortable)
|
82
92
|
validate_range(options[:range]) if options.key?(:range)
|
83
93
|
end
|
84
94
|
|
@@ -10,26 +10,27 @@ module Filterameter
|
|
10
10
|
end
|
11
11
|
|
12
12
|
def build(declaration)
|
13
|
+
context = Helpers::DeclarationWithModel.new(@model_class, declaration)
|
14
|
+
|
13
15
|
if declaration.nested?
|
14
|
-
build_nested_filter(declaration)
|
16
|
+
build_nested_filter(declaration, context)
|
15
17
|
else
|
16
|
-
build_filter(@model_class, declaration)
|
18
|
+
build_filter(@model_class, declaration, context.scope?)
|
17
19
|
end
|
18
20
|
end
|
19
21
|
|
20
22
|
private
|
21
23
|
|
22
|
-
def build_nested_filter(declaration)
|
23
|
-
model = model_from_association
|
24
|
-
filter = build_filter(model, declaration)
|
25
|
-
|
24
|
+
def build_nested_filter(declaration, context)
|
25
|
+
model = context.model_from_association
|
26
|
+
filter = build_filter(model, declaration, context.scope?)
|
27
|
+
nested_filter_class = context.any_collections? ? Filters::NestedCollectionFilter : Filters::NestedFilter
|
26
28
|
|
27
|
-
|
29
|
+
nested_filter_class.new(declaration.association, model, filter)
|
28
30
|
end
|
29
31
|
|
30
|
-
def build_filter(model, declaration) # rubocop:disable Metrics/MethodLength
|
31
|
-
|
32
|
-
if model.respond_to?(declaration.name) && !model.dangerous_class_method?(declaration.name)
|
32
|
+
def build_filter(model, declaration, declaration_is_a_scope) # rubocop:disable Metrics/MethodLength
|
33
|
+
if declaration_is_a_scope
|
33
34
|
build_scope_filter(model, declaration)
|
34
35
|
elsif declaration.partial_search?
|
35
36
|
Filterameter::Filters::MatchesFilter.new(declaration.name, declaration.partial_options)
|
@@ -51,30 +52,5 @@ module Filterameter
|
|
51
52
|
Filterameter::Filters::ConditionalScopeFilter.new(declaration.name)
|
52
53
|
end
|
53
54
|
end
|
54
|
-
|
55
|
-
def filter_class(association_names)
|
56
|
-
if any_collections?(association_names)
|
57
|
-
Filters::NestedCollectionFilter
|
58
|
-
else
|
59
|
-
Filters::NestedFilter
|
60
|
-
end
|
61
|
-
end
|
62
|
-
|
63
|
-
def any_collections?(association_names)
|
64
|
-
association_names.reduce(@model_class) do |model, name|
|
65
|
-
association = model.reflect_on_association(name)
|
66
|
-
return true if association.collection?
|
67
|
-
|
68
|
-
association.klass
|
69
|
-
end
|
70
|
-
|
71
|
-
false
|
72
|
-
end
|
73
|
-
|
74
|
-
# TODO: rescue then raise custom error with cause
|
75
|
-
def model_from_association(association)
|
76
|
-
association.flatten.reduce(@model_class) { |memo, name| memo.reflect_on_association(name).klass }
|
77
|
-
# rescue StandardError => e
|
78
|
-
end
|
79
55
|
end
|
80
56
|
end
|
@@ -6,31 +6,8 @@ module Filterameter
|
|
6
6
|
#
|
7
7
|
# Class NestedFilter joins the nested table(s) then merges the filter to the association's model.
|
8
8
|
class NestedFilter
|
9
|
-
# = Joins Values Builder
|
10
|
-
#
|
11
|
-
# Class JoinsValuesBuilder evaluates an array of names to return either the single entry when there is only
|
12
|
-
# one element in the array or a nested hash when there is more than one element. This is the argument that is
|
13
|
-
# passed into the ActiveRecord query method `joins`.
|
14
|
-
class JoinsValuesBuilder
|
15
|
-
def self.build(association_names)
|
16
|
-
return association_names.first if association_names.size == 1
|
17
|
-
|
18
|
-
new(association_names).to_h
|
19
|
-
end
|
20
|
-
|
21
|
-
def initialize(association_names)
|
22
|
-
@association_names = association_names
|
23
|
-
end
|
24
|
-
|
25
|
-
def to_h
|
26
|
-
{}.tap do |nested_hash|
|
27
|
-
@association_names.reduce(nested_hash) { |memo, name| memo.store(name, {}) }
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
9
|
def initialize(association_names, association_model, attribute_filter)
|
33
|
-
@joins_values = JoinsValuesBuilder.build(association_names)
|
10
|
+
@joins_values = Filterameter::Helpers::JoinsValuesBuilder.build(association_names)
|
34
11
|
@association_model = association_model
|
35
12
|
@attribute_filter = attribute_filter
|
36
13
|
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Helpers
|
5
|
+
# = Declaration with Model
|
6
|
+
#
|
7
|
+
# Class DeclarationWithModel inspects the declaration under the context of the model. This enables
|
8
|
+
# predicate methods as well as drilling through associations.
|
9
|
+
class DeclarationWithModel
|
10
|
+
def initialize(model, declaration)
|
11
|
+
@model = model
|
12
|
+
@declaration = declaration
|
13
|
+
end
|
14
|
+
|
15
|
+
def scope?
|
16
|
+
model = @declaration.nested? ? model_from_association : @model
|
17
|
+
|
18
|
+
model.respond_to?(@declaration.name) &&
|
19
|
+
# checking dangerous_class_method? excludes any names that cannot be scope names, such as "name"
|
20
|
+
!model.dangerous_class_method?(@declaration.name)
|
21
|
+
end
|
22
|
+
|
23
|
+
def any_collections?
|
24
|
+
@declaration.association.reduce(@model) do |related_model, name|
|
25
|
+
association = related_model.reflect_on_association(name)
|
26
|
+
return true if association.collection?
|
27
|
+
|
28
|
+
association.klass
|
29
|
+
end
|
30
|
+
|
31
|
+
false
|
32
|
+
end
|
33
|
+
|
34
|
+
def model_from_association
|
35
|
+
@declaration.association.flatten.reduce(@model) do |memo, name|
|
36
|
+
association = memo.reflect_on_association(name)
|
37
|
+
raise_invalid_association if association.nil?
|
38
|
+
|
39
|
+
association.klass
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def raise_invalid_association
|
46
|
+
raise Filterameter::Exceptions::InvalidAssociationDeclarationError.new(@declaration.name,
|
47
|
+
@model.name,
|
48
|
+
@declaration.association)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Helpers
|
5
|
+
# = Joins Values Builder
|
6
|
+
#
|
7
|
+
# Class JoinsValuesBuilder evaluates an array of names to return either the single entry when there is only
|
8
|
+
# one element in the array or a nested hash when there is more than one element. This is the argument that is
|
9
|
+
# passed into the ActiveRecord query method `joins`.
|
10
|
+
class JoinsValuesBuilder
|
11
|
+
def self.build(association_names)
|
12
|
+
return association_names.first if association_names.size == 1
|
13
|
+
|
14
|
+
new(association_names).to_h
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(association_names)
|
18
|
+
@association_names = association_names
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_h
|
22
|
+
{}.tap do |nested_hash|
|
23
|
+
@association_names.reduce(nested_hash) { |memo, name| memo.store(name, {}) }
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Helpers
|
5
|
+
# = Reqested Sort
|
6
|
+
#
|
7
|
+
# Class RequestedSort parses the name and direction from a sort segment.
|
8
|
+
class RequestedSort
|
9
|
+
SIGN_AND_NAME = /(?<sign>[+|-]?)(?<name>\w+)/
|
10
|
+
attr_reader :name, :direction
|
11
|
+
|
12
|
+
def self.parse(sort)
|
13
|
+
parsed = sort.match SIGN_AND_NAME
|
14
|
+
|
15
|
+
new(parsed['name'],
|
16
|
+
parsed['sign'] == '-' ? :desc : :asc)
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(name, direction)
|
20
|
+
@name = name
|
21
|
+
@direction = direction
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -49,6 +49,16 @@ module Filterameter
|
|
49
49
|
@match == 'dynamic'
|
50
50
|
end
|
51
51
|
|
52
|
+
def to_s
|
53
|
+
if case_sensitive?
|
54
|
+
case_sensitive_to_s
|
55
|
+
elsif match_anywhere?
|
56
|
+
'true'
|
57
|
+
else
|
58
|
+
":#{@match}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
52
62
|
private
|
53
63
|
|
54
64
|
def evaluate_hash(options)
|
@@ -79,6 +89,10 @@ module Filterameter
|
|
79
89
|
|
80
90
|
raise ArgumentError, "Invalid case_sensitive option for partial: #{value}. Valid options are true and false."
|
81
91
|
end
|
92
|
+
|
93
|
+
def case_sensitive_to_s
|
94
|
+
match_anywhere? ? '{ case_sensitive: true }' : "{ match: :#{@match}, case_sensitive: true }"
|
95
|
+
end
|
82
96
|
end
|
83
97
|
end
|
84
98
|
end
|
@@ -4,31 +4,77 @@ module Filterameter
|
|
4
4
|
# = Query Builder
|
5
5
|
#
|
6
6
|
# Class Query Builder turns filter parameters into a query.
|
7
|
+
#
|
8
|
+
# The query builder is instantiated by the filter coordinator. The default query currently is simple `all`. The
|
9
|
+
# default sort comes for the controller declaration of the same name; it is optional and the value may be nil.
|
10
|
+
#
|
11
|
+
# If the request includes a sort, it is always applied. If not, the following logic kicks in to provide a sort:
|
12
|
+
# - if the starting query includes a sort, no additional sort is applied
|
13
|
+
# - if a default sort has been declared, it is applied
|
14
|
+
# - if neither of those provides a sort, then the fallback is primary key desc
|
7
15
|
class QueryBuilder
|
8
|
-
def initialize(default_query, filter_registry)
|
16
|
+
def initialize(default_query, default_sort, filter_registry)
|
9
17
|
@default_query = default_query
|
18
|
+
@default_sort = default_sort
|
10
19
|
@registry = filter_registry
|
11
20
|
end
|
12
21
|
|
13
22
|
def build_query(filter_params, starting_query = nil)
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
add_filter_parameter_to_query(query, name, value)
|
18
|
-
end
|
23
|
+
sorts, filters = parse_filter_params(filter_params.stringify_keys)
|
24
|
+
query = apply_filters(starting_query || @default_query, filters)
|
25
|
+
apply_sorts(query, sorts)
|
19
26
|
end
|
20
27
|
|
21
28
|
private
|
22
29
|
|
30
|
+
def parse_filter_params(filter_params)
|
31
|
+
sort = parse_sorts(filter_params.delete('sort'))
|
32
|
+
[sort, remove_invalid_values(filter_params)]
|
33
|
+
end
|
34
|
+
|
35
|
+
def parse_sorts(sorts)
|
36
|
+
Array.wrap(sorts).map { |sort| Helpers::RequestedSort.parse(sort) }
|
37
|
+
end
|
38
|
+
|
39
|
+
def apply_filters(query, filters)
|
40
|
+
filters.tap { |parameters| convert_min_and_max_to_range(parameters) }
|
41
|
+
.reduce(query) do |memo, (name, value)|
|
42
|
+
add_filter_parameter_to_query(memo, name, value)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
23
46
|
def add_filter_parameter_to_query(query, filter_name, parameter_value)
|
24
|
-
@registry.
|
25
|
-
rescue Filterameter::Exceptions::
|
47
|
+
@registry.fetch_filter(filter_name).apply(query, parameter_value)
|
48
|
+
rescue Filterameter::Exceptions::FilterameterError => e
|
49
|
+
handle_undeclared_parameter(e)
|
50
|
+
query
|
51
|
+
end
|
52
|
+
|
53
|
+
def apply_sorts(query, requested_sorts)
|
54
|
+
return query if no_sort_requested_but_starting_query_includes_sort?(query, requested_sorts)
|
55
|
+
|
56
|
+
sorts = requested_sorts.presence || @default_sort
|
57
|
+
if sorts.present?
|
58
|
+
sorts.reduce(query) { |memo, sort| add_sort_to_query(memo, sort.name, sort.direction) }
|
59
|
+
else
|
60
|
+
sort_by_primary_key_desc(query)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def no_sort_requested_but_starting_query_includes_sort?(query, requested_sorts)
|
65
|
+
requested_sorts.empty? && query.order_values.present?
|
66
|
+
end
|
67
|
+
|
68
|
+
def add_sort_to_query(query, name, direction)
|
69
|
+
@registry.fetch_sort(name).apply(query, direction)
|
70
|
+
rescue Filterameter::Exceptions::FilterameterError => e
|
26
71
|
handle_undeclared_parameter(e)
|
27
72
|
query
|
28
73
|
end
|
29
74
|
|
30
|
-
def
|
31
|
-
|
75
|
+
def sort_by_primary_key_desc(query)
|
76
|
+
primary_key = query.model.primary_key
|
77
|
+
query.order(primary_key => :desc)
|
32
78
|
end
|
33
79
|
|
34
80
|
# if both min and max are present in the query parameters, replace with range
|
@@ -41,6 +87,9 @@ module Filterameter
|
|
41
87
|
end
|
42
88
|
end
|
43
89
|
|
90
|
+
# TODO: this handles any runtime exceptions, not just undeclared parameter errors:
|
91
|
+
# - should the config option be more generalized?
|
92
|
+
# - or should there be a config option for each type of error?
|
44
93
|
def handle_undeclared_parameter(exception)
|
45
94
|
action = Filterameter.configuration.action_on_undeclared_parameters
|
46
95
|
return unless action
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Registries
|
5
|
+
# Filters
|
6
|
+
#
|
7
|
+
# Class FilterRegistry is a collection of the filters. It captures the filter declarations when classes are loaded,
|
8
|
+
# then uses the injected FilterFactory to build the filters on demand as they are needed.
|
9
|
+
class FilterRegistry < SubRegistry
|
10
|
+
attr_reader :ranges
|
11
|
+
|
12
|
+
def initialize(factory)
|
13
|
+
super
|
14
|
+
@ranges = {}
|
15
|
+
end
|
16
|
+
|
17
|
+
def build_declaration(name, options)
|
18
|
+
Filterameter::FilterDeclaration.new(name, options).tap do |fd|
|
19
|
+
add_declarations_for_range(fd, options, name) if fd.range_enabled?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def filter_declarations
|
24
|
+
@declarations.values
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# if range is enabled, then in addition to the attribute filter this also adds min and/or max filters
|
30
|
+
def add_declarations_for_range(attribute_declaration, options, parameter_name)
|
31
|
+
add_range_minimum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.min_only?
|
32
|
+
add_range_maximum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.max_only?
|
33
|
+
capture_range_declaration(parameter_name) if attribute_declaration.range?
|
34
|
+
end
|
35
|
+
|
36
|
+
def add_range_minimum(parameter_name, options)
|
37
|
+
parameter_name_min = "#{parameter_name}_min"
|
38
|
+
options_with_name = options.with_defaults(name: parameter_name)
|
39
|
+
@declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min,
|
40
|
+
options_with_name,
|
41
|
+
range_type: :minimum)
|
42
|
+
end
|
43
|
+
|
44
|
+
def add_range_maximum(parameter_name, options)
|
45
|
+
parameter_name_max = "#{parameter_name}_max"
|
46
|
+
options_with_name = options.with_defaults(name: parameter_name)
|
47
|
+
@declarations[parameter_name_max] = Filterameter::FilterDeclaration.new(parameter_name_max,
|
48
|
+
options_with_name,
|
49
|
+
range_type: :maximum)
|
50
|
+
end
|
51
|
+
|
52
|
+
def capture_range_declaration(name)
|
53
|
+
@ranges[name] = { min: "#{name}_min", max: "#{name}_max" }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Registries
|
5
|
+
# = Registry
|
6
|
+
#
|
7
|
+
# Class Registry records declarations and allows resulting filters and sorts to be fetched from sub-registries.
|
8
|
+
class Registry
|
9
|
+
delegate :filter_declarations, :ranges, to: :@filter_registry
|
10
|
+
|
11
|
+
def initialize(model_class)
|
12
|
+
@filter_registry = Filterameter::Registries::FilterRegistry.new(Filterameter::FilterFactory.new(model_class))
|
13
|
+
@sort_registry = Filterameter::Registries::SortRegistry.new(Filterameter::SortFactory.new(model_class))
|
14
|
+
end
|
15
|
+
|
16
|
+
def add_filter(parameter_name, options)
|
17
|
+
declaration = @filter_registry.add(parameter_name, options)
|
18
|
+
add_sort(parameter_name, options.slice(:name, :association)) if declaration.sortable?
|
19
|
+
end
|
20
|
+
|
21
|
+
def fetch_filter(parameter_name)
|
22
|
+
@filter_registry.fetch(parameter_name)
|
23
|
+
end
|
24
|
+
|
25
|
+
def add_sort(parameter_name, options)
|
26
|
+
@sort_registry.add(parameter_name, options)
|
27
|
+
end
|
28
|
+
|
29
|
+
def fetch_sort(parameter_name)
|
30
|
+
@sort_registry.fetch(parameter_name)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Registries
|
5
|
+
# Sort Registry
|
6
|
+
#
|
7
|
+
# Class SortRegistry is a collection of the sorts. It captures the declarations when classes are loaded,
|
8
|
+
# then uses the injected SortFactory to build the sorts on demand as they are needed.
|
9
|
+
class SortRegistry < SubRegistry
|
10
|
+
private
|
11
|
+
|
12
|
+
def build_declaration(name, options)
|
13
|
+
Filterameter::SortDeclaration.new(name, options)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Registries
|
5
|
+
# SubRegistry
|
6
|
+
#
|
7
|
+
# Class SubRegistry provides add and fetch methods as well as the initialization for sub-registries.
|
8
|
+
#
|
9
|
+
# Subclasses must implement build_declaration.
|
10
|
+
#
|
11
|
+
class SubRegistry
|
12
|
+
def initialize(factory)
|
13
|
+
@factory = factory
|
14
|
+
@declarations = {}
|
15
|
+
@registry = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
def add(parameter_name, options)
|
19
|
+
name = parameter_name.to_s
|
20
|
+
@declarations[name] = build_declaration(name, options)
|
21
|
+
end
|
22
|
+
|
23
|
+
def fetch(parameter_name)
|
24
|
+
name = parameter_name.to_s
|
25
|
+
@registry.fetch(name) do
|
26
|
+
raise Filterameter::Exceptions::UndeclaredParameterError, name unless @declarations.keys.include?(name)
|
27
|
+
|
28
|
+
@registry[name] = @factory.build(@declarations[name])
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
# = Sort Declaration
|
5
|
+
#
|
6
|
+
# Class SortDeclaration captures the sort declaration within the controller. A sort declaration is also generated
|
7
|
+
# from a FilterDeclaration when it is `sortable?`.
|
8
|
+
class SortDeclaration
|
9
|
+
attr_reader :name, :parameter_name, :association
|
10
|
+
|
11
|
+
def initialize(parameter_name, options)
|
12
|
+
@parameter_name = parameter_name.to_s
|
13
|
+
|
14
|
+
validate_options(options)
|
15
|
+
@name = options.fetch(:name, parameter_name).to_s
|
16
|
+
@association = Array.wrap(options[:association]).presence
|
17
|
+
end
|
18
|
+
|
19
|
+
def nested?
|
20
|
+
!@association.nil?
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
options = {}
|
25
|
+
options[:name] = ":#{@name}" if @parameter_name != @name
|
26
|
+
options[:association] = @association if nested?
|
27
|
+
|
28
|
+
(["sort :#{@parameter_name}"] + options.map { |k, v| "#{k}: #{v}" })
|
29
|
+
.join(', ')
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def validate_options(options)
|
35
|
+
options.assert_valid_keys(:name, :association)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
# = Sort Factory
|
5
|
+
#
|
6
|
+
# Class SortFactory builds a sort from a model and a declaration.
|
7
|
+
class SortFactory
|
8
|
+
def initialize(model_class)
|
9
|
+
@model_class = model_class
|
10
|
+
end
|
11
|
+
|
12
|
+
def build(declaration)
|
13
|
+
context = Helpers::DeclarationWithModel.new(@model_class, declaration)
|
14
|
+
|
15
|
+
if declaration.nested?
|
16
|
+
build_nested_sort(declaration, context)
|
17
|
+
else
|
18
|
+
build_sort(declaration.name, context.scope?)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def build_nested_sort(declaration, context)
|
25
|
+
validate!(declaration, context)
|
26
|
+
|
27
|
+
model = context.model_from_association
|
28
|
+
sort = build_sort(declaration.name, context.scope?)
|
29
|
+
nested_sort_class = context.any_collections? ? Filters::NestedCollectionFilter : Filters::NestedFilter
|
30
|
+
|
31
|
+
nested_sort_class.new(declaration.association, model, sort)
|
32
|
+
end
|
33
|
+
|
34
|
+
def build_sort(name, declaration_is_a_scope)
|
35
|
+
if declaration_is_a_scope
|
36
|
+
Filterameter::Filters::ScopeFilter.new(name)
|
37
|
+
else
|
38
|
+
Filterameter::Sorts::AttributeSort.new(name)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def validate!(declaration, context)
|
43
|
+
return unless context.any_collections?
|
44
|
+
|
45
|
+
raise Exceptions::CollectionAssociationSortError, declaration
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
# = Sortable
|
5
|
+
#
|
6
|
+
# Mixin Sortable provides class methods <tt>sort</tt> and <tt>sorts</tt>.
|
7
|
+
module Sortable
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
class_methods do
|
11
|
+
# Declares a sort that can be read from the parameters and applied to the ActiveRecord query. The
|
12
|
+
# <tt>parameter_name</tt> identifies the name of the parameter and is the default value for the attribute
|
13
|
+
# name when none is specified in the options.
|
14
|
+
#
|
15
|
+
# The including class must implement `filter_coordinator`
|
16
|
+
#
|
17
|
+
# === Options
|
18
|
+
#
|
19
|
+
# [:name]
|
20
|
+
# Specify the attribute or scope name if the parameter name is not the same. The default value
|
21
|
+
# is the parameter name, so if the two match this can be left out.
|
22
|
+
#
|
23
|
+
# [:association]
|
24
|
+
# Specify the name of the association if the attribute or scope is nested.
|
25
|
+
def sort(parameter_name, options = {})
|
26
|
+
filter_coordinator.add_sort(parameter_name, options)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Declares a list of filters that can be read from the parameters and applied to the query. The name can be either
|
30
|
+
# an attribute or a scope. Declare filters individually with <tt>filter</tt> if more options are required.
|
31
|
+
def sorts(*parameter_names)
|
32
|
+
parameter_names.each { |parameter_name| filter(parameter_name) }
|
33
|
+
end
|
34
|
+
|
35
|
+
def default_sort(sort_and_direction_pairs)
|
36
|
+
filter_coordinator.default_sort = sort_and_direction_pairs
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Filterameter
|
4
|
+
module Sorts
|
5
|
+
# = Attribute Sort
|
6
|
+
#
|
7
|
+
# Class AttributeSort leverages ActiveRecord's `order` query method to add sorting for an attribute.
|
8
|
+
class AttributeSort
|
9
|
+
def initialize(attribute_name)
|
10
|
+
@attribute_name = attribute_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def apply(query, direction)
|
14
|
+
query.order(@attribute_name => direction)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/filterameter/version.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.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Todd Kummer
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-07-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -179,12 +179,13 @@ files:
|
|
179
179
|
- lib/filterameter/declarative_filters.rb
|
180
180
|
- lib/filterameter/exceptions.rb
|
181
181
|
- lib/filterameter/exceptions/cannot_determine_model_error.rb
|
182
|
+
- lib/filterameter/exceptions/collection_association_sort_error.rb
|
183
|
+
- lib/filterameter/exceptions/invalid_association_declaration_error.rb
|
182
184
|
- lib/filterameter/exceptions/undeclared_parameter_error.rb
|
183
185
|
- lib/filterameter/exceptions/validation_error.rb
|
184
186
|
- lib/filterameter/filter_coordinator.rb
|
185
187
|
- lib/filterameter/filter_declaration.rb
|
186
188
|
- lib/filterameter/filter_factory.rb
|
187
|
-
- lib/filterameter/filter_registry.rb
|
188
189
|
- lib/filterameter/filterable.rb
|
189
190
|
- lib/filterameter/filters/arel_filter.rb
|
190
191
|
- lib/filterameter/filters/attribute_filter.rb
|
@@ -195,10 +196,21 @@ files:
|
|
195
196
|
- lib/filterameter/filters/nested_collection_filter.rb
|
196
197
|
- lib/filterameter/filters/nested_filter.rb
|
197
198
|
- lib/filterameter/filters/scope_filter.rb
|
199
|
+
- lib/filterameter/helpers/declaration_with_model.rb
|
200
|
+
- lib/filterameter/helpers/joins_values_builder.rb
|
201
|
+
- lib/filterameter/helpers/requested_sort.rb
|
198
202
|
- lib/filterameter/log_subscriber.rb
|
199
203
|
- lib/filterameter/options/partial_options.rb
|
200
204
|
- lib/filterameter/parameters_base.rb
|
201
205
|
- lib/filterameter/query_builder.rb
|
206
|
+
- lib/filterameter/registries/filter_registry.rb
|
207
|
+
- lib/filterameter/registries/registry.rb
|
208
|
+
- lib/filterameter/registries/sort_registry.rb
|
209
|
+
- lib/filterameter/registries/sub_registry.rb
|
210
|
+
- lib/filterameter/sort_declaration.rb
|
211
|
+
- lib/filterameter/sort_factory.rb
|
212
|
+
- lib/filterameter/sortable.rb
|
213
|
+
- lib/filterameter/sorts/attribute_sort.rb
|
202
214
|
- lib/filterameter/validators/inclusion_validator.rb
|
203
215
|
- lib/filterameter/version.rb
|
204
216
|
homepage: https://github.com/RockSolt/filterameter
|
@@ -1,67 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Filterameter
|
4
|
-
# Filters
|
5
|
-
#
|
6
|
-
# Class FilterRegistry is a collection of the filters. It captures the filter declarations when classes are loaded,
|
7
|
-
# then uses the injected FilterFactory to build the filters on demand as they are needed.
|
8
|
-
class FilterRegistry
|
9
|
-
attr_reader :ranges
|
10
|
-
|
11
|
-
def initialize(filter_factory)
|
12
|
-
@filter_factory = filter_factory
|
13
|
-
@declarations = {}
|
14
|
-
@ranges = {}
|
15
|
-
@filters = {}
|
16
|
-
end
|
17
|
-
|
18
|
-
def add_filter(parameter_name, options)
|
19
|
-
name = parameter_name.to_s
|
20
|
-
@declarations[name] = Filterameter::FilterDeclaration.new(name, options).tap do |fd|
|
21
|
-
add_declarations_for_range(fd, options, name) if fd.range_enabled?
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
def fetch(parameter_name)
|
26
|
-
name = parameter_name.to_s
|
27
|
-
@filters.fetch(name) do
|
28
|
-
raise Filterameter::Exceptions::UndeclaredParameterError, name unless @declarations.keys.include?(name)
|
29
|
-
|
30
|
-
@filters[name] = @filter_factory.build(@declarations[name])
|
31
|
-
end
|
32
|
-
end
|
33
|
-
|
34
|
-
def filter_declarations
|
35
|
-
@declarations.values
|
36
|
-
end
|
37
|
-
|
38
|
-
private
|
39
|
-
|
40
|
-
# if range is enabled, then in addition to the attribute filter this also adds min and/or max filters
|
41
|
-
def add_declarations_for_range(attribute_declaration, options, parameter_name)
|
42
|
-
add_range_minimum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.min_only?
|
43
|
-
add_range_maximum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.max_only?
|
44
|
-
capture_range_declaration(parameter_name) if attribute_declaration.range?
|
45
|
-
end
|
46
|
-
|
47
|
-
def add_range_minimum(parameter_name, options)
|
48
|
-
parameter_name_min = "#{parameter_name}_min"
|
49
|
-
options_with_name = options.with_defaults(name: parameter_name)
|
50
|
-
@declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min,
|
51
|
-
options_with_name,
|
52
|
-
range_type: :minimum)
|
53
|
-
end
|
54
|
-
|
55
|
-
def add_range_maximum(parameter_name, options)
|
56
|
-
parameter_name_max = "#{parameter_name}_max"
|
57
|
-
options_with_name = options.with_defaults(name: parameter_name)
|
58
|
-
@declarations[parameter_name_max] = Filterameter::FilterDeclaration.new(parameter_name_max,
|
59
|
-
options_with_name,
|
60
|
-
range_type: :maximum)
|
61
|
-
end
|
62
|
-
|
63
|
-
def capture_range_declaration(name)
|
64
|
-
@ranges[name] = { min: "#{name}_min", max: "#{name}_max" }
|
65
|
-
end
|
66
|
-
end
|
67
|
-
end
|