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

Gives the previous cursor in the scroll block #38

Merged
merged 15 commits into from
Aug 29, 2024
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### 1.0.2 (Next)
### 1.1.0 (Next)

* [#38](https://github.com/mongoid/mongoid-scroll/pull/38): Allow to reverse the scroll - [@GCorbel](https://github.com/GCorbel).
* Your contribution here.
dblock marked this conversation as resolved.
Show resolved Hide resolved

### 1.0.1 (2023/03/15)
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,16 @@ Feed::Item.desc(:position).limit(5).scroll do |record, next_cursor|
end
```

Resume iterating using the previously saved cursor.
Resume iterating using saved cursor and save the cursor to go backward.

```ruby
Feed::Item.desc(:position).limit(5).scroll(saved_cursor) do |record, _, previous_cursor|
# each record, one-by-one
saved_cursor = previous_cursor
end
dblock marked this conversation as resolved.
Show resolved Hide resolved
```

Loop over the first records again.

```ruby
Feed::Item.desc(:position).limit(5).scroll(saved_cursor) do |record, next_cursor|
Expand Down Expand Up @@ -179,7 +188,7 @@ Feed::Item.desc(:created_at).scroll(cursor) # Raises a Mongoid::Scroll::Errors::

### Standard Cursor

The `Mongoid::Scroll::Cursor` encodes a value and a tiebreak ID separated by `:`, and does not include other options, such as scroll direction. Take extra care not to pass a cursor into a scroll with different options.
The `Mongoid::Scroll::Cursor` encodes a value and a tiebreak ID separated by `:`, and does not include other options, such as scroll direction. Take extra care not to pass a cursor into a scroll with different options.

### Base64 Encoded Cursor

Expand Down
38 changes: 27 additions & 11 deletions lib/mongo/scrollable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,37 @@ def scroll(cursor_or_type = nil, options = nil, &_block)
cursor_options = { field_name: scroll_field, direction: scroll_direction }.merge(options)
cursor = cursor && cursor.is_a?(cursor_type) ? cursor : cursor_type.new(cursor, cursor_options)
raise_mismatched_sort_fields_error!(cursor, cursor_options) if different_sort_fields?(cursor, cursor_options)
# make a view
view = Mongo::Collection::View.new(
view.collection,
view.selector.merge(cursor.criteria),
sort: (view.sort || {}).merge(_id: scroll_direction),
skip: skip,
limit: limit
)

records = nil
if cursor.previous && limit
# scroll backwards by reversing the sort order, limit and then reverse again
pipeline = [
{ '$match' => view.selector.merge(cursor.criteria) },
{ '$sort' => { scroll_field => -scroll_direction } },
{ '$limit' => limit },
{ '$sort' => { scroll_field => scroll_direction } }
]
aggregation_options = view.options.except(:sort)
records = view.aggregate(pipeline, aggregation_options)
else
# make a view
records = Mongo::Collection::View.new(
view.collection,
view.selector.merge(cursor.criteria),
sort: (view.sort || {}).merge(_id: scroll_direction),
skip: skip,
limit: limit
)
end
# scroll
if block_given?
view.each do |record|
yield record, cursor_type.from_record(record, cursor_options)
previous_cursor = nil
records.each do |record|
previous_cursor ||= cursor_type.from_record(record, cursor_options.merge(previous: true))
yield record, cursor_type.from_record(record, cursor_options), previous_cursor
end
else
view
records
end
end
end
Expand Down
25 changes: 19 additions & 6 deletions lib/mongoid/criteria/scrollable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ def scroll(cursor_or_type = nil, &_block)
cursor_options = build_cursor_options(criteria)
cursor = cursor.is_a?(cursor_type) ? cursor : new_cursor(cursor_type, cursor, cursor_options)
raise_mismatched_sort_fields_error!(cursor, cursor_options) if different_sort_fields?(cursor, cursor_options)
cursor_criteria = build_cursor_criteria(criteria, cursor)
records = find_records(criteria, cursor)
if block_given?
cursor_criteria.order_by(_id: scroll_direction(criteria)).each do |record|
yield record, cursor_from_record(cursor_type, record, cursor_options)
previous_cursor = nil
records.each do |record|
previous_cursor ||= cursor_from_record(cursor_type, record, cursor_options.merge(previous: true))
yield record, cursor_from_record(cursor_type, record, cursor_options), previous_cursor
end
else
cursor_criteria
records
end
end

Expand Down Expand Up @@ -60,10 +62,21 @@ def new_cursor(cursor_type, cursor, cursor_options)
cursor_type.new(cursor, cursor_options)
end

def build_cursor_criteria(criteria, cursor)
def find_records(criteria, cursor)
cursor_criteria = criteria.dup
cursor_criteria.selector = { '$and' => [criteria.selector, cursor.criteria] }
cursor_criteria
if cursor.previous && criteria.options[:limit]
pipeline = [
{ '$match' => cursor_criteria.selector },
{ '$sort' => { cursor.field_name => -cursor.direction } },
{ '$limit' => criteria.options[:limit] },
{ '$sort' => { cursor.field_name => cursor.direction } }
]
aggregation = cursor_criteria.view.aggregate(pipeline)
aggregation.map { |record| Mongoid::Factory.from_db(cursor_criteria.klass, record) }
else
cursor_criteria.order_by(_id: scroll_direction(criteria))
end
end

def cursor_from_record(cursor_type, record, cursor_options)
Expand Down
6 changes: 4 additions & 2 deletions lib/mongoid/scroll/base64_encoded_cursor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def initialize(value, options = {})
field_name: parsed['field_name'],
direction: parsed['direction'],
include_current: parsed['include_current'],
tiebreak_id: parsed['tiebreak_id'] && !parsed['tiebreak_id'].empty? ? BSON::ObjectId.from_string(parsed['tiebreak_id']) : nil
tiebreak_id: parsed['tiebreak_id'] && !parsed['tiebreak_id'].empty? ? BSON::ObjectId.from_string(parsed['tiebreak_id']) : nil,
previous: parsed['previous']
}
else
super nil, options
Expand All @@ -32,7 +33,8 @@ def to_s
field_name: field_name,
direction: direction,
include_current: include_current,
tiebreak_id: tiebreak_id && tiebreak_id.to_s
tiebreak_id: tiebreak_id && tiebreak_id.to_s,
previous: previous
}.to_json)
end
end
Expand Down
12 changes: 8 additions & 4 deletions lib/mongoid/scroll/base_cursor.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module Mongoid
module Scroll
class BaseCursor
attr_accessor :value, :tiebreak_id, :field_type, :field_name, :direction, :include_current
attr_accessor :value, :tiebreak_id, :field_type, :field_name, :direction, :include_current, :previous
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the naming of the previous flag.

