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 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