pagy 8.4.0 → 9.0.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/apps/calendar.ru +682 -2137
  3. data/apps/demo.ru +17 -13
  4. data/apps/keyset_ar.ru +236 -0
  5. data/apps/keyset_s.ru +238 -0
  6. data/apps/rails.ru +25 -15
  7. data/apps/repro.ru +17 -14
  8. data/bin/pagy +17 -12
  9. data/config/pagy.rb +37 -34
  10. data/javascripts/pagy.min.js +4 -0
  11. data/javascripts/pagy.min.js.map +10 -0
  12. data/javascripts/pagy.mjs +100 -0
  13. data/lib/optimist.rb +1 -1
  14. data/lib/pagy/b64.rb +33 -0
  15. data/lib/pagy/backend.rb +19 -19
  16. data/lib/pagy/calendar/day.rb +4 -3
  17. data/lib/pagy/calendar/month.rb +4 -3
  18. data/lib/pagy/calendar/quarter.rb +4 -3
  19. data/lib/pagy/calendar/unit.rb +103 -0
  20. data/lib/pagy/calendar/week.rb +3 -3
  21. data/lib/pagy/calendar/year.rb +4 -3
  22. data/lib/pagy/calendar.rb +54 -97
  23. data/lib/pagy/countless.rb +15 -16
  24. data/lib/pagy/extras/arel.rb +3 -11
  25. data/lib/pagy/extras/array.rb +5 -10
  26. data/lib/pagy/extras/bootstrap.rb +5 -5
  27. data/lib/pagy/extras/bulma.rb +10 -7
  28. data/lib/pagy/extras/calendar.rb +34 -5
  29. data/lib/pagy/extras/countless.rb +8 -13
  30. data/lib/pagy/extras/elasticsearch_rails.rb +16 -25
  31. data/lib/pagy/extras/gearbox.rb +26 -26
  32. data/lib/pagy/extras/headers.rb +25 -24
  33. data/lib/pagy/extras/i18n.rb +1 -1
  34. data/lib/pagy/extras/js_tools.rb +10 -10
  35. data/lib/pagy/extras/jsonapi.rb +26 -19
  36. data/lib/pagy/extras/keyset.rb +30 -0
  37. data/lib/pagy/extras/limit.rb +63 -0
  38. data/lib/pagy/extras/meilisearch.rb +9 -17
  39. data/lib/pagy/extras/metadata.rb +6 -2
  40. data/lib/pagy/extras/overflow.rb +11 -10
  41. data/lib/pagy/extras/pagy.rb +16 -16
  42. data/lib/pagy/extras/searchkick.rb +9 -17
  43. data/lib/pagy/extras/size.rb +40 -0
  44. data/lib/pagy/extras/standalone.rb +6 -6
  45. data/lib/pagy/extras/trim.rb +3 -3
  46. data/lib/pagy/frontend.rb +37 -35
  47. data/lib/pagy/i18n.rb +2 -2
  48. data/lib/pagy/keyset/active_record.rb +38 -0
  49. data/lib/pagy/keyset/sequel.rb +51 -0
  50. data/lib/pagy/keyset.rb +98 -0
  51. data/lib/pagy/shared_methods.rb +27 -0
  52. data/lib/pagy/url_helpers.rb +5 -5
  53. data/lib/pagy.rb +70 -94
  54. data/locales/ar.yml +9 -10
  55. data/locales/be.yml +2 -2
  56. data/locales/bg.yml +2 -2
  57. data/locales/bs.yml +2 -2
  58. data/locales/ca.yml +5 -7
  59. data/locales/ckb.yml +2 -2
  60. data/locales/cs.yml +2 -2
  61. data/locales/da.yml +2 -2
  62. data/locales/de.yml +2 -2
  63. data/locales/en.yml +2 -2
  64. data/locales/es.yml +2 -2
  65. data/locales/fr.yml +2 -2
  66. data/locales/hr.yml +2 -2
  67. data/locales/id.yml +2 -2
  68. data/locales/it.yml +2 -2
  69. data/locales/ja.yml +2 -2
  70. data/locales/km.yml +2 -2
  71. data/locales/ko.yml +2 -2
  72. data/locales/nb.yml +2 -2
  73. data/locales/nl.yml +2 -2
  74. data/locales/nn.yml +2 -2
  75. data/locales/pl.yml +2 -2
  76. data/locales/pt-BR.yml +2 -2
  77. data/locales/pt.yml +2 -2
  78. data/locales/ru.yml +2 -2
  79. data/locales/sr.yml +2 -2
  80. data/locales/sv-SE.yml +2 -2
  81. data/locales/sv.yml +2 -2
  82. data/locales/sw.yml +2 -2
  83. data/locales/ta.yml +2 -2
  84. data/locales/tr.yml +2 -2
  85. data/locales/uk.yml +2 -2
  86. data/locales/vi.yml +2 -2
  87. data/locales/zh-CN.yml +2 -2
  88. data/locales/zh-HK.yml +2 -2
  89. data/locales/zh-TW.yml +2 -2
  90. metadata +19 -19
  91. data/javascripts/pagy-dev.js +0 -114
  92. data/javascripts/pagy-module.js +0 -113
  93. data/javascripts/pagy.js +0 -1
  94. data/lib/pagy/calendar/helper.rb +0 -65
  95. data/lib/pagy/extras/foundation.rb +0 -95
  96. data/lib/pagy/extras/items.rb +0 -64
  97. data/lib/pagy/extras/materialize.rb +0 -100
  98. data/lib/pagy/extras/semantic.rb +0 -94
  99. data/lib/pagy/extras/uikit.rb +0 -98
  100. /data/javascripts/{pagy-module.d.ts → pagy.d.ts} +0 -0