Copy link
Collaborator

@dblock dblock Aug 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per my comment in #38 (comment), this could be an optional cursor itself, previous_cursor and it would be deserialized into a BaseCursor.


def initialize(value, options = {})
@value = value
Expand All @@ -10,6 +10,7 @@ def initialize(value, options = {})
@field_name = options[:field_name]
@direction = options[:direction] || 1
@include_current = options[:include_current] || false
@previous = options[:previous] || false
end

def criteria
Expand Down Expand Up @@ -86,20 +87,23 @@ def extract_field_options(options)
field_type: field_type.to_s,
field_name: field_name.to_s,
direction: options[:direction] || 1,
include_current: options[:include_current] || false
include_current: options[:include_current] || false,
previous: options[:previous] || false
}
elsif options && (field = options[:field])
{
field_type: field.type.to_s,
field_name: field.name.to_s,
direction: options[:direction] || 1,
include_current: options[:include_current] || false
include_current: options[:include_current] || false,
previous: options[:previous] || false
}
end
end

def compare_direction
direction == 1 ? '$gt' : '$lt'
dir = previous ? -direction : direction
dir == 1 ? '$gt' : '$lt'
end

def tiebreak_compare_direction
Expand Down
2 changes: 1 addition & 1 deletion lib/mongoid/scroll/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module Mongoid
module Scroll
VERSION = '1.0.2'.freeze
VERSION = '1.1.0'.freeze
end
end
22 changes: 22 additions & 0 deletions spec/mongo/collection_view_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,28 @@
expect(cursor.value).to eq record[field_name.to_s]
expect(cursor.tiebreak_id).to eq record['_id']
end
it 'can scroll back with the previous cursor' do
cursor = nil
first_previous_cursor = nil
second_previous_cursor = nil

