toller 0.5.0 → 1.1.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: 1b4793bf7b83960e6161abed53be6955abdad7ecc5678a0886277639e89bf838
4
- data.tar.gz: b20310d7b23eb741a4d60a8a67129a9bc51276e003d1b875add801292f12c05c
3
+ metadata.gz: bb4ebc1d6c6e8f4eaae0c68c740ae3b35693cd678c6e14e245295a40ad48f2d0
4
+ data.tar.gz: f380d915cb7f85ed2f3cd6873db803c71d01b9a28e96b9ba8375bb241a02432f
5
5
  SHA512:
6
- metadata.gz: 9a03af42f39cb441c4765376d1f172cddbe2ef471bfa9a757b6d9f99681e5b11e4e6bc332b207aeda90aa690f68d20e095c7e93270090bedf96b40f3ad608544
7
- data.tar.gz: 8359c6389c7c020520b1f816481a848ef67c4fad52d8ca570c93c491df6b5b0e437b12763af8dc2c1c5a05dcb65dd0102a62127a8ca74b244cb843f10f06de4a
6
+ metadata.gz: b84c1d90d5d1ebddb6ac81713874ce935d141664a3cb9b4601e08bd4394e844e26aa495d77d88b6381f39587093b3794411cefcc1fe082951239033ba2da070d
7
+ data.tar.gz: 2a1f0894da895687f8b8699073bd84932c41b1a1af591c95269f6e596e8555111af92dea2b065b598a1e3e18ee3f0869033385c46a44c04696d2fd1b74f37940
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2020 David Freerksen
1
+ Copyright 2026 David Freerksen
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,13 +1,22 @@
1
1
  # Toller
2
2
 