@@ -12,7 +12,7 @@ class Pagy # :nodoc:
12
12
  def pagy_nav_js(pagy, id: nil, aria_label: nil, **vars)
13
13
  sequels = pagy.sequels(**vars)
14
14
  id = %( id="#{id}") if id
15
- a = pagy_anchor(pagy)
15
+ a = pagy_anchor(pagy, **vars)
16
16
  tokens = { 'before' => prev_a(pagy, a),
17
17
  'a' => a.(PAGE_TOKEN, LABEL_TOKEN),
18
18
  'current' => %(<a class="current" role="link" aria-current="page" aria-disabled="true">#{
@@ -27,9 +27,9 @@ class Pagy # :nodoc:
27
27
  end
28
28
 
29
29
  # Javascript combo pagination: it returns a nav with a data-pagy attribute used by the pagy.js file
30
- def pagy_combo_nav_js(pagy, id: nil, aria_label: nil)
30
+ def pagy_combo_nav_js(pagy, id: nil, aria_label: nil, **vars)
31
31
  id = %( id="#{id}") if id
32
- a = pagy_anchor(pagy)
32
+ a = pagy_anchor(pagy, **vars)
33
33
  pages = pagy.pages
34
34
 
35
35
  page_input = %(<input name="page" type="number" min="1" max="#{pages}" value="#{pagy.page}" aria-current="page" ) <<
@@ -37,7 +37,7 @@ class Pagy # :nodoc:
37
37
 
38
38
  %(<nav#{id} class="pagy combo-nav-js" #{
39
39
  nav_aria_label(pagy, aria_label:)} #{
40
- pagy_data(pagy, :combo, pagy_url_for(pagy, PAGE_TOKEN))}>#{
40
+ pagy_data(pagy, :combo, pagy_url_for(pagy, PAGE_TOKEN, **vars))}>#{
41
41
  prev_a(pagy, a)
42
42
  }<label>#{
43
43
  pagy_t('pagy.combo_nav_js', page_input:, pages:)
@@ -47,35 +47,35 @@ class Pagy # :nodoc:
47
47
  end
48
48
 
49
49
  # Return the previous page URL string or nil
50
- def pagy_prev_url(pagy, absolute: false)
51
- pagy_url_for(pagy, pagy.prev, absolute:) if pagy.prev
50
+ def pagy_prev_url(pagy, **vars)
51
+ pagy_url_for(pagy, pagy.prev, **vars) if pagy.prev
52
52
  end
53
53
 
54
54
  # Return the next page URL string or nil
55
- def pagy_next_url(pagy, absolute: false)
56
- pagy_url_for(pagy, pagy.next, absolute:) if pagy.next
55
+ def pagy_next_url(pagy, **vars)
56
+ pagy_url_for(pagy, pagy.next, **vars) if pagy.next
57
57
  end