Mongoid.default_client['feed_items'].find.sort(field_name => 1).limit(2).scroll(cursor_type, field_type: field_type) do |_, next_cursor|
cursor = next_cursor
end

Mongoid.default_client['feed_items'].find.sort(field_name => 1).limit(2).scroll(cursor, field_type: field_type) do |_, next_cursor, previous_cursor|
cursor = next_cursor
first_previous_cursor = previous_cursor
end

Mongoid.default_client['feed_items'].find.sort(field_name => 1).limit(2).scroll(cursor, field_type: field_type) do |_, _, previous_cursor|
second_previous_cursor = previous_cursor
end

records = Mongoid.default_client['feed_items'].find.sort(field_name => 1)
expect(Mongoid.default_client['feed_items'].find.sort(field_name => 1).limit(2).scroll(first_previous_cursor, field_type: field_type).to_a).to eq(records.limit(2).to_a)
expect(Mongoid.default_client['feed_items'].find.sort(field_name => 1).limit(2).scroll(second_previous_cursor, field_type: field_type).to_a).to eq(records.skip(2).limit(2).to_a)
end
end
end
end
Expand Down
38 changes: 31 additions & 7 deletions spec/mongoid/base64_encoded_cursor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
describe Mongoid::Scroll::Base64EncodedCursor do
context 'new' do
context 'an empty cursor' do
let(:base64_string) { 'eyJ2YWx1ZSI6bnVsbCwiZmllbGRfdHlwZSI6IlN0cmluZyIsImZpZWxkX25hbWUiOiJhX3N0cmluZyIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOm51bGx9' }
let(:base64_string) { 'eyJ2YWx1ZSI6bnVsbCwiZmllbGRfdHlwZSI6IlN0cmluZyIsImZpZWxkX25hbWUiOiJhX3N0cmluZyIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOm51bGwsInByZXZpb3VzIjpmYWxzZX0=' }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokens changed because previous: false is encoded.

