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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c02c45f5366abfe01e6858a4d70d932e585b219a27680f799f5b53056202322f
4
- data.tar.gz: acff8ae1523f525d3f7d9415c32dd5d3fd9b707118498b38d78eff4ca05ccc97
3
+ metadata.gz: abb0f5a22c735d69e3e44625f0fd4a0cd451f35499677f4f0d51d5a7aff8ea3a
4
+ data.tar.gz: 359eefe9c128bd46651e475e6fc1620d145c68478429e8d49ab3d601fa95f2bb
5
5
  SHA512:
6
- metadata.gz: 2923df8284998b1129cb3b66bb7166cd08ef8708b21a8a6af666c393c219178f38111802294bc051434cd5a33cf6e6853f593abd6415fa031329bc09fbab8a92
7
- data.tar.gz: 7fc9483785d7226ce14d9d3eae2f04b6d99c9f3bbfecdcd886b10cb9302e2ad119958e8ee1092e566e64eacc7d218b0bf754a0eeabb8463525858dc9cfaf5ed2
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
@@ -7,6 +7,7 @@ module Filterameter
7
7
  module DeclarativeFilters
8
8
  extend ActiveSupport::Concern
9
9
  include Filterameter::Filterable
10
+ include Filterameter::Sortable
10
11
 
11
12
  class_methods do
12
13
  def filter_model(model_class, query_var_name = nil)
@@ -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::FilterRegistry.new(Filterameter::FilterFactory.new(model_class))
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, :filter_on_empty, :validates, :partial, :range)
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(declaration.association)
24
- filter = build_filter(model, declaration)
25
- clazz = filter_class(declaration.association)
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
- clazz.new(declaration.association, model, filter)
29
+ nested_filter_class.new(declaration.association, model, filter)
28
30
  end
29
31
 
30
- def build_filter(model, declaration) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
31
- # checking dangerous_class_method? excludes any names that cannot be scope names, such as "name"
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
- valid_filters(filter_params.stringify_keys)
15
- .tap { |parameters| convert_min_and_max_to_range(parameters) }
16
- .reduce(starting_query || @default_query) do |query, (name, value)|
17
- add_filter_parameter_to_query(query, name, value)
18
- end
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.fetch(filter_name).apply(query, parameter_value)
25
- rescue Filterameter::Exceptions::UndeclaredParameterError => e
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 valid_filters(filter_params)
31
- remove_invalid_values(filter_params)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Filterameter
4
- VERSION = '0.7.0'
4
+ VERSION = '0.8.0'
5
5
  end
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.7.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-06-14 00:00:00.000000000 Z
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