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
khuston
wants to merge
19
commits into
kaminari:master
Choose a base branch
from
infotechinc:cursor-pagination
base: master
Could not load branches
Branch not found: {{ refName }}
Could not load tags
Nothing to show
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Cursor pagination #1101
Conversation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
This draft PR describes a sort of minimum viable contribution to add cursor-based pagination to Kaminari.
At present, it does satisfy a particular use case with cursor-based pagination of a sorted relation. Under the heading Shortcomings/Opportunities below, you will find some shortcomings that would need to be addressed before it satisfies a wider range of use cases. I welcome any feedback and guidance to make this a useful and maintainable addition to Kaminari.
Example usage
Shortcomings/Opportunities
Relation must ordered by columns of
.model
.Cursor pagination is only supported when the relation is ordered by columns of
.model
. For example, ordering by an association's columns is not presently supported.Views that support results whose bounds may frequently change are not well supported.
The primary use case has Next and Prev buttons that appear only if there are results in that direction. For example, the Prev button does not appear on the first page.
A contrary case would be a log viewer where logs are ordered by descending timestamp. In this case the Next button might be labeled "Load Newer", and the user would want the ability to click "Load Newer" even if there might not be any newer results now.
Paging by cursor can result in a non-full page at the beginning of results.
It is not surprising when the last page of search results is not full. However, it can be surprising to leave the first page, return to it, and find a page that is not full. Strictly only showing records before or after a cursor can result in a non-full page. It is possible when bumping up against the ends of the recordset in one direction to requery in the other direction to produce a full page. However, this is not presently supported.
Design decisions
The model's primary key is used as tie-breaker.
The cursor must be unique per record. To ensure this, the model's primary key is appended to the ordering columns.
When added by Kaminari, the primary key order is always ascending.
For example,
Book.order(title: :asc)
andBook.order(title: :desc)
will result intitle asc, id asc
andtitle desc, id asc
, respectively. The latter results are not exactly the reverse of the former results in cases where some books have the same title.If the primary key is explicitly included in the order, then the order will not be changed, e.g.
Book.order(title: :asc, id: :asc)
-->title asc, id asc
Book.order(title: :asc, id: :desc)
-->title asc, id desc
Cursor pagination only works on relations which are ordered by the columns of
.model
.To generate a cursor from results, the values used to order the relation must be available. In principle, any column used to order the relation can be included in the select statement. However, this implementation challenge was left for a later time.
Page direction is embedded in the cursor.
To allow applications to page forward and backward through records using a single method and a single cursor value, I embed the page direction in the cursor, as a property named
page_direction
by default.The cursor is encoded json.
I initially considered the following:
The use of a columns property allows for collision-free addition of more properties later. Using an array type captures the ordering of columns. The inclusion of the
dir
attribute allows Kaminari to enforce that a cursor used in one sort direction is only used for that sort direction.I decided that enforcing a cursor is only used in a particular sort direction was needless rigor. I decided that the columns property also did not need to be an array to capture column order. This led me to fall back on a simple object whose keys and values are the column names and column values, respectively, e.g.
The exception is
page_direction
, an attribute whose name can be reconfigured if the name is already taken by a column.The cursor uses base64 encoding.
This provides nominal obfuscation of the cursor. Any data used to sort the records will be present in the easily-decoded cursor. It would be possible for an application to encrypt cursors if needed.
Differentiated methods are available to explicitly page before or after.
Methods
page_before
andpage_after
are provided to page in a particular direction.The database-defined ordering of null-valued records is respected.
Null values do not have a natural ordering in SQL. RDBMS differ in whether they treat null as low or high in comparison. SQL of course also defines any comparison involving a null value as false. A simple cursor-based filter for non-nullable values could work merely by using
=
,<
, and>
operators to compare columns with cursor values. However, such a cursor pagination would omit records with a null value used for ordering.For this reason, null values have to be handled specially with
is null
andis not null
applied according to whether nulls are treated as small or large in the database. Additionally, the Postgresql order by clause optionally includesnulls first
ornulls last
which overrides the default ordering. This too is handled.An extra 'peekback' relation is created and queried to support the
first_page?
andlast_page?
properties.When paging forward through results, the visibility of the 'next' button is determined by peeking an extra record ahead. If paging backward through results, this extra record would inform the visibility of the 'previous' button. To know whether both 'next' and 'previous' should be visible, it is required to peek in the opposite direction starting at the cursor.
This 'peekback' relation is queried at the when the cursor-paginated relation is loaded.
The
total_count
method is not supported.Usage of Rails methods not part of the public API
order_values
Cursor-based pagination requires filtering based on the relation's ordering. Introspection of the relation's ordering is required. I used the
order_values
attribute to get this ordering. The values inorder_values
include Arel nodes and strings, so interpretation of these into columns creates a dependency on Arel.Awareness of database for timestamp serialization
Serialized times in the cursor must be comparable with time-valued columns in the database query. The simplest solution would be to use the public API method
read_attribute_before_type_cast
to get the raw value directly from the database, which we would expect to include the correct precision. This works for SQLite, for whichread_attribute_before_type_cast
returns a String. However, for Postgresql,read_attribute_before_type_cast
returns aTime
object which is already deserialized.Postgresql and Mysql at present support up to microsecond precision in times. We serialize times with nanosecond precision using iso8601 format.
Awareness of database for null value ordering
As described above ("The database-defined ordering of null-valued records is respected."), Kaminari is aware of the database used for purpose of knowing whether null values are treated as low or high in ordering.