Skip to content

Commit

Permalink
add support precision of DateTime selectors when encoding/decoding cu…
Browse files Browse the repository at this point in the history
…rsors add spec coverage for timestamp order_by selector in pagination fetch

Bump version to 0.3.1
  • Loading branch information
amandawraymond committed Jan 27, 2023
1 parent 2b30d11 commit 76c0d62
Show file tree
Hide file tree
Showing 11 changed files with 1,169 additions and 426 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
ruby-version: ['2.6', '2.7', '3.0', '3.1']
ruby-version: ['2.7', '3.0', '3.1']
postgres-version: [14, 13, 12]

steps:
Expand Down
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ruby-2.7.5
2.7.4
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ These are the latest changes on the project's `master` branch that have not yet
Follow the same format as previous releases by categorizing your feature into "Added", "Changed", "Deprecated", "Removed", "Fixed", or "Security".
--->

## [0.3.1] - 2023-01-13

### Fixed
- Ensure DateTime order_by fields will have expected paginated results by honoring of timestamps down to nanosecond on comparison.

## [0.3.0] - 2022-07-08

### Added
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ gem 'rubocop', '~> 1.31'
gem 'mysql2', '~> 0.5'

gem 'pg', '~> 1.4'

46 changes: 22 additions & 24 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,38 +1,37 @@
PATH
remote: .
specs:
rails_cursor_pagination (0.3.0)
rails_cursor_pagination (0.3.1)
activerecord (>= 5.0)

GEM
remote: https://rubygems.org/
specs:
activemodel (6.1.6)
activesupport (= 6.1.6)
activerecord (6.1.6)
activemodel (= 6.1.6)
activesupport (= 6.1.6)
activesupport (6.1.6)
activemodel (7.0.4.2)
activesupport (= 7.0.4.2)
activerecord (7.0.4.2)
activemodel (= 7.0.4.2)
activesupport (= 7.0.4.2)
activesupport (7.0.4.2)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
ast (2.4.2)
concurrent-ruby (1.1.10)
concurrent-ruby (1.2.0)
diff-lcs (1.5.0)
i18n (1.10.0)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
json (2.6.2)
minitest (5.15.0)
mysql2 (0.5.4)
json (2.6.3)
minitest (5.17.0)
mysql2 (0.5.5)
parallel (1.22.1)
parser (3.1.2.0)
parser (3.2.0.0)
ast (~> 2.4.1)
pg (1.4.1)
rainbow (3.1.1)
rake (13.0.6)
regexp_parser (2.5.0)
regexp_parser (2.6.2)
rexml (3.2.5)
rspec (3.11.0)
rspec-core (~> 3.11.0)
Expand All @@ -47,23 +46,22 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-support (3.11.0)
rubocop (1.31.2)
rubocop (1.44.1)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.1.0.0)
parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.18.0, < 2.0)
rubocop-ast (>= 1.24.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.18.0)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.24.1)
parser (>= 3.1.1.0)
ruby-progressbar (1.11.0)
tzinfo (2.0.4)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
unicode-display_width (2.2.0)
zeitwerk (2.5.4)
unicode-display_width (2.4.2)

PLATFORMS
ruby
Expand All @@ -77,4 +75,4 @@ DEPENDENCIES
rubocop (~> 1.31)

BUNDLED WITH
2.1.4
2.2.3
13 changes: 5 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Of course, this can both be combined with `first`, `last`, `before`, and `after`
**Important:**
If your app regularly orders by another column, you might want to add a database index for this.
Say that your order column is `author` then you'll want to add a compound index on `(author, id)`.
If your table is called `posts` you can use a query like this in MySQL or Postgres:
If your table is called `posts` you can use a query like this in Postgres:
```sql
CREATE INDEX index_posts_on_author_and_id ON posts (author, id);
```
Expand All @@ -201,7 +201,7 @@ Imagine you would also store the post's `category` on the `posts` table (as a pl
And the category could be `pinned`, `announcement`, or `general`.
Then you might want to show all `pinned` posts first, followed by the `announcement` ones and lastly show the `general` posts.

In MySQL you could e.g. use a `FIELD(category, 'pinned', 'announcement', 'general')` query in the `ORDER BY` clause to achieve this.
In Postgres add categories in query of the `ORDER BY` clause to achieve this.
However, you cannot add an index to such a statement.
And therefore, the performance of this is – especially when using cursor pagination where we not only have an `ORDER BY` clause but also need it twice in the `WHERE` clauses – is pretty dismal.

Expand All @@ -215,7 +215,6 @@ This is, for now, out of scope of the functionality of this gem.

What is recommended if you _do_ need to order by more complex logic is to have a separate column that you only use for ordering.
You can use `ActiveRecord` hooks to automatically update this column whenever you change your data.
Or, for example in MySQL, you can also use a [generated column](https://dev.mysql.com/doc/refman/5.7/en/create-table-generated-columns.html) that is automatically being updated by the database based on some stored logic.

### Configuration options

Expand Down Expand Up @@ -414,12 +413,10 @@ We will get the records #5 and #2 as response.
When using a custom `order_by`, this affects both filtering as well as ordering.
Therefore, it is recommended to add an index for columns that are frequently used for ordering.
In our test case we would want to add a compound index for the `(author, id)` column combination.
Databases like MySQL and Postgres are able to then use the leftmost part of the index, in our case `author`, by its own _or_ can use it combined with the `id` index.
Databases like Postgres are able to then use the leftmost part of the index, in our case `author`, by its own _or_ can use it combined with the `id` index.

## Development

Make sure you have MySQL installed on your machine and create a database with the name `rails_cursor_pagination_testing`.

After checking out the repo, run `bin/setup` to install dependencies.
Then, run `rake spec` to run the tests.
You can also run `bin/console` for an interactive prompt that will allow you to experiment.
Expand All @@ -432,13 +429,13 @@ To release a new version, update the version number in `version.rb`, and then ru
This gem should run in any project that uses:
* Ruby
* `ActiveRecord`
* Postgres or MySQL
* Postgres

We aim to support all versions that are still actively maintained and extend support until one year past the version's EOL.
While we think it's important to stay up-to-date with versions and update as soon as an EOL is reached, we know that this is not always immediately possible.
This way, we hope to strike a balance between being usable by most projects without forcing them to upgrade, but also keeping the supported version combinations manageable.

This project is tested against different permutations of Ruby versions and DB versions, both Postgres and MySQL.
This project is tested against different permutations of Ruby versions and DB versions, both Postgres.
Please check the [test automation file under `./.github/workflows/test.yml`](.github/workflows/test.yml) to see all officially supported combinations.

## Contributing
Expand Down
40 changes: 32 additions & 8 deletions lib/rails_cursor_pagination/cursor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,35 @@ def decode(encoded_string:, order_field: :id)
end
new(id: decoded, order_field: :id)
else
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])
decode_custom_order_field(encoded_string: encoded_string,
decoded: decoded, order_field: order_field)
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 @@ -89,7 +106,14 @@ def initialize(id:, order_field: :id, order_field_value: nil)
def encode
unencoded_cursor =
if custom_order_field?
[@order_field_value, @id]
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
else
@id
end
Expand Down
4 changes: 2 additions & 2 deletions lib/rails_cursor_pagination/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module RailsCursorPagination
VERSION = '0.3.0'
end
VERSION = '0.3.1'
end
51 changes: 51 additions & 0 deletions spec/rails_cursor_pagination/cursor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@
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 @@ -156,6 +173,40 @@
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 76c0d62

Please sign in to comment.