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

Improve support for sorting by a timestamp column #142

Merged
merged 5 commits into from
Oct 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
aaronsama marked this conversation as resolved.
Show resolved Hide resolved
)
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
Loading