Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cursor pagination #1101

Draft
wants to merge 19 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,97 @@ def self.#{Kaminari.config.page_method_name}(num = nil)
end
end
RUBY

# Fetch the values after cursor
# Model.page_after(cursor: cursor)
eval <<-RUBY, nil, __FILE__, __LINE__ + 1
def self.#{Kaminari.config.page_after_method_name}(cursor = {})
cursor = decode_cursor(cursor) || {}
cursor.delete(:#{Kaminari.config.page_direction_attr_name})
cursor.delete('#{Kaminari.config.page_direction_attr_name}')
cursor[:#{Kaminari.config.page_direction_attr_name}] = :after
self.#{Kaminari.config.page_by_cursor_method_name}(cursor)
end
RUBY

# Fetch the values before cursor
# Model.page_before(cursor: cursor)
eval <<-RUBY, nil, __FILE__, __LINE__ + 1
def self.#{Kaminari.config.page_before_method_name}(cursor = {})
cursor = decode_cursor(cursor) || {}
cursor.delete(:#{Kaminari.config.page_direction_attr_name})
cursor.delete('#{Kaminari.config.page_direction_attr_name}')
cursor[:#{Kaminari.config.page_direction_attr_name}] = :before
self.#{Kaminari.config.page_by_cursor_method_name}(cursor)
end
RUBY

# Fetch the values after or before cursor, depending on page direction embedded in cursor.
# Direction defaults to after if direction is not provided or cursor values are not provided.
# Model.page_by_cursor(cursor)
eval <<-RUBY, nil, __FILE__, __LINE__ + 1
def self.#{Kaminari.config.page_by_cursor_method_name}(directed_cursor = {})
per_page = max_per_page && (default_per_page > max_per_page) ? max_per_page : default_per_page

# Convert cursor to OpenStruct with .columns, each having .name and .value
cursor = decode_cursor(directed_cursor) || {}
querying_before_cursor = (cursor.delete(:#{Kaminari.config.page_direction_attr_name}) || cursor.delete('#{Kaminari.config.page_direction_attr_name}')).try(:to_sym) == :before && cursor.any?
cursor = cursor.empty? ? nil : JSON.parse({columns: cursor.each_pair.map{|name,value|{name: name.to_s, full_name: table_name + '.' + name.to_s, value: value.try(:to_s)}}}.to_json, object_class:OpenStruct)

if cursor
# Validate cursor columns against model
cursor_columns = cursor.columns.map { |c| c.name }
model_columns = columns.map { |c| c.name }
raise "Cursor has columns that are not on model: \#{cursor_columns - model_columns}" if (cursor_columns - model_columns).any?
end

relation = limit(per_page).extending do
include Kaminari::ActiveRecordRelationMethods
include Kaminari::CursorPageScopeMethods
include Kaminari::CursorPaginatable
end
relation.instance_variable_set('@_cursor', cursor)
relation.instance_variable_set('@_querying_before_cursor', querying_before_cursor)

# Assert that ActiveRecord order columns come directly from model. (ordering by association columns not supported)
raise "Cursor pagination does not support ordering by associated columns" if relation.ordered_by_unsupported_columns

# Ensure that primary key is part of ordering
relation = relation.order(primary_key + ' asc') unless relation.normalized_order_info[:columns].include? primary_key

order_columns = relation.normalized_order_info[:columns]

if !cursor
condition = nil
values = []
peekback_relation = nil
else
# Coerce cursor column order into agreement with ActiveRecord order
cursor.columns.keep_if { |c| order_columns.include? c.name }
cursor.columns.sort_by! { |c| order_columns.index(c.name) }

# Generate condition for cursor-based filter
after_condition, after_values = relation.build_cursor_condition(:after)
before_condition, before_values = relation.build_cursor_condition(:before)
condition = querying_before_cursor ? before_condition : after_condition
values = querying_before_cursor ? before_values : after_values

# Peek back to detect any result in opposite direction
peekback_condition = (querying_before_cursor ? after_condition : before_condition) + ' or (' + (cursor.columns.map {|c| c.value.nil? ? (c.full_name + ' is null ') : (c.full_name + ' = ? ')}).join(' and ') + ')'
peekback_values = (querying_before_cursor ? after_values : before_values) + cursor.columns.map{|c| c.value}.compact
peekback_relation = relation.where(peekback_condition, *peekback_values).limit(1)
peekback_relation.reverse_order!
end

relation = relation.where(condition, *values) if condition
relation = relation.reverse_order if querying_before_cursor

relation.instance_variable_set('@_peekback_relation', peekback_relation)
relation.instance_variable_set('@_order_columns', order_columns)

relation
end
RUBY
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,157 @@ def total_count
end
end
end

module Kaminari
module CursorPaginatable
# Overwrite AR::Relation#load to filter relative to cursor and peek ahead/behind.
def load peek: false
if loaded? || limit_value.nil? || peek
super()
else
has_peekback_record = @_peekback_relation ? @_peekback_relation.load(peek: true).records.any? : false

set_limit_value limit_value + 1
super()
set_limit_value limit_value - 1

if @records.any?
# Use extra record and peekback record to determine whether has next/prev page.
# Re-reverse sort order if querying `before`.
@records = @records.dup if (frozen = @records.frozen?)
has_extra_record = !!@records.delete_at(limit_value)
@records = @records.reverse if @_querying_before_cursor
@_has_next = @_querying_before_cursor ? has_peekback_record : has_extra_record
@_has_prev = @_querying_before_cursor ? has_extra_record : has_peekback_record
@records.freeze if frozen

# Generate start/end cursors for further paging
@_start_cursor = build_cursor_from_record @records.first
@_start_cursor[Kaminari.config.page_direction_attr_name] = 'before'
@_end_cursor = build_cursor_from_record @records.last
@_end_cursor[Kaminari.config.page_direction_attr_name] = 'after'
end

self
end
end

# Force to raise an exception if #total_count is called explicitly.
def total_count
raise "This scope is marked as a cursor paginable scope and can't be used in combination " \
"with `#paginate' or `#page_entries_info'. Use #link_to_page_after or #link_to_page_before instead."
end

def build_cursor_from_record(record)
cursor_values = @_order_columns.map { |c| record.read_attribute_before_type_cast(c) }

# Serialize timestamps with nanoseconds.
cursor_values = cursor_values.map do |value|
if value.respond_to?(:xmlschema)
value.xmlschema(9) # nanoseconds
else
value
end
end

Hash[@_order_columns.zip(cursor_values)]
end

def build_cursor_condition(search_direction)
order_dirs = normalized_order_info[:dirs]
explicit_null_position_per_column = normalized_order_info[:explicit_null_positions]
asc_operator = {after: '>', before: '<'}.fetch(search_direction)
desc_operator = {after: '<', before: '>'}.fetch(search_direction)
preceding_columns_per_column = @_cursor.columns.each_index.map{|i| @_cursor.columns[0, i]}
nulls_after_per_column = explicit_null_position_per_column.zip(order_dirs).map {|explicit_null_position, dir|
case explicit_null_position
when 'first'
false
when 'last'
true
else
(dir == :asc and db_defaults_to_large_nulls) or (dir == :desc and !db_defaults_to_large_nulls)
end
}
condition = @_cursor.columns.zip(preceding_columns_per_column, order_dirs, nulls_after_per_column)
.map { |column, preceding_columns, dir, nulls_after|
nulls_last = (nulls_after and search_direction == :after) || (!nulls_after and search_direction == :before)
if column.value.nil?
inequality = nulls_last ? nil : "#{column.full_name} is not null"
else
inequality = {asc: "#{column.full_name} #{asc_operator} ?", desc: "#{column.full_name} #{desc_operator} ?"}.fetch(dir)
inequality += " or #{column.full_name} is null" if nulls_last
end
inequality.nil? ? nil : (preceding_columns.map{|c| [c.full_name, c.value]} .map {|c, v| v.nil? ? "#{c} is null" : "#{c} = ?" } + ['(' + inequality + ')']).join(' and ')
}
.compact
.map {|c| '(' + c + ')'}
.join(' or ')
values = @_cursor.columns.zip(preceding_columns_per_column, nulls_after_per_column)
.map { |column, preceding_columns, nulls_after|
nulls_last = (nulls_after and search_direction == :after) || (!nulls_after and search_direction == :before)
(column.value.nil? and nulls_last) ? nil : (preceding_columns.map{|c| c.value} + [column.value])
}
.flatten
.compact
return condition, values
end

def normalized_order_info
# Normalize order to strings formatted as '(table.)?<column_name> (asc|desc)( nulls (first|last))?'
order_strings = order_values
.map { |o| o.is_a?(Arel::Nodes::Ordering) ? o.to_sql : o }
.map { |o| o.split(',') }.flatten
.map { |o| o.downcase.strip }
.map { |o| o.gsub(/(?<!")"(([^"]|"")+)"(?!")/, '\1') }
.map { |o| o.match(/\s+(asc|desc)(\s+nulls\s+(first|last))?/) ? o : o.sub(/(\s+nulls\s+(first|last))?$/, ' asc\0') }
return {
columns: order_strings.map(&:split).map(&:first).map{|o| split_and_unquote_identifiers(o).last},
dirs: order_strings.map{|o| o.match(/\s+(asc|desc)(\s+nulls\s+(first|last))?/)}.map{|o| o[1].to_sym},
explicit_null_positions: order_strings.map{|o| o.match(/\s+(asc|desc)(\s+nulls\s+(first|last))?/)}.map{|o| o[3]}
}
end

def split_and_unquote_identifiers fully_qualified_identifier

# Split identifiers that may be quoted or unquoted
grave_quoted_identifier = '(?=`(?:[^`]|`{2})*`)'
double_quoted_identifier = '(?="(?:[^"]|"{2})*")'
non_quoted_identifier_terminated_by_dot = '(?=(?:[^"`]+\.))'
non_quoted_identifier_terminated_by_end = '(?=(?:[^"`]+$))'

adapter_type = connection.adapter_name.downcase.to_sym
identifiers = [non_quoted_identifier_terminated_by_dot, non_quoted_identifier_terminated_by_end]
identifiers += [grave_quoted_identifier] if [:mysql, :mysql2, :sqlite].include? adapter_type
identifiers += [double_quoted_identifier] if [:postgresql, :sqlite].include? adapter_type

quoted_identifiers = fully_qualified_identifier.split(/\.(?:#{identifiers.join('|')})/)

# Unquote identifiers
unquoted_identifiers = (
quoted_identifiers
.map{|s| s[ /`(([^`]|`{2})*)`/, 1] || s} # Unquote grave quoted identifiers
.map{|s| s[/"(([^"]|"{2})*)"/, 1] || s} # Unquote double quoted identifiers
)
return unquoted_identifiers

end

def ordered_by_unsupported_columns
order_values.map{|o| (o.is_a?(Arel::Nodes::Ascending) or o.is_a?(Arel::Nodes::Descending)) ? o.expr.relation.name : nil}.compact.any?{|relation| relation != table_name}
end

def db_defaults_to_large_nulls
case adapter_type = connection.adapter_name.downcase.to_sym
when :mysql, :mysql2
false
when :sqlite
false
when :postgresql
true
else
raise NotImplementedError, "Unknown adapter type '#{adapter_type}'"
end
end
end
end
11 changes: 11 additions & 0 deletions kaminari-core/app/views/kaminari/_page_after.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<%# Link to the "Next" page for cursor-based pagination
- available local variables
url: url to the previous page
first_page?: indicates there are no earlier results
last_page?: indicates there are no later results
per_page: number of items to fetch per page
remote: data-remote
-%>
<span class="next">
<%= link_to_unless last_page?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote %>
</span>
9 changes: 9 additions & 0 deletions kaminari-core/app/views/kaminari/_page_after.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-# Link to the "Next" page for cursor-based pagination
-# available local variables
-# url: url to the previous page
-# first_page?: indicates there are no earlier results
-# last_page?: indicates there are no later results
-# per_page: number of items to fetch per page
-# remote: data-remote
%span.next
= link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote
10 changes: 10 additions & 0 deletions kaminari-core/app/views/kaminari/_page_after.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/ Link to the "Next" page for cursor-based pagination
- available local variables
url : url to the previous page
first_page?: indicates there are no earlier results
last_page?: indicates there are no later results
per_page : number of items to fetch per page
remote : data-remote
span.next
== link_to_unless last_page?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote
'
11 changes: 11 additions & 0 deletions kaminari-core/app/views/kaminari/_page_before.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<%# Link to the "Previous" page for cursor-based pagination
- available local variables
url: url to the previous page
first_page?: indicates there are no earlier results
last_page?: indicates there are no later results
per_page: number of items to fetch per page
remote: data-remote
-%>
<span class="prev">
<%= link_to_unless first_page?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote %>
</span>
9 changes: 9 additions & 0 deletions kaminari-core/app/views/kaminari/_page_before.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-# Link to the "Previous" page for cursor-based pagination
-# available local variables
-# url: url to the previous page
-# first_page?: indicates there are no earlier results
-# last_page?: indicates there are no later results
-# per_page: number of items to fetch per page
-# remote: data-remote
%span.prev
= link_to_unless first_page?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote
10 changes: 10 additions & 0 deletions kaminari-core/app/views/kaminari/_page_before.html.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/ Link to the "Previous" page for cursor-based pagination
- available local variables
url : url to the previous page
first_page?: indicates there are no earlier results
last_page?: indicates there are no later results
per_page : number of items to fetch per page
remote : data-remote
span.prev
== link_to_unless first_page?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote
'
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
# config.left = 0
# config.right = 0
# config.page_method_name = :page
# config.page_by_cursor_method_name = :page_by_cursor
# config.page_before_method_name = :page_before
# config.page_after_method_name = :page_after
# config.cursor_param_name = :cursor
# config.param_name = :page
# config.max_pages = nil
# config.params_on_first_page = false
Expand Down
7 changes: 7 additions & 0 deletions kaminari-core/lib/kaminari/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ def config

class Config
attr_accessor :default_per_page, :max_per_page, :window, :outer_window, :left, :right, :page_method_name, :max_pages, :params_on_first_page
attr_accessor :page_by_cursor_method_name, :page_before_method_name, :page_after_method_name, :cursor_param_name, :page_direction_attr_name
attr_writer :param_name
attr_accessor :before_cursor_param_name, :after_cursor_param_name

def initialize
@default_per_page = 25
Expand All @@ -27,7 +29,12 @@ def initialize
@left = 0
@right = 0
@page_method_name = :page
@page_by_cursor_method_name = :page_by_cursor
@page_before_method_name = :page_before
@page_after_method_name = :page_after
@param_name = :page
@cursor_param_name = :cursor
@page_direction_attr_name = :page_direction
@max_pages = nil
@params_on_first_page = false
end
Expand Down
1 change: 1 addition & 0 deletions kaminari-core/lib/kaminari/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module Kaminari
require 'kaminari/exceptions'
require 'kaminari/helpers/paginator'
require 'kaminari/models/page_scope_methods'
require 'kaminari/models/cursor_page_scope_methods'
require 'kaminari/models/configuration_methods'
require 'kaminari/models/array_extension'

Expand Down