Skip to content

Commit

Permalink
Improve the cache handling and overall code structure
Browse files Browse the repository at this point in the history
  • Loading branch information
ddnexus committed Dec 3, 2024
1 parent da0a45d commit b75b21e
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 54 deletions.
14 changes: 4 additions & 10 deletions gem/lib/pagy/extras/keyset_for_ui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,12 @@ module KeysetForUIExtra

# Return Pagy::Keyset::ForUI object and paginated records
def pagy_keyset_for_ui(set, **vars)
vars[:cursors] ||= pagy_keyset_for_ui_cache(vars)
vars[:page] ||= pagy_get_page(vars) # numeric page
vars[:limit] ||= pagy_get_limit(vars)
pagy = Keyset::ForUI.new(set, **vars).finalize
vars[:page] ||= pagy_get_page(vars) # numeric page
vars[:limit] ||= pagy_get_limit(vars)
vars[:cache] ||= session
pagy = Keyset::ForUI.new(set, **vars)
[pagy, pagy.records]
end

# Return the cached cursors
def pagy_keyset_for_ui_cache(vars)
key = "pagy-keyset-#{B64.encode(params.slice(vars.delete(:query_params)).to_json)}"
session[key] ||= [nil, nil] # 1-based array with no cursor for the first page
end
end
Backend.prepend KeysetForUIExtra
end
56 changes: 40 additions & 16 deletions gem/lib/pagy/keyset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,55 @@ class TypeError < ::TypeError; end
attr_reader :latest # Other readers from SharedMethods

def initialize(set, **vars)
# Extend the instance with the right adapter for the set
if defined?(::ActiveRecord) && set.is_a?(::ActiveRecord::Relation)
extend ActiveRecord
elsif defined?(::Sequel) && set.is_a?(::Sequel::Dataset)
extend Sequel
else
raise TypeError, "expected set to be an instance of ActiveRecord::Relation or Sequel::Dataset; got #{set.class}"
end
assign_vars(default, vars)
assign_limit
@set = set
@page = @vars[:page]
@keyset = extract_keyset
assign_set(set)
assign_keyset
assign_page
ui_support # support for UI (only implemented in Keyset::ForUI)
assign_cursor
raise InternalError, 'the set must be ordered' if @keyset.empty?
return unless @cursor

latest = JSON.parse(B64.urlsafe_decode(@cursor)).transform_keys(&:to_sym)
@latest = typecast_latest(latest)
raise InternalError, 'page and keyset are not consistent' \
unless @latest.keys == @keyset.keys
assign_latest
end

# Assign the cursor from the cache
def assign_cursor
@cursor = @vars[:page]
end

# Assign the keyset extracted by the adaptor
def assign_keyset
@keyset = extract_keyset
raise InternalError, 'the set must be ordered' if @keyset.empty?
end

# Assign the latest and check its consistncy
def assign_latest
latest = JSON.parse(B64.urlsafe_decode(@cursor)).transform_keys(&:to_sym)
@latest = typecast_latest(latest)
raise InternalError, 'latest and keyset are not consistent' \
unless @latest.keys == @keyset.keys
end

# Assign the page
def assign_page
@page = @vars[:page]
end

# Extend the instance with the right adapter for the set
def assign_set(set)
if defined?(::ActiveRecord) && set.is_a?(::ActiveRecord::Relation)
extend ActiveRecord
elsif defined?(::Sequel) && set.is_a?(::Sequel::Dataset)
extend Sequel
else
raise TypeError, "expected set to be an instance of ActiveRecord::Relation or Sequel::Dataset; got #{set.class}"
end
@set = set
end

