rotulus 0.2.4 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dabab916c4cd609c84cf4dea85172a337df2501f846d3747e072ddc4c2ea5995
4
- data.tar.gz: acd18440bd31048eec4cc7aa7afefb4609265a6bbe6002d6e3bea65ebc331eec
3
+ metadata.gz: 07fe3f3acca52daa7170d766b516bbb39b8ad133abee1a60d635ea0c1c3521c3
4
+ data.tar.gz: b31d8781affc551740fe13878e8f269e73802c1b111bf5c29d94a4629850a9e7
5
5
  SHA512:
6
- metadata.gz: c951e3a06482187d8d340d1f66f67b71b8aadf955a48fa4f190fb1edeb921615c7ec47977faf57019c645ca27c3eb66dc31e9b5b4e57aac61323e0a356fdc875
7
- data.tar.gz: fc5ba3d91f64aff2aa0a9594beffe646c99955ef369f590de1f538cc31e31ef6903a3321f4e7adaa1ad62f3e47db3c8a6e7450f97a7a5209d2784d5286ad5f3b
6
+ metadata.gz: 0dc571a3bcde992068a7739c3ec482b44b5af9cbe269d33266177e30ae9c89219df5caea3b34cbe371880349148979fc6e322c23fbed01730e9ce1f391c532d0
7
+ data.tar.gz: 0a1fe9c87952093123df6687b9a7bb73c1f372b8d380d9d2d79be6fee2012fed0eb10f58f012242ce6a7c0e1947c76e6abfc4900fa47a021ddf3ecc579c39c7a
data/CHANGELOG.md CHANGED
@@ -11,4 +11,11 @@
11
11
  - Replace any existing order defined on the given ar_relation
12
12
 
13
13
  ## 0.2.4
14
- - Allow changing limit param
14
+ - Allow changing limit param.
15
+
16
+ ## 1.0.0
17
+ - Allow changing of ar_relation and order by default.
18
+ - Make error class names consistent.
19
+
20
+ ## 2.0.0
21
+ - Use multi_json instead of locking clients into using Oj gem.
data/README.md CHANGED
@@ -1,21 +1,22 @@
1
1
  # Rotulus
2
2
 
