next_page 0.1.6 → 0.1.7
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 +36 -1
- data/lib/next_page.rb +16 -0
- data/lib/next_page/configuration.rb +38 -0
- data/lib/next_page/sort/name_evaluator.rb +60 -0
- data/lib/next_page/sort/segment_parser.rb +33 -0
- data/lib/next_page/sort/sort_builder.rb +118 -0
- data/lib/next_page/sorter.rb +5 -54
- data/lib/next_page/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 91b9a69827f83cb0e3fd5dcbbdab8537c61d859f89cea744a2afccf8afcf3570
|
4
|
+
data.tar.gz: 4474b5cdb0d3e44473df17a0ae10fbd389abf9a3e372062f5691fb656c8a21e4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bf4b093bb8ae31e75c83416539bd3b9848956e81ff90fcecfd0ed90c08cfdbaf6f84e55651130f6115b3c070f77c0d46a336ce95e74bd785f8a0cd20b5d6dba6
|
7
|
+
data.tar.gz: b0d3559fc5db3431acf8102471db073e17fc428bd8bfc05bbf1b8f472547d4686b9da0eaed4872e9313b0c3d2600ae58f28b933b23732646d3027c1206fd3ac0
|
data/README.md
CHANGED
@@ -47,7 +47,7 @@ resource:
|
|
47
47
|
```
|
48
48
|
|
49
49
|
### Sorting
|
50
|
-
Requests can specify sort order using the parameter `sort` with an attribute name
|
50
|
+
Requests can specify sort order using the parameter `sort` with an attribute name or scope. Sorts can be prefixed with `+` or `-` to indicate ascending or descending. Multiple sorts can be specified either as a comma separated list or via bracket notation.
|
51
51
|
|
52
52
|
/photos?sort=-created_at
|
53
53
|
/photos?sort=location,-created_by
|
@@ -59,6 +59,41 @@ The default sort order is primary key descending. It can be overridden by using
|
|
59
59
|
paginate_with default_sort: '-created_at'
|
60
60
|
```
|
61
61
|
|
62
|
+
#### Nested Sorts
|
63
|
+
|
64
|
+
Nested attributes and scopes can be indicated by providing the association names separated by periods.
|
65
|
+
|
66
|
+
/photos?sort=user.name
|
67
|
+
/photos?sort=-user.address.state
|
68
|
+
|
69
|
+
#### Directional Scope Sorts
|
70
|
+
|
71
|
+
In order to use directions (`+` or `-`) with a scope, the scope must be defined as a class method and take a single parameter. The scope will receive either `'asc'` or `'desc'`. Here is an example of a valid directional scope.
|
72
|
+
|
73
|
+
```ruby
|
74
|
+
def self.status(direction)
|
75
|
+
order("CASE status WHEN 'new' THEN 1 WHEN 'in progress' THEN 2 ELSE 3 END #{direction}")
|
76
|
+
end
|
77
|
+
```
|
78
|
+
|
79
|
+
#### Scope Prefix / Suffix
|
80
|
+
|
81
|
+
In order to keep the peace between frontend and backend developers, scope names can include a prefix or suffix that the front end can ignore. For example, given a scope that sorts on a derived attribute (such as status in the _Direction Scope Sorts_ example), the backend developer might prefer to name the scope status_sort or sort_by_status, as a class method that shares the same name as an attribute might be unclear. However, the frontend developer does not want a query parameter that says <tt>sort=sort_by_status</tt>; it is an exception because it doesn't match the name of the attribute (and it's not pretty).
|
82
|
+
|
83
|
+
The configuration allows a prefix and or suffix to be specified. If either is specified, then in addition to looking for a scope that matches the parameter name, it will also look for a scope that matches the prefixed and/or suffixed name. Prefixes are defined by configuration option <tt>sort_scope_prefix</tt> and suffixes are defined by <tt>sort_scope_suffix</tt>.
|
84
|
+
|
85
|
+
For example, if the backend developer prefers <tt>sort_by_status</tt> then the following configuration can be used:
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
NextPage.configure do |config|
|
89
|
+
config.sort_scope_prefix = 'sort_by_'
|
90
|
+
end
|
91
|
+
```
|
92
|
+
This allows the query parameter to be the following:
|
93
|
+
|
94
|
+
sort=status
|
95
|
+
|
96
|
+
|
62
97
|
### Default Limit
|
63
98
|
The default size limit can be overridden with the `paginate_with` method for either type of paginagion. Pass option
|
64
99
|
`default_limit` to specify an override:
|
data/lib/next_page.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'next_page/configuration'
|
3
4
|
require 'next_page/exceptions'
|
4
5
|
require 'next_page/pagination'
|
5
6
|
require 'next_page/pagination_attributes'
|
@@ -8,4 +9,19 @@ require 'next_page/paginator'
|
|
8
9
|
|
9
10
|
# = Next Page
|
10
11
|
module NextPage
|
12
|
+
class << self
|
13
|
+
attr_writer :configuration
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.configuration
|
17
|
+
@configuration ||= Configuration.new
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.reset
|
21
|
+
@configuration = Configuration.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.configure
|
25
|
+
yield(configuration)
|
26
|
+
end
|
11
27
|
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NextPage
|
4
|
+
# = Configuration
|
5
|
+
#
|
6
|
+
# Class Configuration stores the following settings:
|
7
|
+
# - sort_scope_prefix
|
8
|
+
# - sort_scope_suffix
|
9
|
+
#
|
10
|
+
# == Sort Scope Prefix
|
11
|
+
# Enables client to sort request to be mapped to a scope with a more specific name. For example, given a derived
|
12
|
+
# attribute named <tt>status</tt>, the query parameter can be <tt>sort=status</tt> but would map to a more explicitly
|
13
|
+
# named scope, such as <tt>sort_by_status</tt> (assuming the <tt>sort_scope_prefix</tt> value is 'sort_by_').
|
14
|
+
#
|
15
|
+
# == Sort Scope Suffix
|
16
|
+
# Enables client to sort request to be mapped to a scope with a more specific name. For example, given a derived
|
17
|
+
# attribute named <tt>status</tt>, the query parameter can be <tt>sort=status</tt> but would map to a more explicitly
|
18
|
+
# named scope, such as <tt>status_sort</tt> (assuming the <tt>sort_scope_suffix</tt> value is '_sort').
|
19
|
+
class Configuration
|
20
|
+
attr_reader :sort_scope_prefix, :sort_scope_suffix
|
21
|
+
|
22
|
+
def sort_scope_prefix=(value)
|
23
|
+
@sort_scope_prefix = value.to_s
|
24
|
+
end
|
25
|
+
|
26
|
+
def sort_scope_prefix?
|
27
|
+
@sort_scope_prefix.present?
|
28
|
+
end
|
29
|
+
|
30
|
+
def sort_scope_suffix=(value)
|
31
|
+
@sort_scope_suffix = value.to_s
|
32
|
+
end
|
33
|
+
|
34
|
+
def sort_scope_suffix?
|
35
|
+
@sort_scope_suffix.present?
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NextPage
|
4
|
+
module Sort
|
5
|
+
# = Name Evaluator
|
6
|
+
#
|
7
|
+
# Determines if a name represents an attribute or a scope, provides mapping if the scope requires a prefix or
|
8
|
+
# suffix. Scope is checked first, allowing it to override an attribute of the same name. For scopes, method
|
9
|
+
# <tt>directional_scope?</tt> checks to see if the scope is a class method that accepts one parameter; if so, then
|
10
|
+
# the scope will be invoked with the direction.
|
11
|
+
class NameEvaluator
|
12
|
+
attr_reader :scope_name
|
13
|
+
|
14
|
+
def initialize(model, name)
|
15
|
+
@model = model
|
16
|
+
@name = name.to_s
|
17
|
+
evaluate_for_scope
|
18
|
+
end
|
19
|
+
|
20
|
+
def scope?
|
21
|
+
@scope_name.present?
|
22
|
+
end
|
23
|
+
|
24
|
+
# true when scope is class method with one parameter
|
25
|
+
def directional_scope?
|
26
|
+
return false unless scope?
|
27
|
+
|
28
|
+
@model.method(@scope_name).arity == 1
|
29
|
+
end
|
30
|
+
|
31
|
+
def valid_attribute_name?
|
32
|
+
@model.attribute_names.include?(@name)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def evaluate_for_scope
|
38
|
+
assign_scope(@name) || assign_prefixed_scope || assign_suffixed_scope
|
39
|
+
end
|
40
|
+
|
41
|
+
def assign_scope(potential_scope)
|
42
|
+
return if @model.dangerous_class_method?(potential_scope) || !@model.respond_to?(potential_scope)
|
43
|
+
|
44
|
+
@scope_name = potential_scope
|
45
|
+
end
|
46
|
+
|
47
|
+
def assign_prefixed_scope
|
48
|
+
return unless NextPage.configuration.sort_scope_prefix?
|
49
|
+
|
50
|
+
assign_scope("#{NextPage.configuration.sort_scope_prefix}#{@name}")
|
51
|
+
end
|
52
|
+
|
53
|
+
def assign_suffixed_scope
|
54
|
+
return unless NextPage.configuration.sort_scope_suffix?
|
55
|
+
|
56
|
+
assign_scope("#{@name}#{NextPage.configuration.sort_scope_suffix}")
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NextPage
|
4
|
+
module Sort
|
5
|
+
# = Segment Parser
|
6
|
+
#
|
7
|
+
# Parses each sort segment to provide direction, associations, and name.
|
8
|
+
class SegmentParser
|
9
|
+
attr_reader :direction, :associations, :name
|
10
|
+
|
11
|
+
SEGMENT_REGEX = /(?<sign>[+|-]?)(?<names>.+)/.freeze
|
12
|
+
|
13
|
+
def initialize(segment)
|
14
|
+
@segment = segment
|
15
|
+
parsed = segment.match SEGMENT_REGEX
|
16
|
+
@direction = parsed['sign'] == '-' ? 'desc' : 'asc'
|
17
|
+
*@associations, @name = *parsed['names'].split('.')
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_s
|
21
|
+
@segment
|
22
|
+
end
|
23
|
+
|
24
|
+
def attribute_with_direction
|
25
|
+
{ @name => @direction }
|
26
|
+
end
|
27
|
+
|
28
|
+
def nested?
|
29
|
+
@associations.present?
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NextPage
|
4
|
+
module Sort
|
5
|
+
# = Sort Builder
|
6
|
+
class SortBuilder
|
7
|
+
def initialize(model)
|
8
|
+
@model = model
|
9
|
+
end
|
10
|
+
|
11
|
+
# TODO: support passing direction to scope
|
12
|
+
def build(segment)
|
13
|
+
@parser = NextPage::Sort::SegmentParser.new(segment)
|
14
|
+
|
15
|
+
if @parser.nested?
|
16
|
+
build_nested
|
17
|
+
else
|
18
|
+
build_non_nested
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def build_nested
|
25
|
+
sort_model = dig_association_model(@parser.associations)
|
26
|
+
joins = build_joins(@parser.associations)
|
27
|
+
evaluator = NextPage::Sort::NameEvaluator.new(sort_model, @parser.name)
|
28
|
+
|
29
|
+
if evaluator.scope?
|
30
|
+
build_nested_scope_sort(sort_model, joins, evaluator)
|
31
|
+
elsif evaluator.valid_attribute_name?
|
32
|
+
nested_attribute_sort(sort_model, joins, @parser.attribute_with_direction)
|
33
|
+
else
|
34
|
+
raise NextPage::Exceptions::InvalidSortParameter, @parser
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def build_nested_scope_sort(sort_model, joins, evaluator)
|
39
|
+
if evaluator.directional_scope?
|
40
|
+
nested_directional_scope_sort(sort_model, joins, evaluator.scope_name, @parser.direction)
|
41
|
+
else
|
42
|
+
nested_scope_sort(sort_model, joins, evaluator.scope_name)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def nested_attribute_sort(model, joins, attribute_with_direction)
|
47
|
+
->(query) { query.joins(joins).merge(model.order(attribute_with_direction)) }
|
48
|
+
end
|
49
|
+
|
50
|
+
def nested_scope_sort(model, joins, scope_name)
|
51
|
+
->(query) { query.joins(joins).merge(model.public_send(scope_name)) }
|
52
|
+
end
|
53
|
+
|
54
|
+
def nested_directional_scope_sort(model, joins, scope_name, direction)
|
55
|
+
->(query) { query.joins(joins).merge(model.public_send(scope_name, direction)) }
|
56
|
+
end
|
57
|
+
|
58
|
+
def build_non_nested
|
59
|
+
evaluator = NextPage::Sort::NameEvaluator.new(@model, @parser.name)
|
60
|
+
|
61
|
+
if evaluator.scope?
|
62
|
+
build_non_nested_scope_sort(evaluator)
|
63
|
+
elsif evaluator.valid_attribute_name?
|
64
|
+
attribute_sort(@parser.attribute_with_direction)
|
65
|
+
else
|
66
|
+
raise NextPage::Exceptions::InvalidSortParameter, @parser
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def build_non_nested_scope_sort(evaluator)
|
71
|
+
if evaluator.directional_scope?
|
72
|
+
directional_scope_sort(evaluator.scope_name, @parser.direction)
|
73
|
+
else
|
74
|
+
scope_sort(evaluator.scope_name)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def attribute_sort(attribute_with_direction)
|
79
|
+
->(query) { query.order(attribute_with_direction) }
|
80
|
+
end
|
81
|
+
|
82
|
+
def scope_sort(scope_name)
|
83
|
+
->(query) { query.send(scope_name) }
|
84
|
+
end
|
85
|
+
|
86
|
+
def directional_scope_sort(scope_name, direction)
|
87
|
+
->(query) { query.send(scope_name, direction) }
|
88
|
+
end
|
89
|
+
|
90
|
+
# traverse nested associations to find last association's model
|
91
|
+
def dig_association_model(associations)
|
92
|
+
associations.reduce(@model) do |model, association_name|
|
93
|
+
association = model.reflect_on_association(association_name)
|
94
|
+
raise NextPage::Exceptions::InvalidNestedSort.new(model, association_name) if association.nil?
|
95
|
+
|
96
|
+
association.klass
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# transform associations array to nested hash
|
101
|
+
# ['team'] => [:team]
|
102
|
+
# ['team', 'coach'] => { team: :coach }
|
103
|
+
def build_joins(associations)
|
104
|
+
associations.map(&:to_sym)
|
105
|
+
.reverse
|
106
|
+
.reduce { |memo, association| memo.nil? ? association.to_sym : { association => memo } }
|
107
|
+
end
|
108
|
+
|
109
|
+
# TODO: consider prefix / suffix
|
110
|
+
def named_scope(sort_model)
|
111
|
+
# checking dangerous_class_method? excludes any names that cannot be scope names, such as "name"
|
112
|
+
return unless sort_model.respond_to?(@parser.name) && !sort_model.dangerous_class_method?(@parser.name)
|
113
|
+
|
114
|
+
@parser.name
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
data/lib/next_page/sorter.rb
CHANGED
@@ -1,13 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'next_page/sort/name_evaluator'
|
4
|
+
require 'next_page/sort/segment_parser'
|
5
|
+
require 'next_page/sort/sort_builder'
|
6
|
+
|
3
7
|
module NextPage
|
4
8
|
# = Sorter
|
5
9
|
#
|
6
10
|
# Class Sorter reads the sort parameter and applies the related ordering. Results for each parameter string are
|
7
11
|
# cached so evaluation only occurs once.
|
8
12
|
class Sorter
|
9
|
-
SEGMENT_REGEX = /(?<sign>[+|-]?)(?<attribute>\w+)/.freeze
|
10
|
-
|
11
13
|
# Initializes a new sorter. The given model is used to validate sort attributes as well as build nested sorts.
|
12
14
|
def initialize(model)
|
13
15
|
@model = model
|
@@ -40,59 +42,8 @@ module NextPage
|
|
40
42
|
# returns a lambda that applies the appropriate sort, either from a scope, nested attribute, or attribute
|
41
43
|
def build_sort(key)
|
42
44
|
ActiveSupport::Notifications.instrument('build_sort.next_page', { key: key }) do
|
43
|
-
|
44
|
-
->(query) { query.send(key) }
|
45
|
-
elsif key.include?('.')
|
46
|
-
build_nested_sort(key)
|
47
|
-
else
|
48
|
-
order_params = directional_attribute(@model, key)
|
49
|
-
->(query) { query.order(order_params) }
|
50
|
-
end
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
|
-
def build_nested_sort(nested_key)
|
55
|
-
# remove and capture sign if present
|
56
|
-
sign = nil
|
57
|
-
if nested_key.start_with?('+', '-')
|
58
|
-
sign = nested_key[0]
|
59
|
-
nested_key = nested_key[1..-1]
|
60
|
-
end
|
61
|
-
|
62
|
-
*associations, key = *nested_key.split('.')
|
63
|
-
sort_model = dig_association_model(associations)
|
64
|
-
joins = build_joins(associations)
|
65
|
-
order_params = directional_attribute(sort_model, "#{sign}#{key}")
|
66
|
-
|
67
|
-
->(query) { query.joins(joins).merge(sort_model.order(order_params)) }
|
68
|
-
end
|
69
|
-
|
70
|
-
# traverse nested associations to find last association's model
|
71
|
-
def dig_association_model(associations)
|
72
|
-
associations.reduce(@model) do |model, association_name|
|
73
|
-
association = model.reflect_on_association(association_name)
|
74
|
-
raise NextPage::Exceptions::InvalidNestedSort.new(model, association_name) if association.nil?
|
75
|
-
|
76
|
-
association.klass
|
45
|
+
NextPage::Sort::SortBuilder.new(@model).build(key)
|
77
46
|
end
|
78
47
|
end
|
79
|
-
|
80
|
-
# transform associations array to nested hash
|
81
|
-
# ['team'] => [:team]
|
82
|
-
# ['team', 'coach'] => { team: :coach }
|
83
|
-
def build_joins(associations)
|
84
|
-
associations.map(&:to_sym)
|
85
|
-
.reverse
|
86
|
-
.reduce { |memo, association| memo.nil? ? association.to_sym : { association => memo } }
|
87
|
-
end
|
88
|
-
|
89
|
-
def directional_attribute(model, segment)
|
90
|
-
parsed = segment.match SEGMENT_REGEX
|
91
|
-
attribute = parsed['attribute']
|
92
|
-
direction = parsed['sign'] == '-' ? 'desc' : 'asc'
|
93
|
-
return { attribute => direction } if model.attribute_names.include?(attribute)
|
94
|
-
|
95
|
-
raise NextPage::Exceptions::InvalidSortParameter, segment
|
96
|
-
end
|
97
48
|
end
|
98
49
|
end
|
data/lib/next_page/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: next_page
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Todd Kummer
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-08-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -140,12 +140,16 @@ files:
|
|
140
140
|
- README.md
|
141
141
|
- Rakefile
|
142
142
|
- lib/next_page.rb
|
143
|
+
- lib/next_page/configuration.rb
|
143
144
|
- lib/next_page/exceptions.rb
|
144
145
|
- lib/next_page/exceptions/invalid_nested_sort.rb
|
145
146
|
- lib/next_page/exceptions/invalid_sort_parameter.rb
|
146
147
|
- lib/next_page/pagination.rb
|
147
148
|
- lib/next_page/pagination_attributes.rb
|
148
149
|
- lib/next_page/paginator.rb
|
150
|
+
- lib/next_page/sort/name_evaluator.rb
|
151
|
+
- lib/next_page/sort/segment_parser.rb
|
152
|
+
- lib/next_page/sort/sort_builder.rb
|
149
153
|
- lib/next_page/sorter.rb
|
150
154
|
- lib/next_page/version.rb
|
151
155
|
- lib/tasks/next_page_tasks.rake
|