Skip to content

Commit

Permalink
Improve support for sorting by a timestamp column (#142)
Browse files Browse the repository at this point in the history
Add a new subclass of the `Cursor` class to encode/decode cursors using
a timestamp column (such as `created_at`) as order field. It encodes
the value of the field as a number with microsecond resolution (the
default for MySQL).

This cursor class is instantiated instead of the regular `Cursor` when
an `order_field` of a compatible type is used to configure the
paginator.

Co-authored-by: Amanda Raymond <[email protected]>
Co-authored-by: Nicolas Fricke <[email protected]>
  • Loading branch information
3 people authored Oct 6, 2023
1 parent ec40977 commit 30b5ace
Show file tree
Hide file tree
Showing 9 changed files with 1,355 additions and 382 deletions.
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Metrics/BlockLength:
- spec/**/*

Metrics/ClassLength:
Max: 198
Max: 210

Metrics/CyclomaticComplexity:
Max: 15
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ These are the latest changes on the project's `master` branch that have not yet
### Added
- Test against Ruby version 3.2

### Fixed
- Ensure timestamp `order_by` fields will have expected paginated results by honoring of timestamps down to microsecond resolution on comparison.

## [0.3.0] - 2022-07-08

### Added
Expand Down
27 changes: 19 additions & 8 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,36 @@ PATH
GEM
remote: https://rubygems.org/
specs:
activemodel (6.1.7.6)
activesupport (= 6.1.7.6)
activerecord (6.1.7.6)
activemodel (= 6.1.7.6)
activesupport (= 6.1.7.6)
activesupport (6.1.7.6)
activemodel (7.1.0)
activesupport (= 7.1.0)
activerecord (7.1.0)
activemodel (= 7.1.0)
activesupport (= 7.1.0)
timeout (>= 0.4.0)
activesupport (7.1.0)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
ast (2.4.2)
base64 (0.1.1)
bigdecimal (3.1.4)
concurrent-ruby (1.2.2)
connection_pool (2.4.1)
diff-lcs (1.5.0)
drb (2.1.1)
ruby2_keywords
i18n (1.14.1)
concurrent-ruby (~> 1.0)
json (2.6.3)
language_server-protocol (3.17.0.3)
minitest (5.19.0)
mutex_m (0.1.2)
mysql2 (0.5.5)
parallel (1.23.0)
parser (3.2.2.3)
Expand Down Expand Up @@ -65,10 +75,11 @@ GEM
rubocop-ast (1.29.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
timeout (0.4.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.4.2)
zeitwerk (2.6.11)

PLATFORMS
ruby
Expand Down
2 changes: 2 additions & 0 deletions lib/rails_cursor_pagination.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ class InvalidCursorError < ParameterError; end

require_relative 'rails_cursor_pagination/cursor'

require_relative 'rails_cursor_pagination/timestamp_cursor'

class << self
# Allows to configure this gem. Currently supported configuration values
# are:
Expand Down
22 changes: 20 additions & 2 deletions lib/rails_cursor_pagination/paginator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ def filter_value
# @param record [ActiveRecord] Model instance for which we want the cursor
# @return [String]
def cursor_for_record(record)
Cursor.from_record(record: record, order_field: @order_field).encode
cursor_class.from_record(record: record, order_field: @order_field).encode
end

# Decode the provided cursor. Either just returns the cursor's ID or in case
Expand All @@ -375,7 +375,25 @@ def cursor_for_record(record)
# @return [Integer, Array]
def decoded_cursor
memoize(:decoded_cursor) do
Cursor.decode(encoded_string: @cursor, order_field: @order_field)
cursor_class.decode(encoded_string: @cursor, order_field: @order_field)
end
end

# Returns the appropriate class for the cursor based on the SQL type of the
# column used for ordering the relation.
#
# @return [Class<RailsCursorPagination::Cursor>]
def cursor_class
order_field_type = @relation
.column_for_attribute(@order_field)
.sql_type_metadata
.type

case order_field_type
when :datetime
TimestampCursor
else
Cursor
end
end

Expand Down
84 changes: 84 additions & 0 deletions lib/rails_cursor_pagination/timestamp_cursor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

module RailsCursorPagination
# Cursor class that's used to uniquely identify a record and serialize and
# deserialize this cursor so that it can be used for pagination.
# This class expects the `order_field` of the record to be a timestamp and is
# to be used only when sorting a
class TimestampCursor < Cursor
class << self
# Decode the provided encoded cursor. Returns an instance of this
# `RailsCursorPagination::Cursor` class containing both the ID and the
# ordering field value. The ordering field is expected to be a timestamp
# and is always decoded in the UTC timezone.
#
# @param encoded_string [String]
# The encoded cursor
# @param order_field [Symbol]
# The column that is being ordered on. It needs to be a timestamp of a
# class that responds to `#strftime`.
# @raise [RailsCursorPagination::InvalidCursorError]
# In case the given `encoded_string` cannot be decoded properly
# @return [RailsCursorPagination::TimestampCursor]
# Instance of this class with a properly decoded timestamp cursor
def decode(encoded_string:, order_field:)
decoded = JSON.parse(Base64.strict_decode64(encoded_string))

new(
id: decoded[1],
order_field: order_field,
# Turn the order field value into a `Time` instance in UTC. A Rational
# number allows us to represent fractions of seconds, including the
# microseconds. In this way we can preserve the order of items with a
# microsecond precision.
# This also allows us to keep the size of the cursor small by using
# just a number instead of having to pass seconds and the fraction of
# seconds separately.
order_field_value: Time.at(decoded[0].to_r / (10**6)).utc
)
rescue ArgumentError, JSON::ParserError
raise InvalidCursorError,
"The given cursor `#{encoded_string}` " \
'could not be decoded to a timestamp'
end
end

# Initializes the record. Overrides `Cursor`'s initializer making all params
# mandatory.
#
# @param id [Integer]
# The ID of the cursor record
# @param order_field [Symbol]
# The column or virtual column for ordering
# @param order_field_value [Object]
# The value that the +order_field+ of the record contains
def initialize(id:, order_field:, order_field_value:)
super id: id,
order_field: order_field,
order_field_value: order_field_value
end

# Encodes the cursor as an array containing the timestamp as microseconds
# from UNIX epoch and the id of the object
#
# @raise [RailsCursorPagination::ParameterError]
# The order field value needs to respond to `#strftime` to use the
# `TimestampCursor` class. Otherwise, a `ParameterError` is raised.
# @return [String]
def encode
unless @order_field_value.respond_to?(:strftime)
raise ParameterError,
"Could not encode #{@order_field} " \
"with value #{@order_field_value}." \
'It does not respond to #strftime. Is it a timestamp?'
end

Base64.strict_encode64(
[
@order_field_value.strftime('%s%6N').to_i,
@id
].to_json
)
end
end
end
Loading

0 comments on commit 30b5ace

Please sign in to comment.