Files
danbooru/app/logical/pagination_extension.rb
evazion cae6599631 pagination: fix paginator regression caused by Rails 7.
Fix the paginator not detecting the first or last page correctly during
sequential pagination.

Caused by the fact that we fetch one more record than needed to detect
whether we're on the last page, then throw that record away by
overriding Rails' internal `records` method. An upstream refactoring
meant that the `size` method now counts the number of records *after*
the extra record is thrown away, where before it counted *before* the
extra record was thrown away.
2022-01-07 14:24:57 -06:00

159 lines
4.8 KiB
Ruby

# frozen_string_literal: true
# A mixin that adds a `#paginate` method to an ActiveRecord relation.
#
# There are two pagination techniques. The first is page-based (numbered):
#
# https://danbooru.donmai.us/posts?page=1
# https://danbooru.donmai.us/posts?page=2
# https://danbooru.donmai.us/posts?page=3
#
# The second is id-based (sequential):
#
# https://danbooru.donmai.us/posts?page=a1000&limit=100
# https://danbooru.donmai.us/posts?page=a1100&limit=100
# https://danbooru.donmai.us/posts?page=a1200&limit=100
#
# https://danbooru.donmai.us/posts?page=b1000&limit=100
# https://danbooru.donmai.us/posts?page=b900&limit=100
# https://danbooru.donmai.us/posts?page=b800&limit=100
#
# where a1000 means "after id 1000" and b1000 means "before id 1000".
#
module PaginationExtension
class PaginationError < StandardError; end
attr_accessor :current_page, :records_per_page, :paginator_count, :paginator_mode, :paginator_page_limit
# Paginate an ActiveRecord relation. Returns a relation for the given page and number of posts per page.
#
# @param page [String] the page number, or an "aNNN" or "bNNN" string
# @param limit [Integer] the number of posts per page
# @param max_limit [Integer] the maximum number of posts per page the user can view
# @param page_limit [Integer] the highest page the user can view
# @param count [Integer] the precalculated number of search results, or nil to calculate it
# @param search_count [Object] if truthy, don't calculate the number of results; assume a large number of results
def paginate(page, limit: nil, max_limit: 1000, page_limit: CurrentUser.user.page_limit, count: nil, search_count: nil)
@records_per_page = limit || Danbooru.config.posts_per_page
@records_per_page = @records_per_page.to_i.clamp(1, max_limit)
@paginator_page_limit = page_limit
if count.present?
@paginator_count = count
elsif !search_count.nil? && search_count.blank?
@paginator_count = Float::INFINITY
end
if page.to_s =~ /\Ab(\d+)\z/i
@paginator_mode = :sequential_before
paginate_sequential_before($1, records_per_page)
elsif page.to_s =~ /\Aa(\d+)\z/i
@paginator_mode = :sequential_after
paginate_sequential_after($1, records_per_page)
elsif page.to_i > page_limit
raise PaginationError
elsif page.to_i == page_limit
@paginator_mode = :sequential_after
paginate_numbered(page.to_i, records_per_page)
else
@paginator_mode = :numbered
@current_page = [page.to_i, 1].max
paginate_numbered(current_page, records_per_page)
end
end
def paginate_sequential_before(before_id, limit)
where("#{table_name}.id < ?", before_id).reorder("#{table_name}.id desc").limit(limit + 1)
end
def paginate_sequential_after(after_id, limit)
where("#{table_name}.id > ?", after_id).reorder("#{table_name}.id asc").limit(limit + 1)
end
def paginate_numbered(page, limit)
offset((page - 1) * limit).limit(limit)
end
def is_first_page?
case paginator_mode
when :numbered
current_page == 1
when :sequential_before
false
when :sequential_after
load
@records.size <= records_per_page
end
end
def is_last_page?
case paginator_mode
when :numbered
current_page >= total_pages
when :sequential_before
load
@records.size <= records_per_page
when :sequential_after
false
end
end
def prev_page
if is_first_page?
nil
elsif paginator_mode == :numbered
current_page - 1
elsif records.present?
"a#{records.first.id}"
else
nil
end
rescue ActiveRecord::QueryCanceled
nil
end
def next_page
if is_last_page?
nil
elsif paginator_mode == :numbered
current_page + 1
elsif records.present?
"b#{records.last.id}"
else
nil
end
rescue ActiveRecord::QueryCanceled
nil
end
# XXX Hack: in sequential pagination we fetch one more record than we
# need so that we can tell when we're on the first or last page. Here
# we override a rails internal method to discard that extra record. See
# #2044, #3642.
def records
case paginator_mode
when :sequential_before
super.first(records_per_page)
when :sequential_after
super.first(records_per_page).reverse
when :numbered
super
end
end
# Return the number of pages of results, or infinity if it takes too long to count.
def total_pages
return Float::INFINITY if total_count.infinite?
(total_count.to_f / records_per_page).ceil
end
# Return the number of results, or infinity if it takes too long to count.
def total_count
@paginator_count ||= unscoped.from(except(:offset, :limit, :order).reorder(nil)).count
rescue ActiveRecord::StatementInvalid => e
raise unless e.to_s =~ /statement timeout/
@paginator_count ||= Float::INFINITY
end
end