58
58
 
59
59
  # Return the enabled/disabled previous page anchor tag
60
- def pagy_prev_a(pagy, text: pagy_t('pagy.prev'), aria_label: pagy_t('pagy.aria_label.prev'))
61
- a = pagy_anchor(pagy)
60
+ def pagy_prev_a(pagy, text: pagy_t('pagy.prev'), aria_label: pagy_t('pagy.aria_label.prev'), **vars)
61
+ a = pagy_anchor(pagy, **vars)
62
62
  prev_a(pagy, a, text:, aria_label:)
63
63
  end
64
64
 
65
65
  # Return the enabled/disabled next page anchor tag
66
- def pagy_next_a(pagy, text: pagy_t('pagy.next'), aria_label: pagy_t('pagy.aria_label.prev'))
67
- a = pagy_anchor(pagy)
66
+ def pagy_next_a(pagy, text: pagy_t('pagy.next'), aria_label: pagy_t('pagy.aria_label.next'), **vars)
67
+ a = pagy_anchor(pagy, **vars)
68
68
  next_a(pagy, a, text:, aria_label:)
69
69
  end
70
70
 
71
71
  # Conditionally return the previous page link tag
72
- def pagy_prev_link(pagy, absolute: false)
73
- %(<link href="#{pagy_url_for(pagy, pagy.prev, absolute:)}"/>) if pagy.prev
72
+ def pagy_prev_link(pagy, **vars)
73
+ %(<link href="#{pagy_url_for(pagy, pagy.prev, **vars)}"/>) if pagy.prev
74
74
  end
75
75
 
76
76
  # Conditionally return the next page link tag
77
- def pagy_next_link(pagy, absolute: false)
78
- %(<link href="#{pagy_url_for(pagy, pagy.next, absolute:)}"/>) if pagy.next
77
+ def pagy_next_link(pagy, **vars)
78
+ %(<link href="#{pagy_url_for(pagy, pagy.next, **vars)}"/>) if pagy.next
79
79
  end
80
80
  end
81
81
  Frontend.prepend PagyExtra
@@ -23,11 +23,11 @@ class Pagy # :nodoc:
23
23
  # Additions for the Pagy class
24
24
  module PagyExtension
25
25
  # Create a Pagy object from a Searchkick::Results object
26
- def new_from_searchkick(results, vars = {})
27
- vars[:items] = results.options[:per_page]
26
+ def new_from_searchkick(results, **vars)
27
+ vars[:limit] = results.options[:per_page]
28
28
  vars[:page] = results.options[:page]
29
29
  vars[:count] = results.total_count
30
- new(vars)
30
+ new(**vars)
31
31
  end
32
32
  end
33
33
  Pagy.extend PagyExtension
@@ -37,30 +37,22 @@ class Pagy # :nodoc:
37
37
  private
38
38
 
39
39
  # Return Pagy object and results
40
- def pagy_searchkick(pagy_search_args, vars = {})
40
+ def pagy_searchkick(pagy_search_args, **vars)
41
+ vars[:page] ||= pagy_get_page(vars)
42
+ vars[:limit] ||= pagy_get_limit(vars)
41
43
  model, term, options, block, *called = pagy_search_args
42
- vars = pagy_searchkick_get_vars(nil, vars)
43
- options[:per_page] = vars[:items]
44
+ options[:per_page] = vars[:limit]
44
45
  options[:page] = vars[:page]
45
46
  results = model.send(DEFAULT[:searchkick_search], term, **options, &block)
46
47
  vars[:count] = results.total_count
47
48
 
48
- pagy = ::Pagy.new(vars)
49
+ pagy = ::Pagy.new(**vars)
49
50
  # with :last_page overflow we need to re-run the method in order to get the hits
50
- return pagy_searchkick(pagy_search_args, vars.merge(page: pagy.page)) \
51
+ return pagy_searchkick(pagy_search_args, **vars, page: pagy.page) \
51
52
  if defined?(::Pagy::OverflowExtra) && pagy.overflow? && pagy.vars[:overflow] == :last_page
52
53
 
53
54
  [pagy, called.empty? ? results : results.send(*called)]
54
55
  end
