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

Conversation

khuston
Copy link

@khuston khuston commented Mar 31, 2023

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

users = User.order(:id)

# Get cursor at end of first page
#   [ 1 2 3 4 5 ] 6 7 8 ...
next = users.page_by_cursor.end_cursor

# Get next page
#   1 2 3 4 5 [ 6 7 8 ...
next_page = users.page_by_cursor(next)
prev = next_page.start_cursor

# Destroy 3 users from first page
#         4 5 [ 6 7 8 ...
User.destroy(1)
User.destroy(2)
User.destroy(3)

# Get previous page (now with fewer records)
#   [      4 5 ] 6 7 8 ...
users.page_by_cursor(prev)

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) and Book.order(title: :desc)will result in title asc, id asc and title 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:

{
    "columns": [
        {
            "name": <column name>,
            "value": <column value>,
            "dir": <asc|desc>
        }
    ],
    "page_direction": "after"
}

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.

{
    "title": "clean code",
    "id": 9102,
    "page_direction": "after"
}

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 and page_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 and is not null applied according to whether nulls are treated as small or large in the database. Additionally, the Postgresql order by clause optionally includes nulls first or nulls last which overrides the default ordering. This too is handled.

An extra 'peekback' relation is created and queried to support the first_page? and last_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 in order_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 which read_attribute_before_type_cast returns a String. However, for Postgresql, read_attribute_before_type_cast returns a Time 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant