toller 1.1.0 → 1.2.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: bb4ebc1d6c6e8f4eaae0c68c740ae3b35693cd678c6e14e245295a40ad48f2d0
4
- data.tar.gz: f380d915cb7f85ed2f3cd6873db803c71d01b9a28e96b9ba8375bb241a02432f
3
+ metadata.gz: 14ead1892ebc47f8a2dc0ee254545de4a013e7140d738654b9ad42dfd5c05799
4
+ data.tar.gz: 39fa2fa6acd9fed9e002b78f020e3b95ed89a920384481b24271b1898f955057
5
5
  SHA512:
6
- metadata.gz: b84c1d90d5d1ebddb6ac81713874ce935d141664a3cb9b4601e08bd4394e844e26aa495d77d88b6381f39587093b3794411cefcc1fe082951239033ba2da070d
7
- data.tar.gz: 2a1f0894da895687f8b8699073bd84932c41b1a1af591c95269f6e596e8555111af92dea2b065b598a1e3e18ee3f0869033385c46a44c04696d2fd1b74f37940
6
+ metadata.gz: 71f8f591c8b0d40a0e67cacfb3e9953d4a47d9ad8a542f7327f05e67f6deec12c1a8580547a47acf5d9851c016cae0588e0f20d62c1ae93245d1c0ade14b8807
7
+ data.tar.gz: 128e6e2f34d0bf6381b57987d73d485eeeb70628c96aba9ce557ba251395e9cfc96c5b12bb6b23f1546c822642af46ca8260a6ea5b46390f2840a9090c970697
data/README.md CHANGED
@@ -16,7 +16,7 @@ See the [wiki](https://github.com/dfreerksen/toller/wiki) for usage information.
16
16
  Add this line to your application's Gemfile:
17
17
 
18
18
  ```ruby
19
- gem 'toller', '~> 1.1'
19
+ gem 'toller', '~> 1.2'
20
20
  ```
21
21
 
22
22
  And then execute:
@@ -64,7 +64,7 @@ To run tests with Appraisal, run
64
64
  $ bundle exec appraisal rspec
65
65
  ```
66
66
 
67
- `-n 1` forces Appraisal2 to run one Rails version at a time. Without it, Appraisal2 defaults to running 2 appraisals in parallel, and since every appraisal shares the same `test/dummy/db/test.sqlite3` file, concurrent runs can intermittently fail with `SQLite3::BusyException: database is locked`.
67
+ Without any flags, Appraisal2 defaults to running 2 appraisals in parallel. The test database is in-memory (`database: ":memory:"`), so each appraisal subprocess gets its own isolated database and parallel runs don't conflict. Pass `-n 1` to force one Rails version at a time instead.
68
68
 
69
69
  ```bash
70
70
  $ bundle exec appraisal rails-6-0 rspec
data/lib/toller/filter.rb CHANGED
@@ -21,7 +21,10 @@ module Toller
21
21
  # @option options [Boolean] :default whether this filter applies automatically when no filter params were sent
22
22
  # @option options [Symbol] :scope_name for type: :scope, the model scope to call; defaults to +parameter+
23
23
  # @return [Toller::Filter] a new instance of Filter
24
+ # @raise [ArgumentError] if +type+ isn't one of {Toller::VALID_TYPES}
24
25
  def initialize(parameter, type, options)
26
+ validate_type!(parameter, type)
27
+
25
28
  @parameter = parameter
26
29
  @type = type
27
30
  @properties = options.reverse_merge(
@@ -51,5 +54,19 @@ module Toller
51
54
  def default
52
55
  properties[:default]
53
56
  end
57
+
58
+ private
59
+
60
+ ##
61
+ # @param parameter [Symbol] the public filter param name, used in the error message
62
+ # @param type [Symbol] the filter type to validate
63
+ # @return [nil]
64
+ # @raise [ArgumentError] if +type+ isn't one of {Toller::VALID_TYPES}
65
+ def validate_type!(parameter, type)
66
+ return if Toller::VALID_TYPES.include?(type)
67
+
68
+ raise ArgumentError, "Toller: unknown type `#{type.inspect}` for filter `#{parameter}` " \
69
+ "(expected one of #{Toller::VALID_TYPES.join(', ')})"
70
+ end
54
71
  end
55
72
  end
@@ -9,21 +9,20 @@ module Toller
9
9
  ##
10
10
  # Applies a plain `where` clause to +collection+ for a non-scope filter.
11
11
  #
12
- # If +field+ isn't a real column on the collection's model, the filter is
13
- # logged and skipped instead of raising once the relation is evaluated.
12
+ # If +field+ isn't a real column on the collection's model, or the column's actual type
13
+ # doesn't match the declared `type:`, the filter is logged and skipped instead of raising
14
+ # (or silently applying a mismatched mutator) once the relation is evaluated.
14
15
  #
15
16
  # @param collection [ActiveRecord::Relation] the collection to filter
16
17
  # @param type [Symbol] the filter type (e.g. :string, :integer, :boolean)
17
18
  # @param value [Object] the raw filter param value
18
19
  # @param properties [Hash] the filter's properties, used to resolve `:field`
19
- # @return [ActiveRecord::Relation] the filtered collection, or +collection+ unchanged if `:field` is unknown
20
+ # @return [ActiveRecord::Relation] the filtered collection, or +collection+ unchanged if `:field` is
21
+ # unknown or its actual column type doesn't match `type`
20
22
  def call(collection, type, value, properties)
21
23
  field_name = properties[:field]
22
24
 
23
- unless collection.klass.column_names.include?(field_name.to_s)
24
- Rails.logger.warn("[Toller] Skipping filter: #{collection.klass} has no column `#{field_name}`")
25
- return collection
26
- end
25
+ return collection unless resolvable_column?(collection, field_name, type)
27
26
 
28
27
  mutated_value = value_mutator(type, value)
29
28
 
@@ -32,6 +31,29 @@ module Toller
32
31
 
33
32
  private
34
33
 
34
+ ##
35
+ # Confirms +field_name+ is a real column on +collection+'s model whose actual type matches
36
+ # +type+, logging a warning and returning false otherwise.
37
+ #
38
+ # @param collection [ActiveRecord::Relation] the collection to filter
39
+ # @param field_name [Symbol] the column to look up
40
+ # @param type [Symbol] the filter's declared type
41
+ # @return [Boolean] whether +field_name+ exists and its actual column type matches +type+
42
+ def resolvable_column?(collection, field_name, type)
43
+ unless collection.klass.column_names.include?(field_name.to_s)
44
+ Rails.logger.warn("[Toller] Skipping filter: #{collection.klass} has no column `#{field_name}`")
45
+ return false
46
+ end
47
+
48
+ actual_type = collection.klass.columns_hash[field_name.to_s].type
49
+ return true if actual_type == type
50
+
51
+ Rails.logger.warn(
52
+ "[Toller] Skipping filter: #{collection.klass}##{field_name} is `#{actual_type}`, declared as `#{type}`"
53
+ )
54
+ false
55
+ end
56
+
35
57
  ##
36
58
  # Value mutator
37
59
  #
@@ -12,7 +12,7 @@ module Toller
12
12
  # `call(value)` that returns +value+ unchanged, or a Range when +value+ contains
13
13
  # Ruby range syntax (`..` for inclusive, `...` for exclusive).
14
14
  #
15
- # Note: methods here must refer to Ruby's core Range class as `::Range` a bare
15
+ # Methods here must refer to Ruby's core Range class as `::Range` - a bare
16
16
  # `Range` would resolve to this module (Mutators::Common::Range) instead, since
17
17
  # Ruby's constant lookup checks the lexical scope before the top level.
18
18
  module Range
@@ -19,7 +19,7 @@ module Toller
19
19
  def call(collection, value, properties)
20
20
  scoped_name = properties[:scope_name] || properties[:field]
21
21
 
22
- unless collection.klass.respond_to?(scoped_name)
22
+ unless ScopeResolver.own_class_method?(collection.klass, scoped_name)
23
23
  Rails.logger.warn("[Toller] Skipping filter: #{collection.klass} has no scope `#{scoped_name}`")
24
24
  return collection
25
25
  end
@@ -50,9 +50,9 @@ module Toller
50
50
  def filter
51
51
  active_retrievals.reduce(collection) do |items, retrieval|
52
52
  param_value = if retrieval.is_a?(Filter)
53
- filter_params.fetch(retrieval.parameter, nil)
53
+ filter_params.fetch(downcased_parameter(retrieval), nil)
54
54
  else
55
- sort_params.include?("-#{retrieval.parameter}") ? :desc : :asc
55
+ sort_params.include?("-#{downcased_parameter(retrieval)}") ? :desc : :asc
56
56
  end
57
57
 
58
58
  retrieval.apply!(items, param_value)
@@ -75,7 +75,7 @@ module Toller
75
75
  def filtering_activated?(retrieval)
76
76
  return true if filter_params.blank? && retrieval.default
77
77
 
78
- filter_params.fetch(retrieval.parameter, nil).present?
78
+ filter_params.fetch(downcased_parameter(retrieval), nil).present?
79
79
  end
80
80
 
81
81
  ##
@@ -84,9 +84,19 @@ module Toller
84
84
  def sorting_activated?(retrieval)
85
85
  return true if sort_params.blank? && retrieval.default
86
86
 
87
- string_parameter = retrieval.parameter.to_s
87
+ string_parameter = downcased_parameter(retrieval)
88
88
 
89
89
  sort_params.include?(string_parameter) || sort_params.include?("-#{string_parameter}")
90
90
  end
91
+
92
+ ##
93
+ # `filter_params`/`sort_params` are downcased at the request level, so `retrieval.parameter`
94
+ # (declared as-is by `filter_on`/`sort_on`) must be downcased the same way before comparison.
95
+ #
96
+ # @param retrieval [Toller::Filter,Toller::Sort] the filter/sort to check
97
+ # @return [String] +retrieval.parameter+ downcased
98
+ def downcased_parameter(retrieval)
99
+ retrieval.parameter.to_s.downcase
100
+ end
91
101
  end
92
102
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toller
4
+ ##
5
+ # Shared lookup used by the `type: :scope` filter/sort handlers to confirm a resolved
6
+ # `scope_name`/`field` is actually declared on the model (a real `scope`, or a plain
7
+ # `def self.foo`), rather than a method inherited from ActiveRecord itself. Without this,
8
+ # a typo'd `scope_name:` that happens to collide with a built-in ActiveRecord class method
9
+ # (`delete_all`, `where`, `sum`, etc.) would silently `public_send` it with the raw request
10
+ # value instead of being treated as an unresolvable scope.
11
+ module ScopeResolver
12
+ module_function
13
+
14
+ ##
15
+ # @param klass [Class] the ActiveRecord model class to check
16
+ # @param name [Symbol,String] the method name to look for
17
+ # @return [Boolean] whether +name+ is defined by +klass+ or one of its own ancestors,
18
+ # rather than inherited from ActiveRecord::Base itself
19
+ def own_class_method?(klass, name)
20
+ klass.singleton_class.ancestors
21
+ .take_while { |mod| mod != ActiveRecord::Base.singleton_class }
22
+ .any? { |mod| mod.public_method_defined?(name.to_sym, false) }
23
+ end
24
+ end
25
+ end
data/lib/toller/sort.rb CHANGED
@@ -21,7 +21,10 @@ module Toller
21
21
  # @option options [Boolean] :default whether this sort applies automatically when no sort params were sent
22
22
  # @option options [Symbol] :scope_name for type: :scope, the model scope to call; defaults to +parameter+
23
23
  # @return [Toller::Sort] a new instance of Sort
24
+ # @raise [ArgumentError] if +type+ isn't one of {Toller::VALID_TYPES}
24
25
  def initialize(parameter, type, options)
26
+ validate_type!(parameter, type)
27
+
25
28
  @parameter = parameter
26
29
  @type = type
27
30
  @properties = options.reverse_merge(
@@ -42,7 +45,7 @@ module Toller
42
45
  if type == :scope
43
46
  Sorts::ScopeHandler.new.call(collection, direction, properties)
44
47
  else
45
- Sorts::ColumnHandler.new.call(collection, direction, properties)
48
+ Sorts::ColumnHandler.new.call(collection, type, direction, properties)
46
49
  end
47
50
  end
48
51
 
@@ -51,5 +54,19 @@ module Toller
51
54
  def default
52
55
  properties[:default]
53
56
  end
57
+
58
+ private
59
+
60
+ ##
61
+ # @param parameter [Symbol] the public sort param name, used in the error message
62
+ # @param type [Symbol] the sort type to validate
63
+ # @return [nil]
64
+ # @raise [ArgumentError] if +type+ isn't one of {Toller::VALID_TYPES}
65
+ def validate_type!(parameter, type)
66
+ return if Toller::VALID_TYPES.include?(type)
67
+
68
+ raise ArgumentError, "Toller: unknown type `#{type.inspect}` for sort `#{parameter}` " \
69
+ "(expected one of #{Toller::VALID_TYPES.join(', ')})"
70
+ end
54
71
  end
55
72
  end
@@ -9,22 +9,47 @@ module Toller
9
9
  ##
10
10
  # Applies a plain `order` clause to +collection+ for a non-scope sort.
11
11
  #
12
- # If +field+ isn't a real column on the collection's model, the sort is
13
- # logged and skipped instead of raising once the relation is evaluated.
12
+ # If +field+ isn't a real column on the collection's model, or the column's actual type
13
+ # doesn't match the declared `type:`, the sort is logged and skipped instead of raising
14
+ # once the relation is evaluated.
14
15
  #
15
16
  # @param collection [ActiveRecord::Relation] the collection to sort
17
+ # @param type [Symbol] the sort type (e.g. :string, :integer, :boolean)
16
18
  # @param direction [Symbol] the sort direction, :asc or :desc
17
19
  # @param properties [Hash] the sort's properties, used to resolve `:field`
18
- # @return [ActiveRecord::Relation] the sorted collection, or +collection+ unchanged if `:field` is unknown
19
- def call(collection, direction, properties)
20
+ # @return [ActiveRecord::Relation] the sorted collection, or +collection+ unchanged if `:field` is
21
+ # unknown or its actual column type doesn't match `type`
22
+ def call(collection, type, direction, properties)
20
23
  field_name = properties[:field]
21
24
 
25
+ return collection unless resolvable_column?(collection, field_name, type)
26
+
27
+ collection.order(field_name => direction)
28
+ end
29
+
30
+ private
31
+
32
+ ##
33
+ # Confirms +field_name+ is a real column on +collection+'s model whose actual type matches
34
+ # +type+, logging a warning and returning false otherwise.
35
+ #
36
+ # @param collection [ActiveRecord::Relation] the collection to sort
37
+ # @param field_name [Symbol] the column to look up
38
+ # @param type [Symbol] the sort's declared type
39
+ # @return [Boolean] whether +field_name+ exists and its actual column type matches +type+
40
+ def resolvable_column?(collection, field_name, type)
22
41
  unless collection.klass.column_names.include?(field_name.to_s)
23
42
  Rails.logger.warn("[Toller] Skipping sort: #{collection.klass} has no column `#{field_name}`")
24
- return collection
43
+ return false
25
44
  end
26
45
 
27
- collection.order(field_name => direction)
46
+ actual_type = collection.klass.columns_hash[field_name.to_s].type
47
+ return true if actual_type == type
48
+
49
+ Rails.logger.warn(
50
+ "[Toller] Skipping sort: #{collection.klass}##{field_name} is `#{actual_type}`, declared as `#{type}`"
51
+ )
52
+ false
28
53
  end
29
54
  end
30
55
  end
@@ -19,7 +19,7 @@ module Toller
19
19
  def call(collection, direction, properties)
20
20
  scoped_name = properties[:scope_name] || properties[:field]
21
21
 
22
- unless collection.klass.respond_to?(scoped_name)
22
+ unless ScopeResolver.own_class_method?(collection.klass, scoped_name)
23
23
  Rails.logger.warn("[Toller] Skipping sort: #{collection.klass} has no scope `#{scoped_name}`")
24
24
  return collection
25
25
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Toller
4
4
  # @return [String] the current gem version
5
- VERSION = "1.1.0"
5
+ VERSION = "1.2.0"
6
6
  end
data/lib/toller.rb CHANGED
@@ -10,6 +10,7 @@ require "toller/filters/mutators/integer"
10
10
  require "toller/filters/mutators/time"
11
11
  require "toller/filters/scope_handler"
12
12
  require "toller/retriever"
13
+ require "toller/scope_resolver"
13
14
  require "toller/sort"
14
15
  require "toller/sorts/column_handler"
15
16
  require "toller/sorts/scope_handler"
@@ -21,6 +22,9 @@ require "toller/sorts/scope_handler"
21
22
  module Toller
22
23
  extend ActiveSupport::Concern
23
24
 
25
+ # @return [Array<Symbol>] the types `filter_on`/`sort_on` recognize for their `type:` option
26
+ VALID_TYPES = %i[string text integer boolean date datetime time scope].freeze
27
+
24
28
  ##
25
29
  # Coerces a raw string value into a boolean, using the same rule Toller's
26
30
  # own `type: :boolean` filters use internally. Handy inside a model scope
@@ -78,15 +82,19 @@ module Toller
78
82
  end
79
83
 
80
84
  ##
85
+ # Filter params
86
+ #
81
87
  # @return [Hash] the current request's filter params, keyed by filter parameter name
82
88
  def filter_params
83
- params.fetch(filter_param_key.to_sym, {})
89
+ params.fetch(filter_param_key.to_sym, {}).transform_keys { |key| key.to_s.strip.downcase }
84
90
  end
85
91
 
86
92
  ##
93
+ # Sort param split
94
+ #
87
95
  # @return [Array<String>] the current request's sort params, e.g. ['-published_at', 'title']
88
96
  def sort_params
89
- params.fetch(sort_param_key.to_sym, "").split(",")
97
+ params.fetch(sort_param_key.to_sym, "").split(",").map { |param| param.strip.downcase }.reject(&:empty?)
90
98
  end
91
99
 
92
100
  ##
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toller
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Freerksen
@@ -46,6 +46,7 @@ files:
46
46
  - lib/toller/filters/mutators/time.rb
47
47
  - lib/toller/filters/scope_handler.rb
48
48
  - lib/toller/retriever.rb
49
+ - lib/toller/scope_resolver.rb
49
50
  - lib/toller/sort.rb
50
51
  - lib/toller/sorts/column_handler.rb
51
52
  - lib/toller/sorts/scope_handler.rb