3
- [![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)
3
+ [![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)
4
4
 
5
5
  ### Cursor-based pagination for apps built on Rails/ActiveRecord
6
6
 
7
- Cursor-based pagination is an alternative to OFFSET-based pagination that provides a more stable and predictable pagination behavior as records are being added, updated, and removed in the database through the use of an encoded cursor token.
7
+ Cursor-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.
8
8
 
9
9
  Some advantages of this approach are:
10
10
 
11
11
  * Reduces inaccuracies such as duplicate/skipped records due to records being actively manipulated in the DB.
12
12
  * Can significantly improve performance(with proper DB indexing on ordered columns) especially as you move forward on large datasets.
13
13
 
14
+ **TL;DR** See [ sample usage for Rails here ](#rails-usage).
14
15
 
15
16
  ## Features
16
17
 
17
- * Sort records by multiple/any number of columns
18
- * Sort records using columns from joined tables
18
+ * Paginate records sorted by any number of columns
19
+ * Paginate records sorted by columns from joined tables
19
20
  * `NULLS FIRST`/`NULLS LAST` handling
20
21
  * Allows custom cursor format
21
22
  * Built-in cursor token expiration
@@ -45,28 +46,36 @@ gem install rotulus
45
46
  ```
46
47
 
47
48
  ## Configuration
48
- Setting the environment variable `ROTULUS_SECRET` to a random string value(e.g. generate via `rails secret`) is the minimum required setup needed. But for more configuration options:
49
+ Setting the environment variable `ROTULUS_SECRET` to a random string value(e.g. generate via `rails secret`) is the minimum required setup needed.
49
50
 
51
+ <details>
52
+ <summary>More configuration options</summary>
53
+
50
54
  #### Create an initializer `config/initializers/rotulus.rb`:
51
55
 
52
- ```ruby
53
- Rotulus.configure do |config|
54
- config.page_default_limit = 5
55
- config.page_max_limit = 50
56
- config.secret = ENV["MY_ENV_VAR"]
57
- config.token_expires_in = 10800
58
- config.cursor_class = MyCursor
59
- end
60
- ```
56
+ ```ruby
57
+ Rotulus.configure do |config|
58
+ config.page_default_limit = 5
59
+ config.page_max_limit = 50
60
+ config.secret = ENV["MY_ENV_VAR"]
61
+ config.token_expires_in = 10800
62
+ config.cursor_class = MyCursor
63
+ config.restrict_order_change = false
64
+ config.restrict_query_change = false
65
+ end
66
+ ```
61
67
 
62
68
  | Configuration | Description |
63
69
  | ----------- | ----------- |
64
70
  | `page_default_limit` | **Default: 5** <br/> Default record limit per page in case the `:limit` is not given when initializing a page `Rotulus::Page.new(...)` |
65
71
  | `page_max_limit` | **Default: 50** <br/> Maximum `:limit` value allowed when initializing a page.|
66
- | `secret` | **Default: ENV['ROTULUS_SECRET']** <br/> Key needed to generate the cursor state. |
72
+ | `secret` | **Default: ENV['ROTULUS_SECRET']** <br/> Key needed to generate the cursor state needed for cursor integrity checking. |
67
73
  | `token_expires_in` | **Default: 259200**(3 days) <br/> Validity period of a cursor token (in seconds). Set to `nil` to disable token expiration. |
74
+ | `restrict_order_change` | **Default: false** <br/> When `true`, raise an `OrderChanged` error when paginating with a token that was generated from a page instance with a different `:order`. <br/> When `false`, no error is raised and pagination is based on the new `:order` definition. |
75
+ | `restrict_query_change` | **Default: false** <br/> 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. <br/> When `false`, no error is raised and pagination will query based on the new `:ar_relation`. |
68
76
  | `cursor_class` | **Default: Rotulus::Cursor** <br/> Cursor class responsible for encoding/decoding cursor data. Default uses Base64 encoding. see [Custom Token Format](#custom-token-format). |
69
- <br/>
77
+ <br/>
78
+ </details>
70
79
 
71
80
 
72
81
  ## Usage
@@ -124,7 +133,7 @@ In case there is no next page, `nil` is returned
124
133
  page.prev_token
125
134
  => "eyI6ZiI6eyJebyI6..."
126
135
  ```
127
- In case there is no previous page(i.e. currently in first page), `nil` is returned
136
+ In case there is no previous page(i.e., first page), `nil` is returned
128
137
 
129
138
 
130
139
  #### Navigate to the page given a cursor
@@ -168,6 +177,14 @@ page.reload
168
177
  page.reload.records
169
178
  ```
170
179
 
180
+ #### Cursor tokens hash
181
+ ```ruby
182
+ page.links
183
+
184
+ => { previous: "eyI6ZiI6efQ...", next: "eyI6ZiI6eyJ...."}
185
+ ```
186
+ If the token is `nil`, the corresponding key(previous/next) isn't included in the hash.
187
+
171
188
  #### Print page in table format for debugging
172
189
  Currently, only the columns included in `ORDER BY` are shown:
173
190
 
@@ -192,10 +209,10 @@ Instead of just specifying the column sorting such as ```{ first_name: :asc }```
192
209
  | Column Configuration | Description |
193
210
  | ----------- | ----------- |
194
211
  | `direction` | **Default: :asc**. `:asc` or `:desc` |
195
- | `nullable` | **Default: true** if column is defined as nullable in its table, _false_ otherwise. <br/><br />Whether a null value is expected for this column in the result set. <br /><br/>**Note:** <br/>- 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. <br/> - 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.
212
+ | `nullable` | **Default: true** if the column is defined as nullable in its table, _false_ otherwise. <br/><br />Whether a null value is expected for this column in the result set. <br /><br/>**Note:** <br/>- 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. <br/> - 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.
196
213
  | `nulls` | **Default:**<br/>- MySQL and SQLite: `:first` if `direction` is `:asc`, otherwise `:last`<br/>- PostgreSQL: `:last` if `direction` is `:asc`, otherwise `:first`<br/><br/>Tells whether rows with NULL column values comes before/after the records with non-null values. Applicable only if column is `nullable`. |
197
214
  | `distinct` | **Default: true** if the column is the primary key of its table, _false_ otherwise.<br/><br /> Tells whether rows in the result are expected to have unique values for this column. <br/><br />**Note:**<br/>- 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. |
198
- | `model` | **Default:**<br/> - the model of the base AR relation passed to `Rotulus::Page.new(<ar_relation>)` if column name has no prefix(e.g. `first_name`) and the AR relation model has a column matching the column name.<br/>- the model of the base AR relation passed to `Rotulus::Page.new(<ar_relation>)` if column name has a prefix(e.g. `users.first_name`) and thre prefix matches the AR relation's table name and the table has a column matching the column name. <br/><br/>Model 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. <br/>|
215
+ | `model` | **Default:**<br/> - the model of the base AR relation passed to `Rotulus::Page.new(<ar_relation>)` if column name has no prefix(e.g. `first_name`) and the AR relation model has a column matching the column name.<br/>- the model of the base AR relation passed to `Rotulus::Page.new(<ar_relation>)` 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. <br/><br/>Model 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. <br/>|
199
216
 
200
217
 
201
218
  ##### Example:
@@ -258,7 +275,7 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
258
275
  ```
259
276
 
260
277
  Some notes for the example above: <br/>
261
- 1. `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 record unique in the dataset. <br/>
278
+ 1. `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. <br/>
262
279
  2. `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.<br/>
263
280
  3. Explicitly setting the `model: OrderItem` in joined table columns is required for now.
264
281
 
@@ -287,19 +304,65 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
287
304
 
288
305
  ```
289
306
 
290
- <br/>
307
+ ### Rails Usage
308
+
309
+ ##### Controller example 1:
310
+
311
+ ```ruby
312
+ def index
313
+ page = Rotulus::Page.new(User.all, order: index_order, limit: params.dig(:page, :limit))
314
+ .at!(params[:cursor])
315
+ render json: { data: page.records }.merge!(page.links) # `page.links` contain the `cursor` value for next/prev pages.
316
+ end
317
+
318
+ private
319
+
320
+ def index_order
321
+ { first_name: :asc,
322
+ last_name: { direction: :desc, nulls: :last },
323
+ email: { direction: :asc, distinct: true } }
324
+ end
325
+ ```
326
+
327
+ APIs 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:
328
+
329
+ ##### Controller example 2:
330
+
331
+ ```ruby
332
+ def index
333
+ page = Rotulus::Page.new(User.all, order: index_order, limit: params.dig(:page, :limit))
334
+ .at!(params[:cursor])
335
+ render json: { data: page.records }.merge!(page.links)
336
+ end
337
+
338
+ private
339
+
340
+ # Allow clients to sort by first_name, last_name, and/or email.
341
+ # example sort values:
342
+ # a. params[:sort] = +users.last_name,-users.email
343
+ # b. params[:sort] = -first_name
344
+ def index_order
345
+ SortParam.define do
346
+ field 'users.first_name'
347
+ field 'users.last_name', nulls: :last, nullable: true
348
+ field 'users.email', distinct: true
349
+ end.load!(params[:sort].presence || '+users.first_name')
350
+ end
351
+ ```
291
352
 
292
353
  ### Errors
293
354
 
294
355
  | Class | Description |
295
356
  | ----------- | ----------- |
296
- | `Rotulus::InvalidCursor` | Cursor token received is invalid e.g., unrecognized token, token data has been tampered/updated, base ActiveRecord relation filter/sorting is no longer consistent to the token. |
357
+ | `Rotulus::InvalidCursor` | Cursor token received is invalid e.g., unrecognized token, token data has been tampered/updated. |
297
358
  | `Rotulus::Expired` | Cursor token received has expired based on the configured `token_expires_in` |
298
359
  | `Rotulus::InvalidLimit` | Limit set to Rotulus::Page is not valid. e.g., exceeds the configured limit. see `config.page_max_limit` |
299
360
  | `Rotulus::CursorError` | Generic error for cursor related validations |
300
- | `Rotulus::InvalidColumnError` | Column provided in the :order param can't be found. |
301
- | `Rotulus::MissingTiebreakerError` | There is no non-nullable and distinct column in the configured order definition. |
361
+ | `Rotulus::InvalidColumn` | Column provided in the :order param can't be found. |
362
+ | `Rotulus::MissingTiebreaker` | There is no non-nullable and distinct column in the configured order definition. |
302
363
  | `Rotulus::ConfigurationError` | Generic error for missing/invalid configurations. |
364
+ | `Rotulus::OrderChanged` | Error raised paginating with a token(i.e. calling `Page#at` or `Page#at!`) that was generated from a previous page instance with a different `:order` definition. Can be enabled by setting the `restrict_order_change` to true. |
365
+ | `Rotulus::QueryChanged` | Error raised paginating with a token(i.e. calling `Page#at` or `Page#at!`) that was generated from a previous page instance with a different `:ar_relation` filter/query. Can be enabled by setting the `restrict_query_change` to true. |
303
366
 
304
367
  ## How it works
305
368
  Cursor-based pagination uses a reference point/record to fetch the previous or next set of 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:
@@ -309,7 +372,7 @@ Cursor-based pagination uses a reference point/record to fetch the previous or n
309
372
  * Columns used in `ORDER BY` would need to be indexed as they will be used in filtering.
310
373
 
311
374
 
312
- #### Sample SQL generated snippets
375
+ #### Sample SQL-generated snippets
313
376
 
314
377
  ##### Example 1: With order by `id` only
315
378
  ###### Ruby
@@ -370,18 +433,22 @@ To navigate between pages, a cursor is used. The cursor token is a Base64 encode
370
433
 
371
434
  ```json
372
435
  {
373
- "f": {"users.first_name": "Jane", "users.id": 2},
436
+ "f": { "users.first_name": "Jane", "users.id": 2 },
374
437
  "d": "next",
375
- "s": "251177d65873aa37057dba548ecba82f",
376
- "c": 1672502400
438
+ "c": 1672502400,
439
+ "cs": "fe6ac1a1d6a1fc1b7f842b388639f63b",
440
+ "os": "62186497a8073f9c7072389b73c6c60c",
441
+ "qs": "7a5053198709df924dd5ec1752ee4e6b"
377
442
  }
378
443
  ```
379
444
  1. `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.
380
445
  2. `d` - the pagination direction. `next` or `prev` set of records from the reference values in "f".
381
- 3. `s` - the cursor state needed for integrity checking so we can detect whether the base ActiveRecord relation filter(initial `WHERE` conditions) are no longer consistent with the cursor (e.g., API parameters have changed). Additionally, to restrict clients/third-parties from generating their own (unsafe)tokens or from tampering the data of an existing token. The gem requires a secret key configured for this through the `ROTULUS_SECRET` environment variable or the `config.secret` configuration. see [Configuration](#configuration) section.
446
+ 3. `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.
447
+ 4. `os` - the order state needed to detect whether the order definition changed.
448
+ 5. `qs` - the base AR relation state needed to detect whether the ar_relation has changed (e.g. filter/query changed due to API params).
382
449
  4. `c` - cursor token issuance time.
383
450
 
384
- A condition generated from the cursor above would look like:
451
+ A condition generated from the cursor above would look like this:
385
452
 
386
453
  ```sql
387
454
  WHERE users.first_name >= 'Jane' AND (
@@ -459,7 +526,7 @@ end
459
526
  | Environment Variable | Values | Example |
460
527
  | ----------- | ----------- |----------- |
461
528
  | `DB_ADAPTER` | **Default: :sqlite**. `sqlite`,`mysql2`, or `postgresql` | ```DB_ADAPTER=postgresql bundle exec rspec```<br/><br/> ```DB_ADAPTER=postgresql ./bin/console``` |
462
- | `RAILS_VERSION` | **Default: 7-0-stable** <br/><br/> `4-2-stable`,`5-0-stable`,`5-1-stable`,<br/>`5-2-stable`,`6-0-stable`,`6-1-stable`,<br/>`7-0-stable` |```RAILS_VERSION=5-2-stable ./bin/setup```<br/><br/>```RAILS_VERSION=5-2-stable bundle exec rspec```<br/><br/> ```RAILS_VERSION=5-2-stable ./bin/console```|
529
+ | `RAILS_VERSION` | **Default: 7-0** <br/><br/> `4-2`,`5-0`,`5-1`,`5-2`,`6-0`,`6-1`,`7-0` |```RAILS_VERSION=5-2 ./bin/setup```<br/><br/>```RAILS_VERSION=5-2 bundle exec rspec```<br/><br/> ```RAILS_VERSION=5-2 ./bin/console```|
463
530
 
464
531
 
465
532
  <br/><br/>
@@ -26,14 +26,11 @@ module Rotulus
26
26
  def initialize(model, name, direction: :asc, nullable: nil, nulls: nil, distinct: nil)
27
27
  @model = model
28
28
  @name = name.to_s
29
- unless name_valid?
30
- raise Rotulus::InvalidColumnError.new("Column/table name must contain letters, digits (0-9), or \
31
- underscores and must begin with a letter or underscore.".squish)
32
- end
29
+ validate_name!
33
30
 
34
- @direction = direction.to_s.downcase == 'desc' ? :desc : :asc
35
- @distinct = (distinct.nil? ? primary_key? : distinct).presence || false
36
- @nullable = (nullable.nil? ? metadata&.null : nullable).presence || false
31
+ @direction = sort_direction(direction)
32
+ @distinct = uniqueness(distinct)
33
+ @nullable = nullability(nullable)
37
34
  @nulls = nulls_order(nulls)
38
35
  end
39
36
 
@@ -150,8 +147,27 @@ module Rotulus
150
147
  Rotulus.db.default_nulls_order(direction)
151
148
  end
152
149
 
150
+ def nullability(nullable)
151
+ (nullable.nil? ? metadata&.null : nullable).presence || false
152
+ end
153
+
153
154
  def primary_key?
154
155
  unprefixed_name == model.primary_key
155
156
  end
157
+
158
+ def sort_direction(direction)
159
+ direction.to_s.downcase == 'desc' ? :desc : :asc
160
+ end
161
+
162
+ def uniqueness(unique)
163
+ (unique.nil? ? primary_key? : unique).presence || false
164
+ end
165
+
166
+ def validate_name!
167
+ return if name_valid?
168
+
169
+ raise Rotulus::InvalidColumn.new("Column/table name must contain letters, digits (0-9), or \
170
+ underscores and must begin with a letter or underscore.".squish)
171
+ end
156
172
  end
157
173
  end
@@ -38,18 +38,19 @@ module Rotulus
38
38
  end
39
39
 
40
40
  def nullable_filter_condition
41
- if seek_to_null_direction?
42
- return tie_break null_condition if value.nil?
41
+ return seek_to_null_direction_condition if seek_to_null_direction?
42
+ return filter_condition unless value.nil?
43
43
 
44
- condition = "#{seek_condition} OR #{null_condition}"
45
- return condition if column.distinct?
44
+ "#{not_null_condition} OR (#{tie_break(null_condition)})"
45
+ end
46
46
 
47
- prefilter("(#{condition}) OR (#{tie_break(identity)})")
48
- else
49
- return filter_condition unless value.nil?
47
+ def seek_to_null_direction_condition
48
+ return tie_break null_condition if value.nil?
50
49
 
51
- "#{not_null_condition} OR (#{tie_break(null_condition)})"
52
- end
50
+ condition = "#{seek_condition} OR #{null_condition}"
51
+ return condition if column.distinct?
52
+
53
+ prefilter("(#{condition}) OR (#{tie_break(identity)})")
53
54
  end
54
55
 
55
56
  def identity
@@ -6,8 +6,10 @@ module Rotulus
6
6
  @page_default_limit = default_limit
7
7
  @page_max_limit = default_max_limit
8
8
  @secret = ENV['ROTULUS_SECRET']
9
- @token_expires_in = 259200
9
+ @token_expires_in = 259_200
10
10
  @cursor_class = default_cursor_class
11
+ @restrict_order_change = false
12
+ @restrict_query_change = false
11
13
  end
12
14
 
13
15
  def page_default_limit=(limit)
@@ -49,6 +51,22 @@ module Rotulus
49
51
  @cursor_class || default_cursor_class
50
52
  end
51
53
 
54
+ def restrict_order_change=(restrict)
55
+ @restrict_order_change = !!restrict
56
+ end
57
+
58
+ def restrict_order_change?
59
+ @restrict_order_change
60
+ end
61
+
62
+ def restrict_query_change=(restrict)
63
+ @restrict_query_change = !!restrict
64
+ end
65
+
66
+ def restrict_query_change?
67
+ @restrict_query_change
68
+ end
69
+
52
70
  private
53
71
 
54
72
  def default_cursor_class
@@ -9,19 +9,32 @@ module Rotulus
9
9
  # @param token [String] Base64-encoded string data
10
10
  # @return [Cursor] Cursor
11
11
  #
12
- # @raise [InvalidCursor] if the cursor is no longer consistent to the page's ActiveRecord
13
- # relation filters, sorting, or if the encoded cursor data was tampered.
12
+ # @raise [InvalidCursor] if the token can't be decoded or if the cursor data was tampered.
13
+ # @raise [OrderChanged] if token generated from a page with a different `:order` definition.
14
+ # @raise [QueryChanged] if token generated from a page with a different `:ar_relation`.
14
15
  def for_page_and_token!(page, token)
15
16
  data = decode(token)
16
- reference_record = Record.new(page, data[:f])
17
- direction = data[:d]
18
- created_at = Time.at(data[:c]).utc
19
- state = data[:s].presence
17
+ reference_record = Record.new(page, data['f'])
18
+ direction = data['d']
19
+ created_at = Time.at(data['c']).utc
20
+ cursor_state = data['cs'].presence
21
+ order_state = data['os'].presence
22
+ query_state = data['qs'].presence
20
23
 
21
24
  cursor = new(reference_record, direction, created_at: created_at)
22
25
 
23
- if cursor.state != state
24
- raise InvalidCursor.new('Invalid cursor possibly due to filter or sorting changed')
26
+ raise InvalidCursor if cursor.state != cursor_state
27
+
28
+ if page.order_state != order_state
29
+ raise OrderChanged if Rotulus.configuration.restrict_order_change?
30
+
31
+ return nil
32
+ end
33
+
34
+ if page.query_state != query_state
35
+ raise QueryChanged if Rotulus.configuration.restrict_query_change?
36
+
37
+ return nil
25
38
  end
26
39
 
27
40
  cursor
@@ -35,8 +48,8 @@ module Rotulus
35
48
  # of the previous page if page direction is `:next` or the first record of the next
36
49
  # page if page direction is `:prev`.
37
50
  def decode(token)
38
- Oj.load(Base64.urlsafe_decode64(token))
39
- rescue ArgumentError => e
51
+ MultiJson.load(Base64.urlsafe_decode64(token))
52
+ rescue ArgumentError, MultiJson::ParseError => e
40
53
  raise InvalidCursor.new("Invalid Cursor: #{e.message}")
41
54
  end
42
55
 
@@ -45,7 +58,7 @@ module Rotulus
45
58
  # @param token_data [Hash] Cursor token data hash
46
59
  # @return token [String] String token for this cursor that can be used as param to Page#at.
47
60
  def encode(token_data)
48
- Base64.urlsafe_encode64(Oj.dump(token_data, symbol_keys: true))
61
+ Base64.urlsafe_encode64(MultiJson.dump(token_data))
49
62
  end
50
63
  end
51
64
 
@@ -91,21 +104,21 @@ module Rotulus
91
104
  #
92
105
  # @return [String] the token encoded in Base64.
93
106
  def to_token
94
- @token ||= self.class.encode(f: record.values,
95
- d: direction,
96
- s: state,
97
- c: created_at.to_i)
107
+ @token ||= self.class.encode('f' => record.values.as_json,
108
+ 'd' => direction,
109
+ 'c' => created_at.to_i,
110
+ 'cs' => state,
111
+ 'os' => page.order_state,
112
+ 'qs' => page.query_state)
98
113
  end
99
114
  alias to_s to_token
100
115
 
101
- # Generate a 'state' string so we can detect whether the cursor data is no longer consistent to
102
- # the AR filter or order definition. This also provides a mechanism to detect if
103
- # any token data was tampered.
116
+ # Generate a 'state' string for integrity checking of the reference record, direction,
117
+ # and created_at data from a decoded Cursor token.
104
118
  #
105
119
  # @return [String] the hashed state
106
120
  def state
107
- state_data = "#{page.state}#{record.state}"
108
- state_data << "#{direction}#{created_at.to_i}#{secret}"
121
+ state_data = "#{record.state}#{direction}#{created_at.to_i}#{secret}"
109
122
 
110
123
  Digest::MD5.hexdigest(state_data)
111
124
  end
data/lib/rotulus/order.rb CHANGED
@@ -10,10 +10,11 @@ module Rotulus
10
10
  @raw_hash = raw_hash&.with_indifferent_access || {}
11
11
  @definition = {}
12
12
 
13
- build_column_definitions
13
+ build_column_definitions!
14
14
 
15
15
  return if has_tiebreaker?
16
- raise Rotulus::MissingTiebreakerError.new('A non-nullable and distinct column is required.')
16
+
17
+ raise Rotulus::MissingTiebreaker.new('A non-nullable and distinct column is required.')
17
18
  end
18
19
 
19
20
  # Returns an array of the ordered columns
@@ -79,7 +80,9 @@ module Rotulus
79
80
  #
80
81
  # @return [String] the hashed state
81
82
  def state
82
- Digest::MD5.hexdigest(Oj.dump(to_h, mode: :rails))
83
+ data = MultiJson.dump(to_h)
84
+
85
+ Digest::MD5.hexdigest("#{data}#{Rotulus.configuration.secret}")
83
86
  end
84
87
 
85
88
  # Returns a hash containing the hash representation of the ordered columns.
@@ -123,7 +126,7 @@ module Rotulus
123
126
  unless model_override.nil?
124
127
  return model_override unless model_override.columns_hash[unprefixed_name].nil?
125
128
 
126
- raise Rotulus::InvalidColumnError.new(
129
+ raise Rotulus::InvalidColumn.new(
127
130
  "Model '#{model_override}' doesnt have a '#{name}' column. \
128
131
  Tip: check the :model option value in the column's order configuration.".squish
129
132
  )
@@ -134,7 +137,7 @@ module Rotulus
134
137
  return ar_model
135
138
  end
136
139
 
137
- raise Rotulus::InvalidColumnError.new(
140
+ raise Rotulus::InvalidColumn.new(
138
141
  "Unable determine which model the column '#{name}' belongs to. \
139
142
  Tip: set/check the :model option value in the column's order configuration.".squish
140
143
  )
@@ -150,39 +153,42 @@ module Rotulus
150
153
  !definition["#{ar_table}.#{ar_model_primary_key}"].nil?
151
154
  end
152
155
 
153
- def build_column_definitions
156
+ def build_column_definitions!
154
157
  raw_hash.each do |column_name, options|
155
158
  column_name = column_name.to_s
156
159
 
157
- unless options.is_a?(Hash)
158
- options = if options.to_s.downcase == 'desc'
159
- { direction: :desc }
160
- else
161
- { direction: :asc }
162
- end
163
- end
164
-
165
- model = column_model(options[:model].presence, column_name)
160
+ options = normalize_column_options(options)
161
+ model = column_model(options.delete(:model), column_name)
166
162
  column = Column.new(model,
167
163
  column_name,
168
164
  direction: options[:direction],
169
165
  nulls: options[:nulls],
170
166
  nullable: options[:nullable],
171
167
  distinct: options[:distinct])
172
- next unless definition[column.prefixed_name].nil?
173
-
174
- definition[column.prefixed_name] = column
168
+ definition[column.prefixed_name] ||= column
175
169
  end
176
170
 
177
171
  # Add tie-breaker using the PK
178
- unless primary_key_ordered?
179
- pk_column = Column.new(ar_model, ar_model_primary_key, direction: :asc)
180
- definition[pk_column.prefixed_name] = pk_column
181
- end
172
+ add_pk_tiebreaker_column!
182
173
 
183
174
  columns.first.as_leftmost!
184
175
  end
185
176
 
177
+ def add_pk_tiebreaker_column!
178
+ return if primary_key_ordered?
179
+
180
+ pk_column = Column.new(ar_model, ar_model_primary_key, direction: :asc)
181
+
182
+ definition[pk_column.prefixed_name] = pk_column
183
+ end
184
+
185
+ def normalize_column_options(options)
186
+ return options if options.is_a?(Hash)
187
+ return { direction: :desc } if options.to_s.downcase == 'desc'
188
+
189
+ { direction: :asc }
190
+ end
191
+
186
192
  # Returns an array of SELECT statement alias of the ordered columns
187
193
  #
188
194
  # @return [Array<String>] column SELECT aliases
data/lib/rotulus/page.rb CHANGED
@@ -2,7 +2,7 @@ module Rotulus
2
2
  class Page
3
3
  attr_reader :ar_relation, :order, :limit, :cursor
4
4
 
5
- delegate :columns, to: :order, prefix: true
5
+ delegate :columns, :state, to: :order, prefix: true
6
6
 
7
7
  # Creates a new Page instance representing a subset of the given ActiveRecord::Relation
8
8
  # records sorted using the given 'order' definition param.
@@ -77,7 +77,7 @@ module Rotulus
77
77
  # @param token [String] Base64-encoded representation of cursor
78
78
  # @return [self] page instance
79
79
  def at!(token)
80
- @cursor = token.present? ? cursor_clazz.for_page_and_token!(self, token) : nil
80
+ @cursor = token.present? ? config.cursor_class.for_page_and_token!(self, token) : nil
81
81
 
82
82
  reload
83
83
  end
@@ -131,7 +131,7 @@ module Rotulus
131
131
  record = cursor_reference_record(:next)
132
132
  return if record.nil?
133
133
 
134
- cursor_clazz.new(record, :next).to_token
134
+ config.cursor_class.new(record, :next).to_token
135
135
  end
136
136
 
137
137
  # Generate the cursor token to access the previous page if one exists
@@ -143,7 +143,7 @@ module Rotulus
143
143
  record = cursor_reference_record(:prev)
144
144
  return if record.nil?
145
145
 
146
- cursor_clazz.new(record, :prev).to_token
146
+ config.cursor_class.new(record, :prev).to_token
147
147
  end
148
148
 
149
149
  # Next page instance
@@ -176,12 +176,14 @@ module Rotulus
176
176
  }.delete_if { |_, token| token.nil? }
177
177
  end
178
178
 
179
- # Return Hashed value of this page's state so we can check whether the ar_relation's filter and
180
- # order definition are still consistent to the cursor. see Cursor.state_valid?.
179
+ # Return Hashed value of this page's state so we can check whether the base ar_relation has
180
+ # changed(e.g. SQL/filters from API params). see Cursor.for_page_and_token!
181
181
  #
182
182
  # @return [String] the hashed state
183
- def state
184
- Digest::MD5.hexdigest("#{ar_relation.to_sql}~#{order.state}")
183
+ def query_state
184
+ data = ar_relation.to_sql
185
+
186
+ Digest::MD5.hexdigest("#{data}#{Rotulus.configuration.secret}")
185
187
  end
186
188
 
187
189
  # Returns a string showing the page's records in table form with the ordered columns
@@ -200,6 +202,8 @@ module Rotulus
200
202
 
201
203
  private
202
204
 
205
+ delegate :model, to: :ar_relation, prefix: false
206
+
203
207
  # If this is the root page or when paginating forward(#paged_forward), limit+1
204
208
  # includes the first record of the next page. This lets us know whether there is a page
205
209
  # succeeding the current page. When paginating backwards(#paged_back?), the limit+1 includes the
@@ -241,15 +245,11 @@ module Rotulus
241
245
  # to filter next/prev page's records. Alias and normalize those columns so we can access
242
246
  # the values using record#slice.
243
247
  def select_columns
244
- base_select_values = ar_relation.select_values.presence || [select_all_sql]
248
+ base_select_values = ar_relation.select_values.presence || [Rotulus.db.select_all_sql(model.table_name)]
245
249
  base_select_values << order.select_sql
246
250
  base_select_values
247
251
  end
248
252
 
249
- def select_all_sql
250
- Rotulus.db.select_all_sql(model.table_name)
251
- end
252
-
253
253
  def order_by_sql
254
254
  return order.reversed_sql if paged_back?
255
255
 
@@ -263,16 +263,8 @@ module Rotulus
263
263
  limit >= 1 && limit <= config.page_max_limit
264
264
  end
265
265
 
266
- def model
267
- ar_relation.model
268
- end
269
-
270
266
  def config
271
267
  @config ||= Rotulus.configuration
272
268
  end
273
-
274
- def cursor_clazz
275
- @cursor_clazz ||= config.cursor_class
276
- end
277
269
  end
278
270
  end
@@ -1,3 +1,3 @@
1
1
  module Rotulus
2
- VERSION = '0.2.4'.freeze
2
+ VERSION = '2.0.0'.freeze
3
3
  end
data/lib/rotulus.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  require 'active_record'
2
2
  require 'active_support'
3
3
  require 'active_support/core_ext/string/inquiry'
4
- require 'oj'
4
+ require 'multi_json'
5
5
  require 'rotulus/version'
6
6
  require 'rotulus/configuration'
7
7
  require 'rotulus/db/database'
@@ -15,14 +15,16 @@ require 'rotulus/page'
15
15
 
16
16
  module Rotulus
17
17
  class BaseError < StandardError; end
18
+ class InvalidLimit < BaseError; end
18
19
  class CursorError < BaseError; end
19
20
  class InvalidCursor < CursorError; end
20
21
  class ExpiredCursor < CursorError; end
21
22
  class InvalidCursorDirection < CursorError; end
22
- class InvalidLimit < BaseError; end
23
+ class OrderChanged < CursorError; end
24
+ class QueryChanged < CursorError; end
23
25
  class ConfigurationError < BaseError; end
24
- class MissingTiebreakerError < ConfigurationError; end
25
- class InvalidColumnError < ConfigurationError; end
26
+ class MissingTiebreaker < ConfigurationError; end
27
+ class InvalidColumn < ConfigurationError; end
26
28
 
27
29
  def self.db
28
30
  @db ||= case ActiveRecord::Base.connection.adapter_name.downcase
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rotulus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Uy Jayson B
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-05-24 00:00:00.000000000 Z
11
+ date: 2023-06-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -19,7 +19,7 @@ dependencies:
19
19
  version: '4.2'
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
- version: 7.0.5
22
+ version: '7.1'
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -29,7 +29,7 @@ dependencies:
29
29
  version: '4.2'
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
- version: 7.0.5
32
+ version: '7.1'
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: activesupport
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -39,7 +39,7 @@ dependencies:
39
39
  version: '4.2'
40
40
  - - "<"
41
41
  - !ruby/object:Gem::Version
42
- version: 7.0.5
42
+ version: '7.1'
43
43
  type: :runtime
44
44
  prerelease: false
45
45
  version_requirements: !ruby/object:Gem::Requirement
@@ -49,21 +49,21 @@ dependencies:
49
49
  version: '4.2'
50
50
  - - "<"
51
51
  - !ruby/object:Gem::Version
52
- version: 7.0.5
52
+ version: '7.1'
53
53
  - !ruby/object:Gem::Dependency
54
- name: oj
54
+ name: multi_json
55
55
  requirement: !ruby/object:Gem::Requirement
56
56
  requirements:
57
- - - ">="
57
+ - - "~>"
58
58
  - !ruby/object:Gem::Version
59
- version: '0'
59
+ version: '1.15'
60
60
  type: :runtime
61
61
  prerelease: false
62
62
  version_requirements: !ruby/object:Gem::Requirement
63
63
  requirements:
64
- - - ">="
64
+ - - "~>"
65
65
  - !ruby/object:Gem::Version
66
- version: '0'
66
+ version: '1.15'
67
67
  description: Cursor-based pagination for Rails/ActiveRecord apps with multiple column
68
68
  sort and custom cursor format support for a more stable and predictable pagination.
69
69
  email: