{"id":23824951,"url":"https://github.com/jsonb-uy/rotulus","last_synced_at":"2026-02-02T23:47:48.412Z","repository":{"id":168544615,"uuid":"642149210","full_name":"jsonb-uy/rotulus","owner":"jsonb-uy","description":"Cursor-based Rails/ActiveRecord pagination with multi-column sorting and custom cursor token format support.","archived":false,"fork":false,"pushed_at":"2024-11-24T16:01:59.000Z","size":258,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-10-11T23:48:33.073Z","etag":null,"topics":["activerecord","cursor-pagination","keyset-pagination","rails","ruby"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jsonb-uy.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2023-05-17T23:50:57.000Z","updated_at":"2024-11-24T16:02:02.000Z","dependencies_parsed_at":null,"dependency_job_id":"7dff2e7e-dc03-4bd7-9c0f-87294390c072","html_url":"https://github.com/jsonb-uy/rotulus","commit_stats":null,"previous_names":["jsonb-uy/rotulus"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/jsonb-uy/rotulus","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jsonb-uy%2Frotulus","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jsonb-uy%2Frotulus/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jsonb-uy%2Frotulus/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jsonb-uy%2Frotulus/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jsonb-uy","download_url":"https://codeload.github.com/jsonb-uy/rotulus/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jsonb-uy%2Frotulus/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29024256,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-02T23:43:56.073Z","status":"ssl_error","status_checked_at":"2026-02-02T23:43:49.438Z","response_time":58,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activerecord","cursor-pagination","keyset-pagination","rails","ruby"],"created_at":"2025-01-02T11:16:09.848Z","updated_at":"2026-02-02T23:47:48.396Z","avatar_url":"https://github.com/jsonb-uy.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Rotulus\n\n[![Gem Version](https://badge.fury.io/rb/rotulus.svg)](https://badge.fury.io/rb/rotulus) [![CI](https://github.com/jsonb-uy/rotulus/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jsonb-uy/rotulus/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/jsonb-uy/rotulus/branch/main/graph/badge.svg?token=OKGOWP4SH9)](https://codecov.io/gh/jsonb-uy/rotulus) [![Maintainability](https://api.codeclimate.com/v1/badges/1df84f690220d9e5d260/maintainability)](https://codeclimate.com/github/jsonb-uy/rotulus/maintainability)\n\n### Cursor-based pagination for apps built on Rails/ActiveRecord \n\nCursor-based pagination is an alternative to OFFSET-based pagination that provides a more stable and predictable pagination behavior as records are added, updated, and removed in the database through the use of an encoded cursor token.\n\nSome advantages of this approach are:\n\n* Reduces inaccuracies such as duplicate or skipped records as records are being manipulated.\n* Can significantly improve performance(with proper DB indexing on ordered columns) especially as you move forward on large datasets. \n\n\n**TL;DR** See [ sample usage for Rails here ](#rails-usage). \n\n## Features\n\n* Paginate records sorted by any number of columns\n* Paginate records sorted by columns from joined tables\n* `NULLS FIRST`/`NULLS LAST` handling\n* Allows custom cursor format\n* Built-in cursor token expiration\n* Built-in cursor integrity checking\n* Supports **MySQL**, **PostgreSQL**, and **SQLite**\n* Supports **Rails 4.2** and above\n\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'rotulus'\n```\n\nAnd then execute:\n\n```sh\nbundle install\n```\n\nOr install it yourself as:\n\n```sh\ngem install rotulus\n```\n\n## Configuration\nSetting the environment variable `ROTULUS_SECRET` to a random string value(e.g. generate via `rails secret`) is the minimum required setup needed. \n\n\u003cdetails\u003e\n  \u003csummary\u003eMore configuration options\u003c/summary\u003e\n  \n#### Create an initializer `config/initializers/rotulus.rb`:\n\n  ```ruby\n  Rotulus.configure do |config|\n    config.page_default_limit = 5\n    config.page_max_limit = 50\n    config.secret = ENV[\"MY_ENV_VAR\"]\n    config.token_expires_in = 10800\n    config.cursor_class = MyCursor\n    config.restrict_order_change = false\n    config.restrict_query_change = false\n  end\n  ```\n\n| Configuration | Description |\n| ----------- | ----------- |\n| `page_default_limit` | **Default: 5** \u003cbr/\u003e Default record limit per page in case the `:limit` is not given when initializing a page `Rotulus::Page.new(...)` |\n| `page_max_limit` | **Default: 50** \u003cbr/\u003e Maximum `:limit` value allowed when initializing a page.|\n| `secret` | **Default: ENV['ROTULUS_SECRET']** \u003cbr/\u003e Key needed to generate the cursor state needed for cursor integrity checking. |\n| `token_expires_in` | **Default: 259200**(3 days) \u003cbr/\u003e Validity period of a cursor token (in seconds). Set to `nil` to disable token expiration. |\n| `restrict_order_change` | **Default: false** \u003cbr/\u003e When `true`, raise an `OrderChanged` error when paginating with a token that was generated from a page instance with a different `:order`. \u003cbr/\u003e When `false`, no error is raised and pagination is based on the new `:order` definition. |\n| `restrict_query_change` | **Default: false** \u003cbr/\u003e When `true`, raise a `QueryChanged` error when paginating with a token that was generated from a page instance with a different `:ar_relation` filter/query. \u003cbr/\u003e When `false`, no error is raised and pagination will query based on the new `:ar_relation`. |\n| `cursor_class` | **Default: Rotulus::Cursor** \u003cbr/\u003e Cursor class responsible for encoding/decoding cursor data. Default uses Base64 encoding. see [Custom Token Format](#custom-token-format). |\n  \u003cbr/\u003e\n\u003c/details\u003e\n\n\n## Usage\n\n### Basic Usage\n\n#### Initialize a page\n\n```ruby\nusers = User.where('age \u003e ?', 16)\n\npage = Rotulus::Page.new(users, order: { id: :asc })\n# OR just\npage = Rotulus::Page.new(users)\n```\n\n###### Example when sorting with multiple columns and `:limit`:\n\n```ruby\n\npage = Rotulus::Page.new(users, order: { first_name: :asc, last_name: :desc }, limit: 3)\n```\nThe gem will automatically add the table's PK(`users.id`) in the `ORDER BY` as the tie-breaker column to ensure stable sorting and pagination.\n\n\n#### Access the page records\n\n```ruby\npage.records\n=\u003e [#\u003cUser id: 11, first_name: 'John'...]\n```\n\n#### Check if a next page exists\n\n```ruby\npage.next?\n=\u003e true\n```\n#### Check if a previous page exists\n\n```ruby\npage.prev?\n=\u003e false\n```\n\n#### Get the cursor to access the next page\n```ruby\npage.next_token\n=\u003e \"eyI6ZiI6eyJebyI6...\"\n```\nIn case there is no next page, `nil` is returned\n\n#### Get the cursor to access the previous page\n```ruby\npage.prev_token\n=\u003e \"eyI6ZiI6eyJebyI6...\"\n```\nIn case there is no previous page(i.e., first page), `nil` is returned\n\n\n#### Navigate to the page given a cursor\n##### Return a new page instance pointed at the given cursor\n```ruby\nanother_page = page.at('eyI6ZiI6eyJebyI6...')\n=\u003e #\u003cRotulus::Page ..\u003e\n```\n\nOr to immediately get the records:\n\n```ruby\npage.at(next_page_token).records\n```\n\n##### Return the same page instance pointed at the given cursor\n```ruby\npage.at!('eyI6ZiI6eyJebyI6...')\n=\u003e #\u003cRotulus::Page ..\u003e\n```\n\n#### Get the next page\n```ruby\nnext_page = page.next\n```\nThis is the same as `page.at(page.next_token)`. Returns `nil` if there is no next page.\n\n#### Get the previous page\n```ruby\nprevious_page = page.prev\n```\nThis is the same as `page.at(page.prev_token)`. Returns `nil` if there is no previous page.\n\n\n### Extras\n#### Reload page\n```ruby\npage.reload\n\n# reload then return records\npage.reload.records\n```\n\n#### Cursor tokens hash\n```ruby\npage.links\n\n=\u003e { previous: \"eyI6ZiI6efQ...\", next: \"eyI6ZiI6eyJ....\"}\n```\nIf the token is `nil`, the corresponding key(previous/next) isn't included in the hash.\n\n#### Print page in table format for debugging\nCurrently, only the columns included in `ORDER BY` are shown:\n\n```ruby\nputs page.as_table\n\n+------------------------------------------------------------+\n|   users.first_name   |   users.last_name   |   users.id    |\n+------------------------------------------------------------+\n|        George        |       \u003cNULL\u003e        |      1        |\n|         Jane         |        Smith        |      3        |\n|         Jane         |         Doe         |      2        |\n+------------------------------------------------------------+\n```\n\n\u003cbr/\u003e\n\n### Advanced Usage\n#### Expanded order definition\nInstead of just specifying the column sorting such as ```{ first_name: :asc }``` in the :order param, one can use the expanded order config in `Hash` format for more sorting options that would help the library to generate the optimal query: \n\n| Column Configuration | Description |\n| ----------- | ----------- |\n| `direction` | **Default: :asc**. `:asc` or `:desc` |\n| `nullable`  | **Default: true** if the column is defined as nullable in its table, _false_ otherwise. \u003cbr/\u003e\u003cbr /\u003eWhether a null value is expected for this column in the result set. \u003cbr /\u003e\u003cbr/\u003e**Note:** \u003cbr/\u003e- Not setting this to _true_ when there are possible rows with NULL values for the specific column in the DB won't return those records. \u003cbr/\u003e - In queries with table (outer)`JOIN`s, a column in the result could have a NULL value even if the column doesn't allow nulls in its table. So set `nullable` to _true_ for such cases.\n| `nulls` | **Default:**\u003cbr/\u003e- MySQL and SQLite: `:first` if `direction` is `:asc`, otherwise `:last`\u003cbr/\u003e- PostgreSQL:  `:last` if `direction` is `:asc`, otherwise `:first`\u003cbr/\u003e\u003cbr/\u003eTells whether rows with NULL column values comes before/after the records with non-null values. Applicable only if column is `nullable`. |\n| `distinct` | **Default: true** if the column is the primary key of its table, _false_ otherwise.\u003cbr/\u003e\u003cbr /\u003e Tells whether rows in the result are expected to have unique values for this column. \u003cbr/\u003e\u003cbr /\u003e**Note:**\u003cbr/\u003e- In queries with table `JOIN`s, multiple rows could have the same column value even if the column has a unique index in its table. So set `distinct` to false for such cases.  |\n| `model` | **Default:**\u003cbr/\u003e - the model of the base AR relation passed to `Rotulus::Page.new(\u003car_relation\u003e)` if column name has no prefix(e.g. `first_name`) and the AR relation model has a column matching the column name.\u003cbr/\u003e- the model of the base AR relation passed to `Rotulus::Page.new(\u003car_relation\u003e)` if column name has a prefix(e.g. `users.first_name`) and the prefix matches the AR relation's table name and the table has a column matching the column name. \u003cbr/\u003e\u003cbr/\u003eModel where this column belongs. This allows the gem to infer the nullability and uniqueness from the column definition in its table instead of manually setting the `nullable` or `distinct` options and to also automatically prefix the column name with the table name. \u003cbr/\u003e|\n\n\n##### Example:\n\n```ruby\norder = {\n  first_name: :asc,\n  last_name: {\n    direction: :desc,\n    nullable: true,\n    nulls: :last\n  },\n  email: {\n    distinct: true\n  }\n}\npage = Rotulus::Page.new(users, order: order, limit: 3)\n\n```\n\u003cbr/\u003e\n\n#### Queries with `JOIN`ed tables\n##### Example:\n\nSuppose the requirement is to:\u003cbr/\u003e\n1. Get all `Item` records.\u003cbr/\u003e\n2. If an `Item` record has associated `OrderItem` records, get the order ids.\u003cbr/\u003e\n3. `Item` records with `OrderItem`s should come first.\n4. `Item` records with `OrderItem`s should be sorted by `item_count` in descending order. \u003cbr/\u003e\n5. If multiple rows have the same `item_count` value, sort them by item name in ascending order. \u003cbr/\u003e\n6. If multiple rows have the same `item_count` value and the same `name`, sort them by `OrderItem` id. \u003cbr/\u003e\n7. Sort `Item` records with no `OrderItem`, based on the item name in ascending order (tie-breaker). \u003cbr/\u003e\n8. Sort `Item` records with no `OrderItem` and having the same name by the item id (also tie-breaker).\n\n##### Our solution would be:\n\n```ruby\nitems = Item.all      # Requirement 1\n            .joins(\"LEFT JOIN order_items oi ON oi.item_id = items.id\")  # Requirement 2\n            .select('oi.order_id', 'items.*')                            # Requirement 2\n\norder_by = { \n  'oi.item_count' =\u003e { \n    direction: :desc,        # Requirement 4\n    nulls: :last,            # Requirement 3\n    nullable: true,          # Requirement 1\n    model: OrderItem \n  }, \n  name: :asc,                  # Requirement 5, 7\n  'oi.id' =\u003e {\n    direction: :asc,         # Requirement 6\n    distinct: true,          # Requirement 6\n    nullable: true,          # Requirement 1\n    model: OrderItem\n  },\n  id: :asc                    # Requirement 8\n}\npage = Rotulus::Page.new(items, order: order_by, limit: 2)\n\n```\n\nSome notes for the example above: \u003cbr/\u003e\n1. `oi.id` is needed to uniquely identify and serve as the tie-breaker for `Item`s that have `OrderItem`s having the same item_count and name.  The combination of `oi.item_count`, `items.name`, and `oi.id` makes those records unique in the dataset. \u003cbr/\u003e\n2. `id` is translated to `items.id` and is needed to uniquely identify and serve as the tie-breaker for `Item`s that have NO `OrderItem`s. The combination of `oi.item_count`(NULL), `items.name`, `oi.id`(NULL), and `items.id` makes those record unique in the dataset. Although, this can be removed in the configuration above as the `Item` table's primary key will be automatically added as the last `ORDER BY` column if it isn't included yet.\u003cbr/\u003e\n3. Explicitly setting the `model: OrderItem` in joined table columns is required for now.  \n\nAn alternate solution that would also avoid N+1 if the `OrderItem` instances are to be accessed:\n\n```ruby\nitems = Item.all                       # Requirement 1\n            .eager_load(:order_items)  # Requirement 2\n\norder_by = { \n  item_count: { \n    direction: :desc,        # Requirement 4\n    nulls: :last,            # Requirement 3\n    nullable: true,          # Requirement 1\n    model: OrderItem \n  }, \n  name: :asc,                # Requirement 5, 7\n  'order_items.id' =\u003e {\n    direction: :asc,         # Requirement 6\n    distinct: true,          # Requirement 6\n    nullable: true,          # Requirement 1\n    model: OrderItem\n  }\n}\npage = Rotulus::Page.new(items, order: order_by, limit: 2)\n\n```\n\n### Rails Usage\n\n##### Controller example 1:\n\n```ruby\ndef index\n  page = Rotulus::Page.new(User.all, order: index_order, limit: params.dig(:page, :limit))\n                      .at!(params[:cursor]) \n  render json: { data: page.records }.merge!(page.links)      # `page.links` contain the `cursor` value for next/prev pages.               \nend\n\nprivate\n\ndef index_order\n  { first_name: :asc, \n    last_name: { direction: :desc, nulls: :last },\n    email: { direction: :asc, distinct: true } }\nend\n```\n\nAPIs usually allow clients to specify which columns to sort through a parameter. You may use the [sort_param](https://rubygems.org/gems/sort_param) gem to support this:\n\n##### Controller example 2:\n\n```ruby\ndef index\n  page = Rotulus::Page.new(User.all, order: index_order, limit: params.dig(:page, :limit))\n                      .at!(params[:cursor])\n  render json: { data: page.records }.merge!(page.links)                     \nend\n\nprivate\n\n# Allow clients to sort by first_name, last_name, and/or email.\n# example sort values:\n# a. params[:sort] = +last_name,-email\n# b. params[:sort] = -first_name\ndef index_order\n  SortParam.define do\n    field 'first_name'\n    field 'last_name', nulls: :last, nullable: true\n    field 'email', distinct: true\n  end.load!(params[:sort].presence || 'first_name')\nend\n```\n\n### Errors\n\n| Class | Description |\n| ----------- | ----------- |\n| `Rotulus::InvalidCursor` | Cursor token received is invalid e.g., unrecognized token, token data has been tampered/updated. |\n| `Rotulus::Expired` | Cursor token received has expired based on the configured `token_expires_in` |\n| `Rotulus::InvalidLimit` | Limit set to Rotulus::Page is not valid. e.g., exceeds the configured limit. see `config.page_max_limit` |\n| `Rotulus::CursorError` | Generic error for cursor related validations |\n| `Rotulus::InvalidColumn` | Column provided in the :order param can't be found. |\n| `Rotulus::MissingTiebreaker` | There is no non-nullable and distinct column in the configured order definition. |\n| `Rotulus::ConfigurationError` | Generic error for missing/invalid configuration. |\n| `Rotulus::OrderChanged` | Raised when passing a token to `Page#at` or `Page#at!` methods of a page instance, and the token was generated from a page instance with a different `:order` definition. Can be enabled by setting the `restrict_order_change` to true. |\n| `Rotulus::QueryChanged` | Raised when passing a token to `Page#at` or `Page#at!` methods of a page instance, and the token was generated from a page instance with a different `:ar_relation` filter/query. Can be enabled by setting the `restrict_query_change` to true. |\n\n## How it works\nCursor-based pagination uses a reference record to fetch the relative set of previous or next records. This gem takes care of the SQL query and cursor generation needed for the pagination. To ensure that the pagination results are stable, it requires that:\n\n* Records are sorted (`ORDER BY`) by columns.\n* In case multiple records with the same column value(s) exists in the result, a unique non-nullable column is needed as tie-breaker. Usually, the table PK suffices for this but for complex queries(e.g. with table joins and with nullable columns, etc.), combining and using multiple columns that would uniquely identify the row in the result is needed.\n* Columns used in `ORDER BY` would need to be indexed as they will be used in filtering.\n\n\n#### Sample SQL-generated snippets to fetch the next set of records\n\n##### Example 1: With order by `id` only\n###### Ruby\n```ruby\npage = Rotulus::Page.new(User.all, limit: 3)\n```\n\n###### SQL:\n```sql\nWHERE \n  users.id \u003e ?\nORDER BY\n  users.id asc LIMIT 3\n```\n\n##### Example 2: With non-distinct and not nullable column `first_name`\n###### Ruby\n```ruby\npage = Rotulus::Page.new(User.all, order: { first_name: :asc }, limit: 3)\n```\n\n###### SQL:\n```sql\nWHERE\n  users.first_name \u003e= ? AND\n  (users.first_name \u003e ? OR\n    (users.first_name = ? AND\n     users.id \u003e ?))\nORDER BY\n  users.first_name asc,\n  users.id asc LIMIT 3\n```\n\n##### Example 3: With non-distinct and nullable(nulls last) column `last_name`\n###### Ruby\n```ruby\npage = Rotulus::Page.new(User.all, order: { first_name: { direction: :asc, nulls: :last }}, limit: 3)\n```\n\n###### SQL:\n```sql\n-- if last_name value of the current page's last record  is not null:\nWHERE ((users.last_name \u003e= ? OR users.last_name IS NULL) AND\n  ((users.last_name \u003e ? OR users.last_name IS NULL) \n  OR (users.last_name = ? AND users.id \u003e ?)))\nORDER BY users.last_name asc nulls last, users.id asc LIMIT 3\n\n-- if last_name value of the current page's last record is null:\nWHERE users.last_name IS NULL AND users.id \u003e ?\nORDER BY users.last_name asc nulls last, users.id asc LIMIT 3\n```\n\n\n### Cursor\nTo navigate between pages, a cursor is used. The cursor token is a Base64 encoded string containing the data on how to filter the next/previous page's records. A decoded cursor to access the next page would look like:\n\n#### Decoded Cursor\n\n```json\n{\n  \"f\": { \"users.first_name\": \"Jane\", \"users.id\": 2 }, \n  \"d\": \"next\",\n  \"c\": 1672502400,\n  \"cs\": \"fe6ac1a1d6a1fc1b7f842b388639f63b\",\n  \"os\": \"62186497a8073f9c7072389b73c6c60c\",\n  \"qs\": \"7a5053198709df924dd5ec1752ee4e6b\"\n}\n```\n1. `f` - contains the record values from the last record of the current page. Only the columns included in the `ORDER BY` are included. Note also that the unique column `users.id` is included as a tie-breaker.\n2. `d` - the pagination direction. `next` or `prev` set of records from the reference values in \"f\".\n3. `cs` - the cursor state needed for integrity checking, restricting clients/third-parties from generating their own (unsafe)tokens, or from tampering with the data of an existing token. \n4. `os` - the order state needed to detect whether the order definition changed.\n5. `qs` - the base AR relation state needed to detect whether the ar_relation has changed (e.g. filter/query changed due to API params). \n4. `c` -  cursor token issuance time.\n\nA condition generated from the cursor above would look like this:\n\n```sql\nWHERE users.first_name \u003e= 'Jane' AND (\n  users.first_name \u003e 'Jane' OR (\n    users.first_name = 'Jane' AND (users.id \u003e 2)\n  )\n) LIMIT N\n```\n\n#### Custom Token Format\nBy default, the cursor is encoded as a Base64 token. To customize how the cursor is encoded and decoded, you may just need to subclass `Rotulus::Cursor` with the `.decode` and `.encode` methods implemented.\n\n##### Example:\nThe implementation below would generate tokens in UUID format where the actual cursor data is stored in memory(in production, you would use a distributed data store such as Redis):\n\n```ruby\nclass MyCustomCursor \u003c Rotulus::Cursor\n  def self.decode(token)\n    data = storage[token]\n    return data if data.present?\n\n    raise Rotulus::InvalidCursor\n  end\n\n  def self.encode(data)\n    storage_key = SecureRandom.uuid\n\n    storage[storage_key] = data\n    storage_key\n  end\n\n  def self.storage\n    @storage ||= {}\n  end\nend\n```\n\n###### config/initializers/rotulus.rb\n```ruby\nRotulus.configure do |config|\n  ...\n  config.cursor_class = MyCustomCursor\nend\n```\n\u003cbr/\u003e\n\n### Limitations\n1. Custom SQL in `ORDER BY` expression other than sorting by table column values aren't supported to leverage the index usage.\n2. `ORDER BY` column names with characters other than alphanumeric and underscores are not supported.\n\n### Considerations\n1. Although adding indexes improves DB read performance, it can impact write performance. Only expose/whitelist the columns that are really needed in sorting.\n2. Depending on your use case, a disadvantage is that cursor-based pagination does not allow jumping to a specific page (no page numbers).\n\n\n## Development\n\n1. If testing/developing for MySQL or PG, create the database first:\u003cbr/\u003e\n\n  ###### MySQL\n  ```sh\n  mysql\u003e CREATE DATABASE rotulus;\n  ```\n\n  ###### PostgreSQL\n  ```sh\n  $ createdb rotulus\n  ```\n\n2. After checking out the repo, run `bin/setup` to install dependencies.\n3. Run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. Use the environment variables below to target the database\u003cbr/\u003e\u003cbr/\u003e\n  \n  By default, SQLite and the latest stable Rails version are used in tests and console. Refer to the environment variables below to change this:\n\n  | Environment Variable | Values | Example |\n  | ----------- | ----------- |----------- |\n  | `DB_ADAPTER` | **Default: :sqlite**. `sqlite`,`mysql2`, or `postgresql` | ```DB_ADAPTER=postgresql bundle exec rspec```\u003cbr/\u003e\u003cbr/\u003e ```DB_ADAPTER=postgresql ./bin/console``` |\n  | `RAILS_VERSION` | **Default: 8-0** \u003cbr/\u003e\u003cbr/\u003e `4-2`,`5-0`,`5-1`,`5-2`,`6-0`,`6-1`,`7-0`, `7-1`, `7-2`, `8-0` |```RAILS_VERSION=5-2 ./bin/setup```\u003cbr/\u003e\u003cbr/\u003e```RAILS_VERSION=7-2 bundle exec rspec```\u003cbr/\u003e\u003cbr/\u003e ```RAILS_VERSION=7-2 ./bin/console```|\n\n\n\u003cbr/\u003e\u003cbr/\u003e\nTo install this gem onto your local machine, run `bundle exec rake install`.\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/jsonb-uy/rotulus.\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjsonb-uy%2Frotulus","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjsonb-uy%2Frotulus","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjsonb-uy%2Frotulus/lists"}