next_page 0.1.1 → 0.1.6

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: 6ce595b7487e3bad03f114a44e12e9f87ec7dff8044fa652ecc7a9407d395298
4
- data.tar.gz: 8e2eb4b8b7249254b18f05f1e6bbff3d37f94ce245447ed528036ca3df61ef20
3
+ metadata.gz: 48258d40caac39b331bba8d81ea99fcbb5523bab818616243149a952c4c286ea
4
+ data.tar.gz: b52465382e154b636c18f1b0c881cc3c5a4908aa4410a959fe4696904764846b
5
5
  SHA512:
6
- metadata.gz: e0ed8f02d7aaf6122c89dfd4bda2cba1f7f3603f62e6b26a9f72497d597d075828f02c70855918854cd56cd6945477bd6c8e6f6625157be72477690bb1929881
7
- data.tar.gz: 5b38a774a9ae623bab874b486fc48c3dcd3aa62f27c50ce6302dcea24a27254561e81afafd0dc79e05e7c5623769928f1ba94875ecd2bbe6b4b02da9ce1add9f
6
+ metadata.gz: ecdb889ce15846cda3025f6d8c07f61e2901c7633e191c88b8c9aedea79105fd639d95e9abbed8f1cfed8be98ab885f7be4cd67cf5d87d451812e93cceb929b6
7
+ data.tar.gz: 3a1aea4f5ca07e1656e61bc89a4cae38cfb0fe9523d26a7fb5a3064661e87c06c1c1f811fd3191b5f262b5186eabf38e3a40414064d4c1d3eb5bd56d10bb7704
data/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ [![Gem Version](https://badge.fury.io/rb/next_page.svg)](https://badge.fury.io/rb/next_page)
2
+ [![RuboCop](https://github.com/RockSolt/next_page/workflows/RuboCop/badge.svg)](https://github.com/RockSolt/next_page/actions?query=workflow%3ARuboCop)
3
+
1
4
  # NextPage
2
5
  Basic pagination for Rails controllers.
3
6
 
@@ -43,6 +46,18 @@ resource:
43
46
  @photos = paginate_resource(@photos)
44
47
  ```
45
48
 
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.
51
+
52
+ /photos?sort=-created_at
53
+ /photos?sort=location,-created_by
54
+ /photos?sort[]=location&photos[]=-created_by
55
+
56
+ The default sort order is primary key descending. It can be overridden by using the `default_sort` option of `paginate_with`. Use a string formatted just as url parameter would be formatted.
57
+
58
+ ```ruby
59
+ paginate_with default_sort: '-created_at'
60
+ ```
46
61
 
47
62
  ### Default Limit
48
63
  The default size limit can be overridden with the `paginate_with` method for either type of paginagion. Pass option
@@ -60,12 +75,18 @@ paginate_with default_limit: 12, instance_variable_name: 'data'
60
75
  ```
61
76
 
62
77
  ### Link Helpers
63
- This gem does not do any rendering. It does provide helper methods for generating links. The resource will include the following additional methods:
78
+ This gem does not do any rendering. It does provide helper methods for generating links. The resource will include the following additional methods (when the request header Accept is `'application/vnd.api+json'`):
64
79
  - current_page
65
80
  - next_page
66
81
  - total_pages
67
82
  - per_page
68
83
 
84
+ #### Count Query
85
+ In some cases (such as grouping), calling count on the query does not provide an accurate representation. If that is the case, then there are two ways to override the default behavior:
86
+ - provide a count_query that can resolve the attributes
87
+ - specify the following attributes manually: current_page, total_count, and per_page
88
+
89
+
69
90
  ## Installation
70
91
  Add this line to your application's Gemfile:
71
92
 
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'next_page/exceptions'
3
4
  require 'next_page/pagination'
4
5
  require 'next_page/pagination_attributes'
6
+ require 'next_page/sorter'
5
7
  require 'next_page/paginator'
6
8
 
7
9
  # = Next Page
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NextPage
4
+ module Exceptions
5
+ class NextPageError < StandardError
6
+ end
7
+ end
8
+ end
9
+
10
+ require 'next_page/exceptions/invalid_nested_sort'
11
+ require 'next_page/exceptions/invalid_sort_parameter'
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NextPage
4
+ module Exceptions
5
+ # = Invalid Nested Sort
6
+ class InvalidNestedSort < NextPage::Exceptions::NextPageError
7
+ def initialize(model, association)
8
+ @model = model
9
+ @association = association
10
+ end
11
+
12
+ def message
13
+ "Invalid nested sort: Unable to find association #{@association} on model #{@model}"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NextPage
4
+ module Exceptions
5
+ # = Invalid Sort Parameter
6
+ class InvalidSortParameter < NextPage::Exceptions::NextPageError
7
+ def initialize(segment)
8
+ @segment = segment
9
+ end
10
+
11
+ def message
12
+ "Invalid sort parameter (#{@segment}). Must be an attribute or scope."
13
+ end
14
+ end
15
+ end
16
+ end
@@ -52,28 +52,28 @@ module NextPage
52
52
  # - instance_variable_name: explicitly name the variable if it does not follow the convention
53
53
  # - model_class: explicitly specify the model name if it does not follow the convention
54
54
  # - default_limit: specify an alternate default
55
- def paginate_with(instance_variable_name: nil, model_class: nil, default_limit: nil)
56
- next_page_paginator.paginate_with(instance_variable_name, model_class, default_limit)
55
+ # - default_sort: sort parameter if none provided, use same format as url: created_at OR -updated_at
56
+ def paginate_with(instance_variable_name: nil, model_class: nil, default_limit: nil, default_sort: nil)
57
+ next_page_paginator.paginate_with(instance_variable_name, model_class, default_limit, default_sort)
57
58
  end
58
59
  end
59
60
 
60
61
  # Called with before_action in order to automatically paginate the resource.
61
62
  def apply_next_page_pagination
62
- self.class.next_page_paginator.paginate(self, params[:page])
63
+ self.class.next_page_paginator.paginate(self, params.slice(:page, :sort))
63
64
  end
64
65
 
65
66
  # Invokes pagination directly, the result must be stored as the resource itself is not modified.
66
67
  def paginate_resource(resource)
67
- self.class.next_page_paginator.paginate_resource(resource, params[:page])
68
+ self.class.next_page_paginator.paginate_resource(resource, params.slice(:page, :sort))
68
69
  end
69
70
 
70
71
  def render(*args) #:nodoc:
71
72
  return super unless action_name == 'index' && request.headers[:Accept] == 'application/vnd.api+json'
72
73
 
73
- options = args.first
74
- return super unless options.is_a?(Hash) && options.key?(:json)
75
-
76
- options[:meta] = options.fetch(:meta, {}).merge!(total_pages: options[:json].total_pages)
74
+ self.class.next_page_paginator.decorate_meta!(args.first)
75
+ super
76
+ rescue StandardError
77
77
  super
78
78
  end
79
79
  end
@@ -5,21 +5,36 @@ module NextPage
5
5
  #
6
6
  # Module PaginationAttributes adds in methods required for pagination links: current_page, next_page, and total_pages.
7
7
  # It reads the offset and limit on the query to determine the values.
8
+ #
9
+ # In some cases the query will not support count. In that case, there are two ways to override the default behavior:
10
+ # - provide a count_query that can resolve the attributes
11
+ # - specify the following attributes manually: current_page, total_count, and per_page
8
12
  module PaginationAttributes
13
+ attr_writer :count_query, :current_page, :total_count, :per_page
14
+
9
15
  def current_page
10
- @current_page ||= offset_value + 1
16
+ @current_page ||= count_query.offset_value + 1
11
17
  end
12
18
 
13
19
  def next_page
14
20
  current_page + 1
15
21
  end
16
22
 
23
+ def total_count
24
+ @total_count ||= count_query.unscope(:limit).unscope(:offset).count
25
+ end
26
+
17
27
  def total_pages
18
- @total_pages ||= unscope(:limit).unscope(:offset).count / per_page
28
+ total_count.fdiv(per_page).ceil
19
29
  end
20
30
 
21
31
  def per_page
22
- @per_page ||= limit_value
32
+ @per_page ||= count_query.limit_value
33
+ end
34
+
35
+ # checks first to see if an override query has been provided, then fails back to self
36
+ def count_query
37
+ @count_query || self
23
38
  end
24
39
  end
25
40
  end
@@ -22,10 +22,11 @@ module NextPage
22
22
  @default_limit = DEFAULT_LIMIT
23
23
  end
24
24
 
25
- def paginate_with(instance_variable_name, model_class, default_limit)
25
+ def paginate_with(instance_variable_name, model_class, default_limit, default_sort)
26
26
  @default_limit = default_limit if default_limit.present?
27
27
  @instance_variable_name = instance_variable_name
28
28
  @model_class = model_class.is_a?(String) ? model_class.constantize : model_class
29
+ @default_sort = default_sort
29
30
  end
30
31
 
31
32
  def paginate(controller, page_params)
@@ -35,12 +36,19 @@ module NextPage
35
36
  controller.instance_variable_set(name, paginate_resource(data, page_params))
36
37
  end
37
38
 
38
- def paginate_resource(data, page_params)
39
- data.extend(NextPage::PaginationAttributes)
39
+ def paginate_resource(data, params)
40
+ assign_pagination_attributes(data, params)
41
+
42
+ data = sorter.sort(data, params.fetch(:sort, default_sort))
43
+ data.limit(data.per_page).offset((data.current_page - 1) * data.per_page)
44
+ end
40
45
 
41
- limit = page_size(page_params)
42
- offset = page_number(page_params) - 1
43
- data.limit(limit).offset(offset * limit)
46
+ def decorate_meta!(options)
47
+ return unless options.is_a?(Hash) && options.key?(:json) && !options[:json].is_a?(Hash)
48
+
49
+ resource = options[:json]
50
+ options[:meta] = options.fetch(:meta, {}).merge!(total_pages: resource.total_pages,
51
+ total_count: resource.total_count)
44
52
  end
45
53
 
46
54
  private
@@ -55,6 +63,16 @@ module NextPage
55
63
  @instance_variable_name ||= @controller_name
56
64
  end
57
65
 
66
+ def default_sort
67
+ @default_sort ||= "-#{@model_class.primary_key}"
68
+ end
69
+
70
+ def assign_pagination_attributes(data, params)
71
+ data.extend(NextPage::PaginationAttributes)
72
+ data.per_page = page_size(params[:page])
73
+ data.current_page = page_number(params[:page])
74
+ end
75
+
58
76
  def page_size(page)
59
77
  if page.present? && page[:size].present?
60
78
  page[:size]&.to_i
@@ -70,5 +88,9 @@ module NextPage
70
88
  1
71
89
  end
72
90
  end
91
+
92
+ def sorter
93
+ @sorter ||= NextPage::Sorter.new(model_class)
94
+ end
73
95
  end
74
96
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NextPage
4
+ # = Sorter
5
+ #
6
+ # Class Sorter reads the sort parameter and applies the related ordering. Results for each parameter string are
7
+ # cached so evaluation only occurs once.
8
+ class Sorter
9
+ SEGMENT_REGEX = /(?<sign>[+|-]?)(?<attribute>\w+)/.freeze
10
+
11
+ # Initializes a new sorter. The given model is used to validate sort attributes as well as build nested sorts.
12
+ def initialize(model)
13
+ @model = model
14
+ @cache = Hash.new { |hash, key| hash[key] = build_sort(key) }
15
+ end
16
+
17
+ # Adds sorting to given query based upon the param. Returns a new query; the existing query is NOT modified.
18
+ #
19
+ # The +query+ parameter is an ActiveRecord arel or model.
20
+ #
21
+ # The +sort_fields+ parameter is a string that conforms to the JSON-API specification for sorting fields:
22
+ # https://jsonapi.org/format/#fetching-sorting
23
+ def sort(query, sort_fields)
24
+ return from_array(query, sort_fields.split(',')) if sort_fields.include?(',')
25
+ return from_array(query, sort_fields) if sort_fields.is_a? Array
26
+
27
+ apply_sort(query, sort_fields)
28
+ end
29
+
30
+ private
31
+
32
+ def apply_sort(query, key)
33
+ @cache[key].call(query)
34
+ end
35
+
36
+ def from_array(query, param)
37
+ param.reduce(query) { |memo, key| apply_sort(memo, key) }
38
+ end
39
+
40
+ # returns a lambda that applies the appropriate sort, either from a scope, nested attribute, or attribute
41
+ def build_sort(key)
42
+ 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
77
+ end
78
+ 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
+ end
98
+ end
@@ -1,3 +1,3 @@
1
1
  module NextPage
2
- VERSION = '0.1.1'
2
+ VERSION = '0.1.6'
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.1
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Todd Kummer
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-05-04 00:00:00.000000000 Z
11
+ date: 2020-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -106,14 +106,14 @@ dependencies:
106
106
  requirements:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: '0.77'
109
+ version: 0.82.0
110
110
  type: :development
111
111
  prerelease: false
112
112
  version_requirements: !ruby/object:Gem::Requirement
113
113
  requirements:
114
114
  - - "~>"
115
115
  - !ruby/object:Gem::Version
116
- version: '0.77'
116
+ version: 0.82.0
117
117
  - !ruby/object:Gem::Dependency
118
118
  name: simplecov
119
119
  requirement: !ruby/object:Gem::Requirement
@@ -140,16 +140,20 @@ files:
140
140
  - README.md
141
141
  - Rakefile
142
142
  - lib/next_page.rb
143
+ - lib/next_page/exceptions.rb
144
+ - lib/next_page/exceptions/invalid_nested_sort.rb
145
+ - lib/next_page/exceptions/invalid_sort_parameter.rb
143
146
  - lib/next_page/pagination.rb
144
147
  - lib/next_page/pagination_attributes.rb
145
148
  - lib/next_page/paginator.rb
149
+ - lib/next_page/sorter.rb
146
150
  - lib/next_page/version.rb
147
151
  - lib/tasks/next_page_tasks.rake
148
152
  homepage: https://github.com/RockSolt/next_page
149
153
  licenses:
150
154
  - MIT
151
155
  metadata: {}
152
- post_install_message:
156
+ post_install_message:
153
157
  rdoc_options: []
154
158
  require_paths:
155
159
  - lib
@@ -165,7 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
165
169
  version: '0'
166
170
  requirements: []
167
171
  rubygems_version: 3.0.8
168
- signing_key:
172
+ signing_key:
169
173
  specification_version: 4
170
174
  summary: Pagination for Rails Controllers
171
175
  test_files: []