Skip to content

Commit

Permalink
Add new TimestampCursor class
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.
  • Loading branch information
aaronsama committed Oct 5, 2023
1 parent 98eb980 commit e3bc0f7
Show file tree
Hide file tree
Showing 7 changed files with 424 additions and 268 deletions.
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
40 changes: 8 additions & 32 deletions lib/rails_cursor_pagination/cursor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,35 +44,18 @@ def decode(encoded_string:, order_field: :id)
end
new(id: decoded, order_field: :id)
else
decode_custom_order_field(encoded_string: encoded_string,
decoded: decoded, order_field: order_field)
unless decoded.is_a?(Array) && decoded.size == 2
raise InvalidCursorError,
"The given cursor `#{encoded_string}` was decoded as " \
"`#{decoded}` but could not be parsed"
end
new(id: decoded[1], order_field: order_field,
order_field_value: decoded[0])
end
rescue ArgumentError, JSON::ParserError
raise InvalidCursorError,
"The given cursor `#{encoded_string}` could not be decoded"
end

def decode_custom_order_field(encoded_string:, decoded:, order_field:)
unless decoded.is_a?(Array) && decoded.size == 2
raise InvalidCursorError,
"The given cursor `#{encoded_string}` was decoded as " \
"`#{decoded}` but could not be parsed"
end
if decoded[0].is_a?(Hash) && ['seconds', 'nanoseconds'].all? { |key| decoded[0].key? key }
new(
id: decoded[1],
order_field: order_field,
order_field_value: Time.at(
decoded[0]['seconds'],
decoded[0]['nanoseconds'],
:nsec
)
)
else
new(id: decoded[1], order_field: order_field,
order_field_value: decoded[0])
end
end
end

# Initializes the record
Expand Down Expand Up @@ -108,14 +91,7 @@ def initialize(id:, order_field: :id, order_field_value: nil)
def encode
unencoded_cursor =
if custom_order_field?
if @order_field_value.respond_to?(:strftime)
[{
seconds: order_field_value.to_i,
nanoseconds: order_field_value.nsec
}, @id]
else
[@order_field_value, @id]
end
[@order_field_value, @id]
else
@id
end
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
55 changes: 55 additions & 0 deletions lib/rails_cursor_pagination/timestamp_cursor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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`.
def decode(encoded_string:, order_field:)
decoded = JSON.parse(Base64.strict_decode64(encoded_string))

new(
id: decoded[1],
order_field: order_field,
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

# Encodes the cursor as an array containing the timestamp as microseconds
# from UNIX epoch and the id of the object
#
# @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
51 changes: 0 additions & 51 deletions spec/rails_cursor_pagination/cursor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,6 @@
expect(decoded.order_field_value).to eq record.author
end
end

context 'when ordering by created_at' do
subject(:encoded) do
described_class.from_record(record: record, order_field: :created_at).encode
end

it 'produces a valid string' do
expect(encoded).to be_a(String)
end

it 'can be decoded back to the originally encoded value' do
decoded = described_class.decode(encoded_string: encoded,
order_field: :created_at)
expect(decoded.id).to eq record.id
expect(decoded.order_field_value).to eq record.created_at
end
end
end

describe '.from_record' do
Expand Down Expand Up @@ -173,40 +156,6 @@
end
end

context 'when decoding an encoded message with order_field :created_at' do
let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' }
let(:encoded) do
described_class.from_record(record: record, order_field: :created_at).encode
end

context 'and the order_field to decode is set to :id' do
subject(:decoded) do
described_class.decode(encoded_string: encoded)
end

it 'raises an InvalidCursorError' do
cursor = [{"seconds"=> record.created_at.to_i, "nanoseconds"=> record.created_at.nsec}, record.id]
msg = %Q|The given cursor `#{encoded}` was decoded as `#{cursor}` but could not be parsed|
expect { decoded }.to raise_error(
::RailsCursorPagination::InvalidCursorError,
msg
)
end
end

context 'and the order_field to decode is set to :created_at' do
subject(:decoded) do
described_class.decode(encoded_string: encoded, order_field: :created_at)
end

it 'decodes the string succesfully' do
expect(decoded.id).to eq record.id
expect(decoded.order_field_value).to eq record.created_at
expect(decoded.order_field_value.nsec).to eq record.created_at.nsec
end
end
end

context 'when decoding a message that did not come from a known encoder' do
let(:encoded) { 'SomeGarbageString' }

Expand Down
Loading

0 comments on commit e3bc0f7

Please sign in to comment.