subject do
Mongoid::Scroll::Base64EncodedCursor.new base64_string
end
its(:tiebreak_id) { should be_nil }
its(:value) { should be_nil }
its(:criteria) { should eq({}) }
its(:previous) { should be_falsy }
its(:to_s) { should eq(base64_string) }
end
context 'a string field cursor' do
let(:base64_string) { 'eyJ2YWx1ZSI6ImEgc3RyaW5nIiwiZmllbGRfdHlwZSI6IlN0cmluZyIsImZpZWxkX25hbWUiOiJhX3N0cmluZyIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOiI2NDA2M2RmODA5NDQzNDE3YzdkMmIxMDIifQ==' }
let(:base64_string) { 'eyJ2YWx1ZSI6ImEgc3RyaW5nIiwiZmllbGRfdHlwZSI6IlN0cmluZyIsImZpZWxkX25hbWUiOiJhX3N0cmluZyIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOiI2NDA2M2RmODA5NDQzNDE3YzdkMmIxMDIiLCJwcmV2aW91cyI6ZmFsc2V9' }
let(:a_value) { 'a string' }
let(:tiebreak_id) { BSON::ObjectId.from_string('64063df809443417c7d2b102') }
let(:criteria) do
Expand All @@ -32,10 +33,11 @@
its(:value) { should eq a_value }
its(:tiebreak_id) { should eq tiebreak_id }
its(:criteria) { should eq(criteria) }
its(:previous) { should be_falsy }
its(:to_s) { should eq(base64_string) }
end
context 'an id field cursor' do
let(:base64_string) { 'eyJ2YWx1ZSI6IjY0MDY0NTg0MDk0NDM0MjgxZmE3MWFiMiIsImZpZWxkX3R5cGUiOiJCU09OOjpPYmplY3RJZCIsImZpZWxkX25hbWUiOiJpZCIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOiI2NDA2NDU4NDA5NDQzNDI4MWZhNzFhYjIifQ==' }
let(:base64_string) { 'eyJ2YWx1ZSI6IjY0MDY0NTg0MDk0NDM0MjgxZmE3MWFiMiIsImZpZWxkX3R5cGUiOiJCU09OOjpPYmplY3RJZCIsImZpZWxkX25hbWUiOiJpZCIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOiI2NDA2NDU4NDA5NDQzNDI4MWZhNzFhYjIiLCJwcmV2aW91cyI6ZmFsc2V9' }
let(:a_value) { BSON::ObjectId('64064584094434281fa71ab2') }
let(:tiebreak_id) { a_value }
let(:criteria) do
Expand All @@ -52,10 +54,11 @@
its(:value) { should eq a_value }
its(:tiebreak_id) { should eq tiebreak_id }
its(:criteria) { should eq(criteria) }
its(:previous) { should be_falsy }
its(:to_s) { should eq(base64_string) }
end
context 'an integer field cursor' do
let(:base64_string) { 'eyJ2YWx1ZSI6MTAsImZpZWxkX3R5cGUiOiJJbnRlZ2VyIiwiZmllbGRfbmFtZSI6ImFfaW50ZWdlciIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOiI2NDA2M2RmODA5NDQzNDE3YzdkMmIxMDgifQ==' }
let(:base64_string) { 'eyJ2YWx1ZSI6MTAsImZpZWxkX3R5cGUiOiJJbnRlZ2VyIiwiZmllbGRfbmFtZSI6ImFfaW50ZWdlciIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOiI2NDA2M2RmODA5NDQzNDE3YzdkMmIxMDgiLCJwcmV2aW91cyI6ZmFsc2V9' }
let(:a_value) { 10 }
let(:tiebreak_id) { BSON::ObjectId('64063df809443417c7d2b108') }
let(:criteria) do
Expand All @@ -74,10 +77,11 @@
its(:value) { should eq a_value }
its(:tiebreak_id) { should eq tiebreak_id }
its(:criteria) { should eq(criteria) }
its(:previous) { should be_falsy }
its(:to_s) { should eq(base64_string) }
end
context 'a date/time field cursor' do
let(:base64_string) { 'eyJ2YWx1ZSI6MTM4NzU5MDEyMy4wLCJmaWVsZF90eXBlIjoiRGF0ZVRpbWUiLCJmaWVsZF9uYW1lIjoiYV9kYXRldGltZSIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOiI2NDA2NDNhNzA5NDQzNDIzOWYyZGJmODYifQ==' }
let(:base64_string) { 'eyJ2YWx1ZSI6MTM4NzU5MDEyMy4wLCJmaWVsZF90eXBlIjoiRGF0ZVRpbWUiLCJmaWVsZF9uYW1lIjoiYV9kYXRldGltZSIsImRpcmVjdGlvbiI6MSwiaW5jbHVkZV9jdXJyZW50IjpmYWxzZSwidGllYnJlYWtfaWQiOiI2NDA2NDNhNzA5NDQzNDIzOWYyZGJmODYiLCJwcmV2aW91cyI6ZmFsc2V9' }
let(:a_value) { DateTime.new(2013, 12, 21, 1, 42, 3, 'UTC') }
let(:tiebreak_id) { BSON::ObjectId('640643a7094434239f2dbf86') }
let(:criteria) do
Expand All @@ -94,10 +98,11 @@
its(:value) { should eq a_value }
its(:tiebreak_id) { should eq tiebreak_id }
its(:criteria) { should eq(criteria) }
its(:previous) { should be_falsy }
its(:to_s) { should eq(base64_string) }
end
context 'a date field cursor' do
let(:base64_string) { 'eyJ2YWx1ZSI6MTM4NzU4NDAwMCwiZmllbGRfdHlwZSI6IkRhdGUiLCJmaWVsZF9uYW1lIjoiYV9kYXRlIiwiZGlyZWN0aW9uIjoxLCJpbmNsdWRlX2N1cnJlbnQiOmZhbHNlLCJ0aWVicmVha19pZCI6IjY0MDY0MmM5MDk0NDM0MjEyYzRkNDQyMCJ9' }
let(:base64_string) { 'eyJ2YWx1ZSI6MTM4NzU4NDAwMCwiZmllbGRfdHlwZSI6IkRhdGUiLCJmaWVsZF9uYW1lIjoiYV9kYXRlIiwiZGlyZWN0aW9uIjoxLCJpbmNsdWRlX2N1cnJlbnQiOmZhbHNlLCJ0aWVicmVha19pZCI6IjY0MDY0MmM5MDk0NDM0MjEyYzRkNDQyMCIsInByZXZpb3VzIjpmYWxzZX0=' }
let(:tiebreak_id) { BSON::ObjectId('640642c9094434212c4d4420') }
let(:a_value) { Date.new(2013, 12, 21) }
let(:criteria) do
Expand All @@ -114,10 +119,11 @@
its(:value) { should eq a_value }
its(:tiebreak_id) { should eq tiebreak_id }
its(:criteria) { should eq(criteria) }
its(:previous) { should be_falsy }
its(:to_s) { should eq(base64_string) }
end
context 'a time field cursor' do
let(:base64_string) { 'eyJ2YWx1ZSI6MTM4NzYwNTcyMy4wLCJmaWVsZF90eXBlIjoiVGltZSIsImZpZWxkX25hbWUiOiJhX3RpbWUiLCJkaXJlY3Rpb24iOjEsImluY2x1ZGVfY3VycmVudCI6ZmFsc2UsInRpZWJyZWFrX2lkIjoiNjQwNjNkNGEwOTQ0MzQxNjZiZDA1M2VkIn0=' }
let(:base64_string) { 'eyJ2YWx1ZSI6MTM4NzYwNTcyMy4wLCJmaWVsZF90eXBlIjoiVGltZSIsImZpZWxkX25hbWUiOiJhX3RpbWUiLCJkaXJlY3Rpb24iOjEsImluY2x1ZGVfY3VycmVudCI6ZmFsc2UsInRpZWJyZWFrX2lkIjoiNjQwNjNkNGEwOTQ0MzQxNjZiZDA1M2VkIiwicHJldmlvdXMiOmZhbHNlfQ==' }
let(:item_id) { BSON::ObjectId('640636f209443407333b46d4') }
let(:a_value) { Time.new(2013, 12, 21, 6, 2, 3, '+00:00').utc }
let(:tiebreak_id) { BSON::ObjectId('64063d4a094434166bd053ed') }
Expand All @@ -136,6 +142,7 @@
its(:tiebreak_id) { tiebreak_id }
its(:tiebreak_id) { should eq tiebreak_id }
its(:criteria) { should eq(criteria) }
its(:previous) { should be_falsy }
its(:to_s) { should eq(base64_string) }
end
context 'an invalid field cursor' do
Expand Down Expand Up @@ -229,5 +236,22 @@
end.to raise_error Mongoid::Scroll::Errors::UnsupportedFieldTypeError, /The type of the field 'a_array' is not supported: Array./
end
end

