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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48258d40caac39b331bba8d81ea99fcbb5523bab818616243149a952c4c286ea
4
- data.tar.gz: b52465382e154b636c18f1b0c881cc3c5a4908aa4410a959fe4696904764846b
3
+ metadata.gz: 91b9a69827f83cb0e3fd5dcbbdab8537c61d859f89cea744a2afccf8afcf3570
4
+ data.tar.gz: 4474b5cdb0d3e44473df17a0ae10fbd389abf9a3e372062f5691fb656c8a21e4
5
5
  SHA512:
6
- metadata.gz: ecdb889ce15846cda3025f6d8c07f61e2901c7633e191c88b8c9aedea79105fd639d95e9abbed8f1cfed8be98ab885f7be4cd67cf5d87d451812e93cceb929b6
7
- data.tar.gz: 3a1aea4f5ca07e1656e61bc89a4cae38cfb0fe9523d26a7fb5a3064661e87c06c1c1f811fd3191b5f262b5186eabf38e3a40414064d4c1d3eb5bd56d10bb7704
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, scope, or nested attribute. Attributes and nested attributes 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.
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:
@@ -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
@@ -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
- if @model.respond_to?(key)
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
@@ -1,3 +1,3 @@
1
1
  module NextPage
2
- VERSION = '0.1.6'
2
+ VERSION = '0.1.7'
3
3
  end
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.6
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-07-01 00:00:00.000000000 Z
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