3
- URL based filtering and sorting. See the wiki for usage information.
3
+ URL-query-param-based filtering and sorting for Rails controllers.
4
+
5
+ Toller is a Rails engine that lets controllers declare filter_on/sort_on directives, then applies whichever filters and sorts are active for the current request to an ActiveRecord relation based on URL query parameters.
6
+
7
+ See the [wiki](https://github.com/dfreerksen/toller/wiki) for usage information.
8
+
9
+ ## Requirements
10
+
11
+ * Ruby >= 3.3.0
12
+ * Rails >= 6.0
4
13
 
5
14
  ## Installation
6
15
 
7
16
  Add this line to your application's Gemfile:
8
17
 
9
18
  ```ruby
10
- gem 'toller'
19
+ gem 'toller', '~> 1.1'
11
20
  ```
12
21
 
13
22
  And then execute:
@@ -40,40 +49,77 @@ $ bin/test
40
49
 
41
50
  ### Appraisal
42
51
 
52
+ Uses [Appraisal2](https://github.com/appraisal-rb/appraisal2) (a maintained fork of [Appraisal](https://github.com/thoughtbot/appraisal), still exposing the `appraisal` executable) to ensure various dependency versions work as expected
53
+
54
+ When dependencies change, run
55
+
43
56
  ```bash
44
57
  $ bundle exec appraisal install
45
- $ bundle exec appraisal rails-5 bin/test
46
- $ bundle exec appraisal rails-6 bin/test
58
+ $ bundle exec appraisal generate-install
47
59
  ```
48
60
 
49
- ### WWTD
61
+ To run tests with Appraisal, run
62
+
63
+ ```bash
64
+ $ bundle exec appraisal rspec
65
+ ```
50
66
 
51
- Test against the Travis simulator. Note that [WWTD](https://github.com/grosser/wwtd) needs to be installed (install with `gem install wwtd`)
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`.
52
68
 
53
69
  ```bash
54
- $ wwtd
70
+ $ bundle exec appraisal rails-6-0 rspec
71
+ $ bundle exec appraisal rails-6-1 rspec
72
+ $ bundle exec appraisal rails-7-0 rspec
73
+ $ bundle exec appraisal rails-7-1 rspec
74
+ $ bundle exec appraisal rails-7-2 rspec
75
+ $ bundle exec appraisal rails-8-0 rspec
76
+ $ bundle exec appraisal rails-8-1 rspec
55
77
  ```
56
78
 
57
79
  ## Release
58
80
 
59
- 1. Bump the gem version in `lib/toller/version.rb`
60
- 2. Build the gem with
81
+ See [Release.md](docs/Release.md)
82
+
83
+ ## Code Analysis
84
+
85
+ Various tools are used to ensure code is linted and formatted correctly.
86
+
87
+ ### RuboCop
88
+
89
+ [RuboCop](https://github.com/bbatsov/rubocop) is a Ruby static code analyzer.
90
+
91
+ ```bash
92
+ $ rubocop
93
+ ```
94
+
95
+ ### YARD-Lint
96
+
97
+ [YARD-Lint](https://github.com/mensfeld/yard-lint) is a linter for YARD documentation.
98
+
99
+ ```bash
100
+ $ bundle exec yard-lint
101
+ ```
61
102
 
62
- ```
63
- $ bundle exec rake build
64
- ```
103
+ ## Documentation
65
104
 
66
- This will create a new .gem file in `pkg/`. Fix any errors or warnings that come up.
67
- 3. Commit the version change to git with a commit message similar to "Release [X.Y.Z]"
68
- 4. Create the gem, tag it in Github and release to Rubygems
105
+ [Yard](https://github.com/lsegal/yard) is used to generate documentation. [Online documentation is available](http://www.rubydoc.info/github/dfreerksen/toller/master)
69
106
 
70
- ```
71
- $ bundle exec rake release
72
- ```
107
+ Build the documentation with one of the following
108
+
109
+ ```bash
110
+ $ yard
111
+ $ yard doc
112
+ ```
113
+
114
+ Build the documentation and list all undocumented objects
115
+
116
+ ```bash
117
+ $ yard stats --list-undoc
118
+ ```
73
119
 
74
120
  ## Contributing
75
121
 
76
- 1. Fork it ([https://github.com/dfrerksen/recieve/fork](https://github.com/dfrerksen/recieve/fork))
122
+ 1. Fork it ([https://github.com/dfrerksen/toller/fork](https://github.com/dfrerksen/toller/fork))
77
123
  2. Create your feature branch (`git checkout -b my-new-feature`)
78
124
  3. Commit your changes (`git commit -am 'Add some feature'`)
79
125
  4. Push to the branch (`git push origin my-new-feature`)
data/Rakefile CHANGED
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  begin
4
- require 'bundler/setup'
4
+ require "bundler/setup"
5
5
  rescue LoadError
6
- puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
7
7
  end
8
8
 
9
- require 'bundler/gem_tasks'
9
+ require "bundler/gem_tasks"
10
10
 
11
- APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
12
- load 'rails/tasks/engine.rake'
11
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
12
+ load "rails/tasks/engine.rake"
data/lib/toller/filter.rb CHANGED
@@ -3,10 +3,24 @@
3
3
  module Toller
4
4
  ##
5
5
  # Filter
6
- #
7
6
  class Filter
8
- attr_reader :parameter, :properties, :type
7
+ # @return [Symbol] the public filter param name
8
+ attr_reader :parameter
9
9
 
10
+ # @return [Hash] the filter's resolved options (:field, :default, :scope_name, etc.)
11
+ attr_reader :properties
12
+
13
+ # @return [Symbol] the filter type, e.g. :string, :integer, :scope
14
+ attr_reader :type
15
+
16
+ ##
17
+ # @param parameter [Symbol] the public filter param name
18
+ # @param type [Symbol] the filter type, e.g. :string, :integer, :scope
19
+ # @param options [Hash] filter options; merged over defaults for :field, :default, and :scope_name
20
+ # @option options [Symbol] :field the column/attribute to query; defaults to +parameter+
21
+ # @option options [Boolean] :default whether this filter applies automatically when no filter params were sent
22
+ # @option options [Symbol] :scope_name for type: :scope, the model scope to call; defaults to +parameter+
23
+ # @return [Toller::Filter] a new instance of Filter
10
24
  def initialize(parameter, type, options)
11
25
  @parameter = parameter
12
26
  @type = type
@@ -17,14 +31,23 @@ module Toller
17
31
  )
18
32
  end
19
33
 
34
+ ##
35
+ # Applies this filter to +collection+, dispatching to the scope or
36
+ # where handler based on +type+.
37
+ #
38
+ # @param collection [ActiveRecord::Relation] the collection to filter
39
+ # @param value [Object] the active filter param value
40
+ # @return [ActiveRecord::Relation] the filtered collection
20
41
  def apply!(collection, value)
21
42
  if type == :scope
22
43
  Filters::ScopeHandler.new.call(collection, value, properties)
23
44
  else
24
- Filters::WhereHandler.new.call(collection, type, value, properties)
45
+ Filters::ColumnHandler.new.call(collection, type, value, properties)
25
46
  end
26
47
  end
27
48
 
49
+ ##
50
+ # @return [Boolean] whether this filter applies automatically when no filter params were sent at all
28
51
  def default
29
52
  properties[:default]
30
53
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toller
4
+ # :nodoc:
5
+ module Filters
6
+ ##
7
+ # Column handler for filter
8
+ class ColumnHandler
9
+ ##
10
+ # Applies a plain `where` clause to +collection+ for a non-scope filter.
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.
14
+ #
15
+ # @param collection [ActiveRecord::Relation] the collection to filter
16
+ # @param type [Symbol] the filter type (e.g. :string, :integer, :boolean)
17
+ # @param value [Object] the raw filter param value
18
+ # @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
+ def call(collection, type, value, properties)
21
+ field_name = properties[:field]
22
+
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
27
+
28
+ mutated_value = value_mutator(type, value)
29
+
30
+ collection.where(field_name => mutated_value)
31
+ end
32
+
33
+ private
34
+
35
+ ##
36
+ # Value mutator
37
+ #
38
+ # Runs +value+ through the type-specific mutator, if one exists for +type+.
39
+ #
40
+ # @param type [Symbol] the filter type
41
+ # @param value [Object] the raw filter param value
42
+ # @return [Object] the mutated value, or the original +value+ if +type+ has no mutator
43
+ def value_mutator(type, value)
44
+ return value unless %i[boolean date datetime integer time].include?(type)
45
+
46
+ send("#{type}_mutator", value)
47
+ end
48
+
49
+ ##
50
+ # Boolean mutator
51
+ #
52
+ # @param value [String] the raw filter param value
53
+ # @return [Boolean] the mutated boolean value
54
+ def boolean_mutator(value)
55
+ Mutators::Boolean.call(value)
56
+ end
57
+
58
+ ##
59
+ # Integer mutator
60
+ #
61
+ # @param value [String] the raw filter param value
62
+ # @return [String, Range] the mutated integer value or range
63
+ def integer_mutator(value)
64
+ Mutators::Integer.call(value)
65
+ end
66
+
67
+ ##
68
+ # Date mutator
69
+ #
70
+ # @param value [String] the raw filter param value
71
+ # @return [String, Range] the mutated date value or range
72
+ def date_mutator(value)
73
+ Mutators::Date.call(value)
74
+ end
75
+
76
+ ##
77
+ # Time mutator
78
+ #
79
+ # @param value [String] the raw filter param value
80
+ # @return [String, Range] the mutated time value or range
81
+ def time_mutator(value)
82
+ Mutators::Time.call(value)
83
+ end
84
+
85
+ ##
86
+ # DateTime mutator
87
+ #
88
+ # @param value [String] the raw filter param value
89
+ # @return [String, Range] the mutated datetime value or range
90
+ def datetime_mutator(value)
91
+ Mutators::Datetime.call(value)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -1,16 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toller
4
+ # :nodoc:
4
5
  module Filters
6
+ # :nodoc:
5
7
  module Mutators
6
8
  ##
7
9
  # Boolean filter mutator
8
- #
9
10
  module Boolean
10
11
  module_function
11
12
 
13
+ ##
14
+ # Coerces a raw filter param into a boolean.
15
+ #
16
+ # @param value [String] the raw filter param value
17
+ # @return [Boolean] true if +value+ is one of "1", "t", "true", "y", "yes"; false otherwise
12
18
  def call(value)
13
- %w[1 t true y yes].include?(value)
19
+ %w[1 t true y yes].include?(value.to_s.downcase)
14
20
  end
15
21
  end
16
22
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toller
4
+ # :nodoc:
5
+ module Filters
6
+ # :nodoc:
7
+ module Mutators
8
+ # :nodoc:
9
+ module Common
10
+ ##
11
+ # Shared range-parsing behavior for filter mutators. Extend this module to get a
12
+ # `call(value)` that returns +value+ unchanged, or a Range when +value+ contains
13
+ # Ruby range syntax (`..` for inclusive, `...` for exclusive).
14
+ #
15
+ # Note: methods here must refer to Ruby's core Range class as `::Range` — a bare
16
+ # `Range` would resolve to this module (Mutators::Common::Range) instead, since
17
+ # Ruby's constant lookup checks the lexical scope before the top level.
18
+ module Range
19
+ ##
20
+ # Coerces a raw filter param into its original value or a Range.
21
+ #
22
+ # @param value [String] the raw filter param value
23
+ # @return [String,::Range] the original value, or a Range when the value contains range syntax
24
+ # (`..` or `...`)
25
+ def call(value)
26
+ range_dots = inclusive_or_exclusive_range(value)
27
+
28
+ return value if range_dots.blank?
29
+
30
+ range(value, range_dots)
31
+ end
32
+
33
+ ##
34
+ # Builds a Range by splitting +value+ on the given range dots.
35
+ #
36
+ # @param value [String] the raw range string, e.g. "1..10"
37
+ # @param dots [String] the range separator, either ".." or "..."
38
+ # @return [::Range] the resulting range, exclusive when +dots+ is "..."
39
+ def range(value, dots)
40
+ ::Range.new(*value.split(dots), dots == "...")
41
+ end
42
+
43
+ ##
44
+ # Detects whether +value+ contains inclusive or exclusive range syntax.
45
+ #
46
+ # @param value [String] the raw filter param value
47
+ # @return [String,nil] "..." for an exclusive range, ".." for an inclusive range, or nil if +value+ is not
48
+ # a range
49
+ def inclusive_or_exclusive_range(value)
50
+ return "..." if value.include?("...")
51
+ return ".." if value.include?("..")
52
+
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,32 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toller
4
+ # :nodoc:
4
5
  module Filters
6
+ # :nodoc:
5
7
  module Mutators
6
8
  ##
7
9
  # Date filter mutator
8
- #
9
10
  module Date
10
- module_function
11
-
12
- def call(value)
13
- range_dots = inclusive_or_exclusive_range(value)
14
-
15
- return value if range_dots.blank?
16
-
17
- range(value, range_dots)
18
- end
19
-
20
- def range(value, dots)
21
- Range.new(*value.split(dots))
22
- end
23
-
24
- def inclusive_or_exclusive_range(value)
25
- return '...' if value.include?('...')
26
- return '..' if value.include?('..')
27
-
28
- nil
29
- end
11
+ extend Common::Range
30
12
  end
31
13
  end
32
14
  end
@@ -1,32 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toller
4
+ # :nodoc:
4
5
  module Filters
6
+ # :nodoc:
5
7
  module Mutators
6
8
  ##
7
9
  # Datetime filter mutator
8
- #
9
10
  module Datetime
10
- module_function
11
-
12
- def call(value)
13
- range_dots = inclusive_or_exclusive_range(value)
14
-
15
- return value if range_dots.blank?
16
-
17
- range(value, range_dots)
18
- end
19
-
20
- def range(value, dots)
21
- Range.new(*value.split(dots))
22
- end
23
-
24
- def inclusive_or_exclusive_range(value)
25
- return '...' if value.include?('...')
26
- return '..' if value.include?('..')
27
-
28
- nil
29
- end
11
+ extend Common::Range
30
12
  end
31
13
  end
32
14
  end
@@ -1,36 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toller
4
+ # :nodoc:
4
5
  module Filters
6
+ # :nodoc:
5
7
  module Mutators
6
8
  ##
7
9
  # Integer filter mutator
8
- #
9
10
  module Integer
10
- module_function
11
-
12
- def call(value)
13
- return value unless range?(value)
14
-
15
- range(value)
16
- end
17
-
18
- def range?(value)
19
- range_dots = inclusive_or_exclusive_range(value)
20
-
21
- range_dots.present?
22
- end
23
-
24
- def range(value)
25
- Range.new(*value.split(inclusive_or_exclusive_range(value)))
26
- end
27
-
28
- def inclusive_or_exclusive_range(value)
29
- return '...' if value.include?('...')
30
- return '..' if value.include?('..')
31
-
32
- nil
33
- end
11
+ extend Common::Range
34
12
  end
35
13
  end
36
14
  end
@@ -1,32 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toller
4
+ # :nodoc:
4
5
  module Filters
6
+ # :nodoc:
5
7
  module Mutators
6
8
  ##
7
9
  # Time filter mutator
8
- #
9
10
  module Time
10
- module_function
11
-
12
- def call(value)
13
- range_dots = inclusive_or_exclusive_range(value)
14
-
15
- return value if range_dots.blank?
16
-
17
- range(value, range_dots)
18
- end
19
-
20
- def range(value, dots)
21
- Range.new(*value.split(dots))
22
- end
23
-
24
- def inclusive_or_exclusive_range(value)
25
- return '...' if value.include?('...')
26
- return '..' if value.include?('..')
27
-
28
- nil
29
- end
11
+ extend Common::Range
30
12
  end
31
13
  end
32
14
  end
@@ -1,14 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toller
4
+ # :nodoc:
4
5
  module Filters
5
6
  ##
6
7
  # Scope handler for filter
7
- #
8
8
  class ScopeHandler
9
+ ##
10
+ # Applies a named scope to +collection+ for a `type: :scope` filter.
11
+ #
12
+ # If the resolved scope doesn't exist on the collection's model, the
13
+ # filter is logged and skipped instead of raising a NoMethodError.
14
+ #
15
+ # @param collection [ActiveRecord::Relation] the collection to filter
16
+ # @param value [Object] the filter param value to pass to the scope
17
+ # @param properties [Hash] the filter's properties, used to resolve `:scope_name` (falling back to `:field`)
18
+ # @return [ActiveRecord::Relation] the scoped collection, or +collection+ unchanged if the scope is unknown
9
19
  def call(collection, value, properties)
10
20
  scoped_name = properties[:scope_name] || properties[:field]
11
21
 
22
+ unless collection.klass.respond_to?(scoped_name)
23
+ Rails.logger.warn("[Toller] Skipping filter: #{collection.klass} has no scope `#{scoped_name}`")
24
+ return collection
25
+ end
26
+
12
27
  collection.public_send(scoped_name, value)
13
28
  end
14
29
  end
@@ -3,14 +3,38 @@
3
3
  module Toller
4
4
  ##
5
5
  # Retriever
6
- #
7
6
  class Retriever
8
- attr_reader :collection, :filter_params, :retrievals, :sort_params
7
+ # @return [ActiveRecord::Relation] the collection being filtered/sorted
8
+ attr_reader :collection
9
9
 
10
+ # @return [Hash] the request's filter params, keyed by filter parameter name
11
+ attr_reader :filter_params
12
+
13
+ # @return [Array<Toller::Filter,Toller::Sort>] every Filter and Sort registered on the
14
+ # including class's ancestor chain
15
+ attr_reader :retrievals
16
+
17
+ # @return [Array<String>] the request's sort params, e.g. ['-published_at', 'title']
18
+ attr_reader :sort_params
19
+
20
+ ##
21
+ # Builds a Retriever and applies all active filters/sorts to +collection+.
22
+ #
23
+ # @param collection [ActiveRecord::Relation] the collection to filter/sort
24
+ # @param filter_params [Hash] the request's filter params
25
+ # @param sort_params [Array<String>] the request's sort params
26
+ # @param retrievals [Array<Toller::Filter,Toller::Sort>] every registered Filter and Sort
27
+ # @return [ActiveRecord::Relation] the filtered and sorted collection
10
28
  def self.filter(collection, filter_params, sort_params, retrievals)
11
29
  new(collection, filter_params, sort_params, retrievals).filter
12
30
  end
13
31
 
32
+ ##
33
+ # @param collection [ActiveRecord::Relation] the collection to filter/sort
34
+ # @param filter_params [Hash] the request's filter params
35
+ # @param sort_params [Array<String>] the request's sort params
36
+ # @param retrievals [Array<Toller::Filter,Toller::Sort>] every registered Filter and Sort
37
+ # @return [Toller::Retriever] a new instance of Retriever
14
38
  def initialize(collection, filter_params, sort_params, retrievals)
15
39
  @collection = collection
16
40
  @filter_params = filter_params
@@ -18,6 +42,11 @@ module Toller
18
42
  @retrievals = retrievals
19
43
  end
20
44
 
45
+ ##
46
+ # Reduces over the active filters/sorts, chaining each one's `apply!`
47
+ # onto the collection returned by the previous one.
48
+ #
49
+ # @return [ActiveRecord::Relation] the filtered and sorted collection
21
50
  def filter
22
51
  active_retrievals.reduce(collection) do |items, retrieval|
23
52
  param_value = if retrieval.is_a?(Filter)
@@ -32,18 +61,26 @@ module Toller
32
61
 
33
62
  private
34
63
 
64
+ ##
65
+ # @return [Array<Toller::Filter,Toller::Sort>] the subset of +retrievals+ that are active for the current request
35
66
  def active_retrievals
36
67
  retrievals.select do |retrieval|
37
68
  retrieval.is_a?(Filter) ? filtering_activated?(retrieval) : sorting_activated?(retrieval)
38
69
  end
39
70
  end
40
71
 
72
+ ##
73
+ # @param retrieval [Toller::Filter] the filter to check
74
+ # @return [Boolean] whether +retrieval+ is active for the current request
41
75
  def filtering_activated?(retrieval)
42
76
  return true if filter_params.blank? && retrieval.default
43
77
 
44
78
  filter_params.fetch(retrieval.parameter, nil).present?
45
79
  end
46
80
 
81
+ ##
82
+ # @param retrieval [Toller::Sort] the sort to check
83
+ # @return [Boolean] whether +retrieval+ is active for the current request
47
84
  def sorting_activated?(retrieval)
48
85
  return true if sort_params.blank? && retrieval.default
49
86
 
data/lib/toller/sort.rb CHANGED
@@ -3,10 +3,24 @@
3
3
  module Toller
4
4
  ##
5
5
  # Sort
6
- #
7
6
  class Sort
8
- attr_reader :parameter, :properties, :type
7
+ # @return [Symbol] the public sort param name
8
+ attr_reader :parameter
9
9
 
10
+ # @return [Hash] the sort's resolved options (:field, :default, :scope_name, etc.)
11
+ attr_reader :properties
12
+
13
+ # @return [Symbol] the sort type, e.g. :string, :integer, :scope
14
+ attr_reader :type
15
+
16
+ ##
17
+ # @param parameter [Symbol] the public sort param name
18
+ # @param type [Symbol] the sort type, e.g. :string, :integer, :scope
19
+ # @param options [Hash] sort options; merged over defaults for :field, :default, and :scope_name
20
+ # @option options [Symbol] :field the column/attribute to sort on; defaults to +parameter+
21
+ # @option options [Boolean] :default whether this sort applies automatically when no sort params were sent
22
+ # @option options [Symbol] :scope_name for type: :scope, the model scope to call; defaults to +parameter+
23
+ # @return [Toller::Sort] a new instance of Sort
10
24
  def initialize(parameter, type, options)
11
25
  @parameter = parameter
12
26
  @type = type
@@ -17,14 +31,23 @@ module Toller
17
31
  )
18
32
  end
19
33
 
34
+ ##
35
+ # Applies this sort to +collection+, dispatching to the scope or
36
+ # order handler based on +type+.
37
+ #
38
+ # @param collection [ActiveRecord::Relation] the collection to sort
39
+ # @param direction [Symbol] the sort direction, :asc or :desc
40
+ # @return [ActiveRecord::Relation] the sorted collection
20
41
  def apply!(collection, direction = :asc)
21
42
  if type == :scope
22
43
  Sorts::ScopeHandler.new.call(collection, direction, properties)
23
44
  else
24
- Sorts::OrderHandler.new.call(collection, direction, properties)
45
+ Sorts::ColumnHandler.new.call(collection, direction, properties)
25
46
  end
26
47
  end
27
48
 
49
+ ##
50
+ # @return [Boolean] whether this sort applies automatically when no sort params were sent at all
28
51
  def default
29
52
  properties[:default]
30
53
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toller
4
+ # :nodoc:
5
+ module Sorts
6
+ ##
7
+ # Column handler for filter
8
+ class ColumnHandler
9
+ ##
10
+ # Applies a plain `order` clause to +collection+ for a non-scope sort.
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.
14
+ #
15
+ # @param collection [ActiveRecord::Relation] the collection to sort
16
+ # @param direction [Symbol] the sort direction, :asc or :desc
17
+ # @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
+ field_name = properties[:field]
21
+
22
+ unless collection.klass.column_names.include?(field_name.to_s)
23
+ Rails.logger.warn("[Toller] Skipping sort: #{collection.klass} has no column `#{field_name}`")
24
+ return collection
25
+ end
26
+
27
+ collection.order(field_name => direction)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,14 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toller
4
+ # :nodoc:
4
5
  module Sorts
5
6
  ##
6
7
  # Scope handler for sort
7
- #
8
8
  class ScopeHandler
9
+ ##
10
+ # Applies a named scope to +collection+ for a `type: :scope` sort.
11
+ #
12
+ # If the resolved scope doesn't exist on the collection's model, the
13
+ # sort is logged and skipped instead of raising a NoMethodError.
14
+ #
15
+ # @param collection [ActiveRecord::Relation] the collection to sort
16
+ # @param direction [Symbol] the sort direction, :asc or :desc, passed to the scope
17
+ # @param properties [Hash] the sort's properties, used to resolve `:scope_name` (falling back to `:field`)
18
+ # @return [ActiveRecord::Relation] the scoped collection, or +collection+ unchanged if the scope is unknown
9
19
  def call(collection, direction, properties)
10
20
  scoped_name = properties[:scope_name] || properties[:field]
11
21
 
22
+ unless collection.klass.respond_to?(scoped_name)
23
+ Rails.logger.warn("[Toller] Skipping sort: #{collection.klass} has no scope `#{scoped_name}`")
24
+ return collection
25
+ end
26
+
12
27
  collection.public_send(scoped_name, direction)
13
28
  end
14
29
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toller
4
- VERSION = '0.5.0'
4
+ # @return [String] the current gem version
5
+ VERSION = "1.1.0"
5
6
  end
data/lib/toller.rb CHANGED
@@ -1,26 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'toller/filter'
4
- require 'toller/filters/mutators/boolean'
5
- require 'toller/filters/mutators/date'
6
- require 'toller/filters/mutators/datetime'
7
- require 'toller/filters/mutators/integer'
8
- require 'toller/filters/mutators/time'
9
- require 'toller/filters/scope_handler'
10
- require 'toller/filters/where_handler'
11
- require 'toller/retriever'
12
- require 'toller/sort'
13
- require 'toller/sorts/order_handler'
14
- require 'toller/sorts/scope_handler'
3
+ require "toller/filter"
4
+ require "toller/filters/column_handler"
5
+ require "toller/filters/mutators/boolean"
6
+ require "toller/filters/mutators/common/range"
7
+ require "toller/filters/mutators/date"
8
+ require "toller/filters/mutators/datetime"
9
+ require "toller/filters/mutators/integer"
10
+ require "toller/filters/mutators/time"
11
+ require "toller/filters/scope_handler"
12
+ require "toller/retriever"
13
+ require "toller/sort"
14
+ require "toller/sorts/column_handler"
15
+ require "toller/sorts/scope_handler"
15
16
 
16
17
  ##
17
18
  # Toller
18
19
  #
19
20
  # Query param based filtering and sorting
20
- #
21
21
  module Toller
22
22
  extend ActiveSupport::Concern
23
23
 
24
+ ##
25
+ # Coerces a raw string value into a boolean, using the same rule Toller's
26
+ # own `type: :boolean` filters use internally. Handy inside a model scope
27
+ # backing a `type: :scope` filter, which receives the raw param value
28
+ # unmutated.
29
+ #
30
+ # @param value [String] the raw value
31
+ # @return [Boolean] true if +value+ is one of "1", "t", "true", "y", "yes"; false otherwise
32
+ def self.truthy(value)
33
+ Filters::Mutators::Boolean.call(value)
34
+ end
35
+
36
+ ##
37
+ # Applies every active filter/sort for the current request to +collection+.
38
+ #
39
+ # @param collection [ActiveRecord::Relation] the collection to filter/sort
40
+ # @return [ActiveRecord::Relation] the filtered and sorted collection
24
41
  def retrieve(collection)
25
42
  Retriever.filter(collection, filter_params, sort_params, retrievals)
26
43
  end
@@ -30,37 +47,73 @@ module Toller
30
47
  end
31
48
 
32
49
  class_methods do
50
+ ##
51
+ # Declares a filter on the including controller.
52
+ #
53
+ # @param parameter [Symbol] the public filter param name
54
+ # @param type [Symbol] the filter type, e.g. :string, :integer, :scope
55
+ # @param options [Hash] filter options, e.g. :field, :scope_name, :default
56
+ # @return [Array<Toller::Filter,Toller::Sort>] the class's updated `_filters` list
33
57
  def filter_on(parameter, type:, **options)
34
58
  _filters << Filter.new(parameter, type, options)
35
59
  end
36
60
 
61
+ ##
62
+ # Declares a sort on the including controller.
63
+ #
64
+ # @param parameter [Symbol] the public sort param name
65
+ # @param type [Symbol] the sort type, e.g. :string, :integer, :scope
66
+ # @param options [Hash] sort options, e.g. :field, :scope_name, :default
67
+ # @return [Array<Toller::Filter,Toller::Sort>] the class's updated `_filters` list
37
68
  def sort_on(parameter, type:, **options)
38
69
  _filters << Sort.new(parameter, type, options)
39
70
  end
40
71
 
72
+ ##
73
+ # @return [Array<Toller::Filter,Toller::Sort>] the filters/sorts declared directly on this class
74
+ # via `filter_on`/`sort_on`
41
75
  def _filters
42
76
  @_filters ||= []
43
77
  end
44
78
  end
45
79
 
80
+ ##
81
+ # @return [Hash] the current request's filter params, keyed by filter parameter name
46
82
  def filter_params
47
83
  params.fetch(filter_param_key.to_sym, {})
48
84
  end
49
85
 
86
+ ##
87
+ # @return [Array<String>] the current request's sort params, e.g. ['-published_at', 'title']
50
88
  def sort_params
51
- params.fetch(sort_param_key.to_sym, '').split(',')
89
+ params.fetch(sort_param_key.to_sym, "").split(",")
52
90
  end
53
91
 
92
+ ##
93
+ # Override in an including controller to change the query param Toller
94
+ # reads filters from.
95
+ #
96
+ # @return [Symbol] the query param key holding filter params
54
97
  def filter_param_key
55
98
  :filters
56
99
  end
57
100
 
101
+ ##
102
+ # Override in an including controller to change the query param Toller
103
+ # reads sorts from.
104
+ #
105
+ # @return [Symbol] the query param key holding sort params
58
106
  def sort_param_key
59
107
  :sort
60
108
  end
61
109
 
62
110
  private
63
111
 
112
+ ##
113
+ # Retrievals
114
+ #
115
+ # @return [Array<Toller::Filter,Toller::Sort>] every Filter and Sort registered via `filter_on`/`sort_on` across the
116
+ # including class's ancestor chain
64
117
  def retrievals
65
118
  self.class.ancestors.flat_map { |klass| klass.try(:_filters) }.compact
66
119
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toller
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Freerksen
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2020-09-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -16,29 +15,17 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '5.0'
18
+ version: '6.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '5.0'
27
- - !ruby/object:Gem::Dependency
28
- name: appraisal
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: 2.3.0
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: 2.3.0
41
- description: Description of Toller.
25
+ version: '6.0'
26
+ description: Toller is a Rails engine that lets controllers declare filter_on/sort_on
27
+ directives, then applies whichever filters and sorts are active for the current
28
+ request to an ActiveRecord relation based on URL query parameters.
42
29
  email:
43
30
  - dfreerksen@gmail.com
44
31
  executables: []
@@ -50,23 +37,29 @@ files:
50
37
  - Rakefile
51
38
  - lib/toller.rb
52
39
  - lib/toller/filter.rb
40
+ - lib/toller/filters/column_handler.rb
53
41
  - lib/toller/filters/mutators/boolean.rb
42
+ - lib/toller/filters/mutators/common/range.rb
54
43
  - lib/toller/filters/mutators/date.rb
55
44
  - lib/toller/filters/mutators/datetime.rb
56
45
  - lib/toller/filters/mutators/integer.rb
57
46
  - lib/toller/filters/mutators/time.rb
58
47
  - lib/toller/filters/scope_handler.rb
59
- - lib/toller/filters/where_handler.rb
60
48
  - lib/toller/retriever.rb
61
49
  - lib/toller/sort.rb
62
- - lib/toller/sorts/order_handler.rb
50
+ - lib/toller/sorts/column_handler.rb
63
51
  - lib/toller/sorts/scope_handler.rb
64
52
  - lib/toller/version.rb
65
53
  homepage: https://github.com/dfreerksen/toller
66
54
  licenses:
67
55
  - MIT
68
- metadata: {}
69
- post_install_message:
56
+ metadata:
57
+ rubygems_mfa_required: 'true'
58
+ bug_tracker_uri: https://github.com/dfreerksen/toller/issues
59
+ documentation_uri: https://www.rubydoc.info/github/dfreerksen/toller/master
60
+ homepage_uri: https://github.com/dfreerksen/toller
61
+ source_code_uri: https://github.com/dfreerksen/toller
62
+ wiki_uri: https://github.com/dfreerksen/toller/wiki
70
63
  rdoc_options: []
71
64
  require_paths:
72
65
  - lib
@@ -74,15 +67,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
74
67
  requirements:
75
68
  - - ">="
76
69
  - !ruby/object:Gem::Version
77
- version: 2.5.8
70
+ version: '3.0'
78
71
  required_rubygems_version: !ruby/object:Gem::Requirement
79
72
  requirements:
80
73
  - - ">="
81
74
  - !ruby/object:Gem::Version
82
75
  version: '0'
83
76
  requirements: []
84
- rubygems_version: 3.0.3
85
- signing_key:
77
+ rubygems_version: 4.0.16
86
78
  specification_version: 4
87
- summary: Summary of Toller.
79
+ summary: URL-query-param-based filtering and sorting for Rails controllers.
88
80
  test_files: []
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Toller
4
- module Filters
5
- ##
6
- # Where handler for filter
7
- #
8
- class WhereHandler
9
- def call(collection, type, value, properties)
10
- field_name = properties[:field]
11
-
12
- mutated_value = value_mutator(type, value)
13
-
14
- collection.where(field_name => mutated_value)
15
- end
16
-
17
- private
18
-
19
- def value_mutator(type, value)
20
- return value unless %i[boolean date datetime integer time].include?(type)
21
-
22
- send("#{type}_mutator", value)
23
- end
24
-
25
- def boolean_mutator(value)
26
- Mutators::Boolean.call(value)
27
- end
28
-
29
- def integer_mutator(value)
30
- Mutators::Integer.call(value)
31
- end
32
-
33
- def date_mutator(value)
34
- Mutators::Date.call(value)
35
- end
36
-
37
- def time_mutator(value)
38
- Mutators::Time.call(value)
39
- end
40
-
41
- def datetime_mutator(value)
42
- Mutators::Datetime.call(value)
43
- end
44
- end
45
- end
46
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Toller
4
- module Sorts
5
- ##
6
- # Order handler for filter
7
- #
8
- class OrderHandler
9
- def call(collection, direction, properties)
10
- field_name = properties[:field]
11
-
12
- collection.order(field_name => direction)
13
- end
14
- end
15
- end
16
- end