lib/pagy/keyset.rb
# See Pagy API documentation: https://ddnexus.github.io/pagy/docs/api/keyset # frozen_string_literal: true require 'json' require_relative 'b64' require_relative 'shared_methods' class Pagy # Implement wicked-fast keyset pagination for big data class Keyset class TypeError < ::TypeError; end include SharedMethods # Pick the right adapter for the set def self.new(set, **vars) if self == Pagy::Keyset if defined?(::ActiveRecord) && set.is_a?(::ActiveRecord::Relation) ActiveRecord elsif defined?(::Sequel) && set.is_a?(::Sequel::Dataset) Sequel else raise TypeError, "expected set to be an instance of ActiveRecord::Relation or Sequel::Dataset; got #{set.class}" end.new(set, **vars) else allocate.tap { |instance| instance.send(:initialize, set, **vars) } end end attr_reader :latest # Other readers from SharedMethods def initialize(set, **vars) default = DEFAULT.slice(:limit, :page_param, # from pagy :headers, # from headers extra :jsonapi, # from jsonapi extra :limit_param, :limit_max, :limit_extra) # from limit_extra assign_vars({ **default, page: nil }, vars) assign_limit @set = set @page = @vars[:page] @keyset = extract_keyset raise InternalError, 'the set must be ordered' if @keyset.empty? return unless @page latest = JSON.parse(B64.urlsafe_decode(@page)).transform_keys(&:to_sym) @latest = typecast_latest(latest) raise InternalError, 'page and keyset are not consistent' \ unless @latest.keys == @keyset.keys end # Return the next page def next records return unless @more @next ||= begin hash = keyset_attributes_from(@records.last) json = @vars[:jsonify_keyset_attributes]&.(hash) || hash.to_json B64.urlsafe_encode(json) end end # Fetch the array of records for the current page def records @records ||= begin @set = apply_select if select? if @latest # :nocov: @set = @vars[:after_latest]&.(@set, @latest) || # deprecated # :nocov: @vars[:filter_newest]&.(@set, @latest, @keyset) || filter_newest end records = @set.limit(@limit + 1).to_a @more = records.size > @limit && !records.pop.nil? records end end protected # Prepare the literal query string (complete with the placeholders for value interpolation) # used to filter the newest records. # For example: # With a set like Pet.order(animal: :asc, name: :desc, id: :asc) it returns the following string: # ( "pets"."animal" = :animal AND "pets"."name" = :name AND "pets"."id" > :id ) OR # ( "pets"."animal" = :animal AND "pets"."name" < :name ) OR # ( "pets"."animal" > :animal ) # When :tuple_comparison is enabled, and if the order is all :asc or all :desc, # with a set like Pet.order(:animal, :name, :id) it returns the following string: # ( "pets"."animal", "pets"."name", "pets"."id" ) > ( :animal, :name, :id ) def filter_newest_query operator = { asc: '>', desc: '<' } directions = @keyset.values table = @set.model.table_name name = @keyset.to_h { |column| [column, %("#{table}"."#{column}")] } if @vars[:tuple_comparison] && (directions.all?(:asc) || directions.all?(:desc)) placeholders = @keyset.keys.map { |column| ":#{column}" }.join(', ') "( #{name.values.join(', ')} ) #{operator[directions.first]} ( #{placeholders} )" else keyset = @keyset.to_a where = [] until keyset.empty? last_column, last_direction = keyset.pop query = +'( ' query << (keyset.map { |column, _d| "#{name[column]} = :#{column}" } \ << "#{name[last_column]} #{operator[last_direction]} :#{last_column}").join(' AND ') query << ' )' where << query end where.join(' OR ') end end end end require_relative 'keyset/active_record' require_relative 'keyset/sequel'