0.04
A long-lived project that still receives updates
Mongoid extensions to enable infinite scroll.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Runtime

>= 0
>= 6.0
 Project Readme
  • Mongoid::Scroll
    • Compatibility
    • Demo
    • The Problem
    • Installation
    • Usage
    • Indexes and Performance
    • Cursors
      • Standard Cursor
      • Base64 Encoded Cursor
    • Contributing
    • Copyright and License

Mongoid::Scroll

Gem Version Build Status Coverage Status Code Climate

Mongoid extension that enables infinite scrolling for Mongoid::Criteria and Mongo::Collection::View.

Compatibility

This gem supports Mongoid 6, 7, 8 and 9.

Demo

Take a look at this example. Try with with bundle exec ruby examples/feed.rb.

The Problem

Traditional pagination does not work when data changes between paginated requests, which makes it unsuitable for infinite scroll behaviors.

  • If a record is inserted before the current page limit, items will shift right, and the next page will include a duplicate.
  • If a record is removed before the current page limit, items will shift left, and the next page will be missing a record.

The solution implemented by the scroll extension paginates data using a cursor, giving you the ability to restart pagination where you left it off. This is a non-trivial problem when combined with sorting over non-unique record fields, such as timestamps.

Installation

Add the gem to your Gemfile and run bundle install.

gem 'mongoid-scroll'

Usage

A sample model.

module Feed
  class Item
    include Mongoid::Document

    field :title, type: String
    field :position, type: Integer

    index({ position: 1, _id: 1 })
  end
end

Scroll by :position and save a cursor to the last item.

saved_iterator = nil

Feed::Item.desc(:position).limit(5).scroll do |record, iterator|
  # each record, one-by-one
  saved_iterator = iterator
end

Resume iterating using the saved cursor and save the cursor to go backwards.

Feed::Item.desc(:position).limit(5).scroll(saved_iterator.next_cursor) do |record, iterator|
  # each record, one-by-one
  saved_iterator = iterator
end

Loop over the first records again.

Feed::Item.desc(:position).limit(5).scroll(saved_iterator.previous_cursor) do |record, iterator|
  # each record, one-by-one
  saved_iterator = iterator
end

Use saved_iterator.first_cursor to loop over the first records or saved_iterator.current_cursor to loop over the same records again.

The iteration finishes when no more records are available. You can also finish iterating over the remaining records by omitting the query limit.

Feed::Item.desc(:position).limit(5).scroll(saved_iterator.next_cursor) do |record, iterator|
  # each record, one-by-one
  saved_iterator = iterator
end

Indexes and Performance

A query without a cursor is identical to a query without a scroll.

# db.feed_items.find().sort({ position: 1 }).limit(7)
Feed::Item.desc(:position).limit(7).scroll

Subsequent queries use an $or to avoid skipping items with the same value as the one at the current cursor position.

# db.feed_items.find({ "$or" : [
#   { "position" : { "$gt" : 13 }},
#   { "position" : 13, "_id": { "$gt" : ObjectId("511d7c7c3b5552c92400000e") }}
# ]}).sort({ position: 1 }).limit(7)
Feed:Item.desc(:position).limit(7).scroll(cursor)

This means you need to hit an index on position and _id.

# db.feed_items.ensureIndex({ position: 1, _id: 1 })

module Feed
  class Item
    ...
    index({ position: 1, _id: 1 })
  end
end

Cursors

You can use Mongoid::Scroll::Cursor.from_record to generate a cursor. A cursor points at the last record of the iteration and unlike MongoDB cursors will not expire.

record = Feed::Item.desc(:position).limit(3).last
cursor = Mongoid::Scroll::Cursor.from_record(record, { field: Feed::Item.fields["position"] })
# cursor or cursor.to_s can be returned to a client and passed into .scroll(cursor)

You can also a field_name and field_type instead of a Mongoid field.

cursor = Mongoid::Scroll::Cursor.from_record(record, { field_type: DateTime, field_name: "position" })

When the include_current option is set to true, the cursor will include the record it points to:

record = Feed::Item.desc(:position).limit(3).last
cursor = Mongoid::Scroll::Cursor.from_record(record, { field: Feed::Item.fields["position"], include_current: true })
Feed::Item.asc(:position).limit(1).scroll(cursor).first # record

If the field_name, field_type or direction options you specify when creating the cursor are different from the original criteria, a Mongoid::Scroll::Errors::MismatchedSortFieldsError will be raised.

cursor = Mongoid::Scroll::Cursor.from_record(record, { field_type: DateTime, field_name: "position" })
Feed::Item.desc(:created_at).scroll(cursor) # Raises a Mongoid::Scroll::Errors::MismatchedSortFieldsError

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.

Base64 Encoded Cursor

The Mongoid::Scroll::Base64EncodedCursor can be used instead of Mongoid::Scroll::Cursor to generate a base64-encoded string (using RFC 4648) containing all the information needed to rebuild a cursor.

Feed::Item.desc(:position).limit(5).scroll(Mongoid::Scroll::Base64EncodedCursor) do |record, iterator|
   # iterator.next_cursor is of type Mongoid::Scroll::Base64EncodedCursor
end

Contributing

Fork the project. Make your feature addition or bug fix with tests. Send a pull request. Bonus points for topic branches.

Copyright and License

MIT License, see LICENSE for details.

(c) 2013-2024 Daniel Doubrovkine, based on code by Frank Macreery, Artsy Inc.