55
-
56
- # Sub-method called only by #pagy_searchkick: here for easy customization of variables by overriding
57
- # the _collection argument is not available when the method is called
58
- def pagy_searchkick_get_vars(_collection, vars)
59
- pagy_set_items_from_params(vars) if defined?(ItemsExtra)
60
- vars[:items] ||= DEFAULT[:items]
61
- vars[:page] ||= pagy_get_page(vars)
62
- vars
63
- end
64
56
  end
65
57
  Backend.prepend BackendAddOn
66
58
  end
@@ -0,0 +1,40 @@
1
+ # See the Pagy documentation: https://ddnexus.github.io/pagy/docs/extras/size
2
+ # frozen_string_literal: true
3
+
4
+ class Pagy # :nodoc:
5
+ # Implement the legacy bar using the array size.
6
+ # Unless you have very specific requirements, use the faster and better looking default bar.
7
+ module SizeExtra
8
+ # Implements the old series algorithm
9
+ def series(size: @vars[:size], **_)
10
+ return super unless size.is_a?(Array)
11
+ return [] if size == []
12
+ raise VariableError.new(self, :size, 'to be an Array of 4 Integers or []', size) \
13
+ unless size.is_a?(Array) && size.size == 4 && size.all? { |num| !num.negative? rescue false } # rubocop:disable Style/RescueModifier
14
+
15
+ [].tap do |series|
16
+ # This algorithm is up to ~5x faster and ~2.3x lighter than the previous one (pagy < 4.3)
17
+ # However the behavior of the legacy nav bar was taken straight from WillPaginate and Kaminari:
18
+ # it's ill-concieved and complicates the experience of devs and users.
19
+ left_gap_start = 1 + size[0]
20
+ left_gap_end = @page - size[1] - 1
21
+ right_gap_start = @page + size[2] + 1
22
+ right_gap_end = @last - size[3]
23
+ left_gap_end = right_gap_end if left_gap_end > right_gap_end
24
+ right_gap_start = left_gap_start if left_gap_start > right_gap_start
25
+ start = 1
26
+ if (left_gap_end - left_gap_start).positive?
27
+ series.push(*start...left_gap_start, :gap)
28
+ start = left_gap_end + 1
29
+ end
30
+ if (right_gap_end - right_gap_start).positive?
31
+ series.push(*start...right_gap_start, :gap)
32
+ start = right_gap_end + 1
33
+ end
34
+ series.push(*start..@last)
35
+ series[series.index(@page)] = @page.to_s
36
+ end
37
+ end
38
+ end
39
+ prepend SizeExtra
40
+ end
@@ -12,10 +12,6 @@ class Pagy # :nodoc:
12
12
  module QueryUtils
13
13
  module_function
14
14
 
15
- def escape(str)
16
- URI.encode_www_form_component(str)
17
- end
18
-
19
15
  def build_nested_query(value, prefix = nil)
20
16
  case value
21
17
  when Array
@@ -32,13 +28,17 @@ class Pagy # :nodoc:
32
28
  "#{escape(prefix)}=#{escape(value)}"
33
29
  end
34
30
  end
31
+
32
+ def escape(str)
33
+ URI.encode_www_form_component(str)
34
+ end
35
35
  end
36
36
  # :nocov:
37
37
 
38
38
  # Return the URL for the page. If there is no pagy.vars[:url]
39
39
  # it works exactly as the regular #pagy_url_for, relying on the params method and Rack.
40
40
  # If there is a defined pagy.vars[:url] variable it does not need the params method nor Rack.
41
- def pagy_url_for(pagy, page, absolute: false, **_)
41
+ def pagy_url_for(pagy, page, fragment: nil, **_)
42
42
  return super unless pagy.vars[:url]
43
43
 
44
44
  vars = pagy.vars
@@ -46,7 +46,7 @@ class Pagy # :nodoc:
46
46
  pagy_set_query_params(page, vars, params)
47
47
  params = vars[:params].(params) if vars[:params].is_a?(Proc)
48
48
  query_string = "?#{QueryUtils.build_nested_query(params)}"
49
- "#{vars[:url]}#{query_string}#{vars[:fragment]}"
49
+ "#{vars[:url]}#{query_string}#{fragment}"
50
50
  end
51
51
  end
52
52
  UrlHelpers.prepend StandaloneExtra
@@ -6,10 +6,10 @@ class Pagy # :nodoc:
6
6
 