it 'encode and decode previous option' do
feed_item = Feed::Item.create!
cursor = Mongoid::Scroll::Base64EncodedCursor.from_record feed_item, field_name: 'id', field_type: BSON::ObjectId, previous: true
expect(Mongoid::Scroll::Base64EncodedCursor.new(cursor.to_s).previous).to be_truthy
end
context 'a cursor with previous set to true' do
let(:field_type) { BSON::ObjectId }
let(:field_name) { 'id' }
let(:feed_item) { Feed::Item.create! }
subject do
Mongoid::Scroll::Base64EncodedCursor.from_record feed_item, field_name: field_name, field_type: field_type, previous: true
end
its(:value) { should eq feed_item._id }
its(:field_type) { should eq field_type.to_s }
its(:previous) { should be_truthy }
end
end
end
22 changes: 22 additions & 0 deletions spec/mongoid/criteria_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,28 @@
break
end
end
it 'can scroll back with the previous cursor' do
cursor = nil
first_previous_cursor = nil
second_previous_cursor = nil

Feed::Item.asc(field_name).limit(2).scroll(cursor_type) do |_, next_cursor|
cursor = next_cursor
end

Feed::Item.asc(field_name).limit(2).scroll(cursor) do |_, next_cursor, previous_cursor|
cursor = next_cursor
first_previous_cursor = previous_cursor
end

Feed::Item.asc(field_name).limit(2).scroll(cursor) do |_, _, previous_cursor|
second_previous_cursor = previous_cursor
end

records = Feed::Item.asc(field_name)
expect(Feed::Item.asc(field_name).limit(2).scroll(first_previous_cursor)).to eq(records.limit(2))
expect(Feed::Item.asc(field_name).limit(2).scroll(second_previous_cursor)).to eq(records.skip(2).limit(2))
end
end
end
end
Expand Down