# Return the Keyset default variables
def default
default = DEFAULT.slice(:limit, :page_param, # from pagy
:headers, # from headers extra
Expand Down Expand Up @@ -82,6 +103,9 @@ def records
end
end

# Only implemente in KEyset::ForUI
def ui_support; end

protected

# Prepare the literal query string (complete with the placeholders for value interpolation)
Expand Down
59 changes: 34 additions & 25 deletions gem/lib/pagy/keyset/for_ui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,61 @@
# frozen_string_literal: true

require_relative '../keyset'
require 'digest/sha2'

class Pagy # :nodoc:
class Keyset
# Implement wicked-fast keyset pagination for UI by using numeric pages that work with regular pagy navs.
class ForUI < Keyset
include SharedMethodsForUI
attr_reader :cursors

def self.new(set, **vars)
allocate.tap do |instance|
instance.instance_variable_set(:@cursors, vars[:cursors])
instance.send(:initialize, set, **vars)
end
# Finalize the instance variables needed for the UI
def initialize(set, **vars)
super
# Ensure next is called, so we know the actual last page
self.next
@prev = @page - 1 unless @page == 1
@last = @cursors.size - 1 # 1-based array size
@in = @records.size
@offset = @limit * (@page - 1) # may not be accurate
@from = @in.zero? ? 0 : @offset + 1
@to = @offset + @in
end

# Override the assign_cursor
# Get the cursor from the cache, not the page param
def assign_cursor
@cursor = @cursors[@page]
end

# Override adding default variables required by the UI
def default
{ **super, **DEFAULT.slice(:ends, :page, :size) }
# Assign a numeric page param
def assign_page
assign_and_check(page: 1)
end

# Finalize the instance variables needed for the UI
def finalize
# Ensure next is called, so we know the actual last page
self.next
@prev = @page - 1 unless @page == 1
@last = @cursors.size - 1 # 1-based array size
raise OverflowError.new(self, :page, "in 1..#{@last}", @page) if @page > @last

@in = @records.size
@offset = @limit * (@page - 1) # may not be accurate
@from = @in.zero? ? 0 : @offset + 1
@to = @offset + @in
self
# Add the default variables required by the UI
def default
{ **super, **DEFAULT.slice(:ends, :page, :size), query_key: [] }
end

# Return the next page from cursors or store the next_cursor; call before finalize
# Return the next page; cache it if it's missing
def next
records
return if !@more || (@vars[:max_pages] && @last > vars[:max_pages])

@next ||= (@page + 1).tap { |next_page| @cursors[next_page] ||= next_cursor }
@next ||= (@page + 1).tap do |next_page|
@cursors[next_page] ||= next_cursor
end
end

# Set up the cache and check for OverflowError
def ui_support
@vars[:cache_key] ||= ->(vars) { vars.slice(:limit).to_json }
key = @vars[:cache_key].is_a?(Proc) ? @vars[:cache_key].(@vars) : @vars[:cache_key]
@key = "pagy-#{Digest::SHA2.hexdigest(key)}"
@cache = @vars[:cache]
@cursors = @cache[@key] ||= [nil, nil] # 1-based array; first cursor is nil
last = @cursors.size - 1
raise OverflowError.new(self, :page, "in 1..#{last}", @page) if @page > last # last before next
end
end
end
Expand Down
2 changes: 0 additions & 2 deletions gem/lib/pagy/shared_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@ def assign_limit
def assign_vars(default, vars)
@vars = { **default, **vars.delete_if { |k, v| default.key?(k) && (v.nil? || v == '') } }
end

end

# Shared with Keyset::ForUI
module SharedMethodsForUI

attr_reader :from, :in, :last, :next, :offset, :prev, :to
alias pages last

Expand Down
2 changes: 1 addition & 1 deletion test/pagy/keyset_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
err = assert_raises(Pagy::InternalError) do
Pagy::Keyset.new(model.order(:id), limit: 10, page: page_animal_id)
end
assert_match(/page and keyset are not consistent/, err.message)
assert_match(/latest and keyset are not consistent/, err.message)
end
end
describe 'uses optional variables' do
Expand Down

0 comments on commit b75b21e

Please sign in to comment.