7
7
  # Remove the page=1 param from the first page link
8
8
  module TrimExtra
9
- # Override the original pagy_a_proc.
9
+ # Override the original pagy_anchor.
10
10
  # Call the pagy_trim method for page 1 if the trim_extra is enabled
11
- def pagy_anchor(pagy)
12
- a_proc = super(pagy)
11
+ def pagy_anchor(pagy, **_)
12
+ a_proc = super
13
13
  return a_proc unless pagy.vars[:trim_extra]
14
14
 
15
15
  lambda do |page, text = pagy.label_for(page), **opts|
data/lib/pagy/frontend.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # See Pagy::Frontend API documentation: https://ddnexus.github.io/pagy/docs/api/frontend
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative 'url_helpers'
5
4
  require_relative 'i18n'
5
+ require_relative 'url_helpers'
6
6
 
7
7
  class Pagy
8
8
  # Used for search and replace, hardcoded also in the pagy.js file
@@ -14,13 +14,44 @@ class Pagy
14
14
  module Frontend
15
15
  include UrlHelpers
16
16
 
17
+ # Return a performance optimized lambda to generate the HTML anchor element (a tag)
18
+ # Benchmarked on a 20 link nav: it is ~22x faster and uses ~18x less memory than rails' link_to
19
+ def pagy_anchor(pagy, anchor_string: nil, **vars)
20
+ anchor_string &&= %( #{anchor_string})
21
+ left, right = %(<a#{anchor_string} href="#{pagy_url_for(pagy, PAGE_TOKEN, **vars)}").split(PAGE_TOKEN, 2)
22
+ # lambda used by all the helpers
23
+ lambda do |page, text = pagy.label_for(page), classes: nil, aria_label: nil|
24
+ classes = %( class="#{classes}") if classes
25
+ aria_label = %( aria-label="#{aria_label}") if aria_label
26
+ %(#{left}#{page}#{right}#{classes}#{aria_label}>#{text}</a>)
27
+ end
28
+ end
29
+
30
+ # Return examples: "Displaying items 41-60 of 324 in total" or "Displaying Products 41-60 of 324 in total"
31
+ def pagy_info(pagy, id: nil, item_name: nil)
32
+ id = %( id="#{id}") if id
33
+ p_count = pagy.count
34
+ key = if p_count.zero?
35
+ 'pagy.info.no_items'
36
+ elsif pagy.pages == 1
37
+ 'pagy.info.single_page'
38
+ else
39
+ 'pagy.info.multiple_pages'
40
+ end
41
+
42
+ %(<span#{id} class="pagy info">#{
43
+ pagy_t key, item_name: item_name || pagy_t('pagy.item_name', count: p_count),
44
+ count: p_count, from: pagy.from, to: pagy.to
45
+ }</span>)
46
+ end
47
+
17
48
  # Generic pagination: it returns the html with the series of links to the pages
18
49
  def pagy_nav(pagy, id: nil, aria_label: nil, **vars)
19
50
  id = %( id="#{id}") if id
20
- a = pagy_anchor(pagy)
51
+ a = pagy_anchor(pagy, **vars)
21
52
 
22
53
  html = %(<nav#{id} class="pagy nav" #{nav_aria_label(pagy, aria_label:)}>#{
23
- prev_a(pagy, a)})
54
+ prev_a(pagy, a)})
24
55
  pagy.series(**vars).each do |item| # series example: [1, :gap, 7, 8, "9", 10, 11, :gap, 36]
25
56
  html << case item
26
57
  when Integer
