next_page 0.1.6 → 0.1.7
Sign up to get free protection for your applications and to get access to all the features.
- 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
|