@@ -36,39 +67,10 @@ class Pagy
36
67
  html << %(#{next_a(pagy, a)}</nav>)
37
68
  end
38
69
 
39
- # Return examples: "Displaying items 41-60 of 324 in total" or "Displaying Products 41-60 of 324 in total"
40
- def pagy_info(pagy, id: nil, item_name: nil)
41
- id = %( id="#{id}") if id
42
- p_count = pagy.count
43
- key = if p_count.zero? then 'pagy.info.no_items'
44
- elsif pagy.pages == 1 then 'pagy.info.single_page'
45
- else 'pagy.info.multiple_pages' # rubocop:disable Lint/ElseLayout
46
- end
47
-
48
- %(<span#{id} class="pagy info">#{
49
- pagy_t key, item_name: item_name || pagy_t('pagy.item_name', count: p_count),
50
- count: p_count, from: pagy.from, to: pagy.to
51
- }</span>)
52
- end
53
-
54
- # Return a performance optimized lambda to generate the HtML anchor element (a tag)
55
- # Benchmarked on a 20 link nav: it is ~22x faster and uses ~18x less memory than rails' link_to
56
- def pagy_anchor(pagy)
57
- a_string = pagy.vars[:anchor_string]
58
- a_string = %( #{a_string}) if a_string
59
- left, right = %(<a#{a_string} href="#{pagy_url_for(pagy, PAGE_TOKEN)}").split(PAGE_TOKEN, 2)
60
- # lambda used by all the helpers
61
- lambda do |page, text = pagy.label_for(page), classes: nil, aria_label: nil|
62
- classes = %( class="#{classes}") if classes
63
- aria_label = %( aria-label="#{aria_label}") if aria_label
64
- %(#{left}#{page}#{right}#{classes}#{aria_label}>#{text}</a>)
65
- end
66
- end
67
-
68
70
  # Similar to I18n.t: just ~18x faster using ~10x less memory
69
71
  # (@pagy_locale explicitly initialized in order to avoid warning)
70
- def pagy_t(key, opts = {})
71
- Pagy::I18n.translate(@pagy_locale ||= nil, key, opts)
72
+ def pagy_t(key, **opts)
73
+ Pagy::I18n.translate(@pagy_locale ||= nil, key, **opts)
72
74
  end
73
75
 
74
76
  private
@@ -90,7 +92,7 @@ class Pagy
90
92
  if (p_next = pagy.next)
91
93
  a.(p_next, text, aria_label:)
92
94
  else
93
- %(<a role="link" aria-disabled="true" aria-label=#{aria_label}>#{text}</a>)
95
+ %(<a role="link" aria-disabled="true" aria-label="#{aria_label}">#{text}</a>)
94
96
  end
95
97
  end
96
98
  end
data/lib/pagy/i18n.rb CHANGED
@@ -118,7 +118,7 @@ class Pagy
118
118
 
119
119
  # Stores the i18n DATA structure for each loaded locale
120
120
  # default on the first locale DATA
121
- DATA = Hash.new { |hash, _| hash.first[1] }
121
+ DATA = Hash.new { |hash,| hash.first[1] }
122
122
 
123
123
  private
124
124
 
@@ -154,7 +154,7 @@ class Pagy
154
154
  end
155
155
 
156
156
  # Translate and pluralize the key with the locale DATA
157
- def translate(locale, key, opts = {})
157
+ def translate(locale, key, **opts)
158
158
  data, pluralize = DATA[locale]
159
159
  translation = data[key] || (opts[:count] && data[key += ".#{pluralize.call(opts[:count])}"]) \
160
160
  or return %([translation missing: "#{key}"])
@@ -0,0 +1,38 @@
1
+ # See Pagy API documentation: https://ddnexus.github.io/pagy/docs/api/keyset
2
+ # frozen_string_literal: true
3
+
4
+ class Pagy
5
+ class Keyset
6
+ # Keyset adapter for ActiveRecord
7
+ class ActiveRecord < Keyset
8
+ protected
9
+
10
+ # Get the keyset attributes of the record
11
+ def latest_from(latest_record) = latest_record.slice(*@keyset.keys)
12
+
13
+ # Extract the keyset from the set
14
+ def extract_keyset
15
+ @set.order_values.each_with_object({}) do |node, keyset|
16
+ keyset[node.value.name.to_sym] = node.direction
17
+ end
18
+ end
19
+
20
+ # Filter out the already retrieved records
21
+ def after_latest = @set.where(after_latest_query, **@latest)
22
+
23
+ # Append the missing keyset keys if the set is restricted by select
24
+ def apply_select
25
+ @set.select(*@keyset.keys)
26
+ end
27
+
28
+ # Set with selected columns?
29
+ def select? = !@set.select_values.empty?
30
+
31
+ # Typecast the latest attributes
32
+ def typecast_latest(latest)
33
+ @set.model.new(latest).slice(latest.keys)
34
+ .to_hash.transform_keys(&:to_sym)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,51 @@
1
+ # See Pagy API documentation: https://ddnexus.github.io/pagy/docs/api/keyset
2
+ # frozen_string_literal: true
3
+
4
+ class Pagy
5
+ class Keyset
6
+ # Keyset adapter for sequel
7
+ class Sequel < Keyset
8
+ protected
9
+
10
+ # Get the keyset attributes of the latest record
11
+ def latest_from(latest_record) = latest_record.to_hash.slice(*@keyset.keys)
12
+
13
+ # Extract the keyset from the set
14
+ def extract_keyset
15
+ return {} unless @set.opts[:order]
16
+
17
+ @set.opts[:order].each_with_object({}) do |item, keyset|
18
+ case item
19
+ when Symbol
20
+ keyset[item] = :asc
21
+ when ::Sequel::SQL::OrderedExpression
22
+ keyset[item.expression] = item.descending ? :desc : :asc
23
+ else
24
+ raise TypeError, "#{item.class.inspect} is not a supported Sequel::SQL::OrderedExpression"
25
+ end
26
+ end
27
+ end
28
+
29
+ # Filter out the already retrieved records
30
+ def after_latest = @set.where(::Sequel.lit(after_latest_query, **@latest))
31
+
32
+ # Append the missing keyset keys if the set is restricted by select
33
+ def apply_select
34
+ selected = @set.opts[:select]
35
+ @set.select_append(*@keyset.keys.reject { |c| selected.include?(c) })
36
+ end
37
+
38
+ # Set with selected columns?
39
+ def select? = !@set.opts[:select].nil?
40
+
41
+ # Typecast the latest attributes
42
+ def typecast_latest(latest)
43
+ model = @set.opts[:model]
44
+ model.unrestrict_primary_key if (restricted_pk = model.restrict_primary_key?)
45
+ latest = model.new(latest).to_hash.slice(*latest.keys.map(&:to_sym))
46
+ model.restrict_primary_key if restricted_pk
47
+ latest
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,98 @@
1
+ # See Pagy API documentation: https://ddnexus.github.io/pagy/docs/api/keyset
2
+ # frozen_string_literal: true
3
+
4
+ require 'json'
5
+ require_relative 'b64'
6
+ require_relative 'shared_methods'
7
+
8
+ class Pagy
9
+ # Implement wicked-fast keyset pagination for big data
10
+ class Keyset
11
+ class TypeError < ::TypeError; end
12
+
13
+ include SharedMethods
14
+
15
+ # Pick the right adapter for the set
16
+ def self.new(set, **vars)
17
+ if self == Pagy::Keyset
18
+ if defined?(::ActiveRecord) && set.is_a?(::ActiveRecord::Relation)
19
+ ActiveRecord
20
+ elsif defined?(::Sequel) && set.is_a?(::Sequel::Dataset)
21
+ Sequel
22
+ else
23
+ raise TypeError, "expected set to be an instance of ActiveRecord::Relation or Sequel::Dataset; got #{set.class}"
24
+ end.new(set, **vars)
25
+ else
26
+ allocate.tap { |instance| instance.send(:initialize, set, **vars) }
27
+ end
28
+ end
29
+
30
+ attr_reader :latest # Other readers from SharedMethods
31
+
32
+ def initialize(set, **vars)
33
+ default = DEFAULT.slice(:limit, :page_param, # from pagy
34
+ :headers, # from headers extra
35
+ :jsonapi, # from jsonapi extra
36
+ :limit_param, :limit_max, :limit_extra) # from limit_extra
37
+ assign_vars({ **default, page: nil }, vars)
38
+ assign_limit
39
+ @set = set
40
+ @page = @vars[:page]
41
+ @keyset = extract_keyset
42
+ raise InternalError, 'the set must be ordered' if @keyset.empty?
43
+ return unless @page
44
+
45
+ latest = JSON.parse(B64.urlsafe_decode(@page)).transform_keys(&:to_sym)
46
+ @latest = @vars[:typecast_latest]&.(latest) || typecast_latest(latest)
47
+ raise InternalError, 'page and keyset are not consistent' \
48
+ unless @latest.keys == @keyset.keys
49
+ end
50
+
51
+ # Return the next page
52
+ def next
53
+ records
54
+ return unless @more
55
+
56
+ @next ||= B64.urlsafe_encode(latest_from(@records.last).to_json)
57
+ end
58
+
59
+ # Retrieve the array of records for the current page
60
+ def records
61
+ @records ||= begin
62
+ @set = apply_select if select?
63
+ @set = @vars[:after_latest]&.(@set, @latest) || after_latest if @latest
64
+ records = @set.limit(@limit + 1).to_a
65
+ @more = records.size > @limit && !records.pop.nil?
66
+ records
67
+ end
68
+ end
69
+
70
+ protected
71
+
72
+ # Prepare the literal query to filter out the already fetched records
73
+ def after_latest_query
74
+ operator = { asc: '>', desc: '<' }
75
+ directions = @keyset.values
76
+ if @vars[:tuple_comparison] && (directions.all?(:asc) || directions.all?(:desc))
77
+ columns = @keyset.keys
78
+ placeholders = columns.map { |column| ":#{column}" }.join(', ')
79
+ "( #{columns.join(', ')} ) #{operator[directions.first]} ( #{placeholders} )"
80
+ else
81
+ keyset = @keyset.to_a
82
+ where = []
83
+ until keyset.empty?
84
+ last_column, last_direction = keyset.pop
85
+ query = +'( '
86
+ query << (keyset.map { |column, _d| "#{column} = :#{column}" } \
87
+ << "#{last_column} #{operator[last_direction]} :#{last_column}").join(' AND ')
88
+ query << ' )'
89
+ where << query
90
+ end
91
+ where.join(' OR ')
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ require_relative 'keyset/active_record'
98
+ require_relative 'keyset/sequel'
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Pagy
4
+ # Shared with Keyset
5
+ module SharedMethods
6
+ attr_reader :page, :limit, :vars
7
+
8
+ # Validates and assign the passed vars: var must be present and value.to_i must be >= to min
9
+ def assign_and_check(name_min)
10
+ name_min.each do |name, min|
11
+ raise VariableError.new(self, name, ">= #{min}", @vars[name]) \
12
+ unless @vars[name]&.respond_to?(:to_i) && \
13
+ instance_variable_set(:"@#{name}", @vars[name].to_i) >= min
14
+ end
15
+ end
16
+
17
+ # Assign @limit (overridden by the gearbox extra)
18
+ def assign_limit
19
+ assign_and_check(limit: 1)
20
+ end
21
+
22
+ # Assign @vars
23
+ def assign_vars(default, vars)
24
+ @vars = { **default, **vars.delete_if { |k, v| default.key?(k) && (v.nil? || v == '') } }
25
+ end
26
+ end
27
+ end
@@ -6,21 +6,21 @@ class Pagy
6
6
  # Return the URL for the page, relying on the params method and Rack by default.
7
7
  # It supports all Rack-based frameworks (Sinatra, Padrino, Rails, ...).
8
8
  # For non-rack environments you can use the standalone extra
9
- def pagy_url_for(pagy, page, absolute: false, **_)
9
+ def pagy_url_for(pagy, page, absolute: false, fragment: nil, **_)
10
10
  vars = pagy.vars
11
- query_params = request.GET.clone
11
+ query_params = request.GET.clone(freeze: false)
12
12
  query_params.merge!(vars[:params].transform_keys(&:to_s)) if vars[:params].is_a?(Hash)
13
13
  pagy_set_query_params(page, vars, query_params)
14
14
  query_params = vars[:params].(query_params) if vars[:params].is_a?(Proc)
15
15
  query_string = "?#{Rack::Utils.build_nested_query(query_params)}"
16
- "#{request.base_url if absolute}#{vars[:request_path] || request.path}#{query_string}#{vars[:fragment]}"
16
+ "#{request.base_url if absolute}#{vars[:request_path] || request.path}#{query_string}#{fragment}"
17
17
  end
18
18
 
19
- # Add the page and items params
19
+ # Add the page and limit params
20
20
  # Overridable by the jsonapi extra
21
21
  def pagy_set_query_params(page, vars, query_params)
22
22
  query_params[vars[:page_param].to_s] = page
23
- query_params[vars[:items_param].to_s] = vars[:items] if vars[:items_extra]
23
+ query_params[vars[:limit_param].to_s] = vars[:limit] if vars[:limit_extra]
24
24
  end
25
25
  end
26
26
  end