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 +4 -4
- data/CHANGELOG.md +8 -1
- data/README.md +98 -31
- data/lib/rotulus/column.rb +23 -7
- data/lib/rotulus/column_condition_builder.rb +10 -9
- data/lib/rotulus/configuration.rb +19 -1
- data/lib/rotulus/cursor.rb +33 -20
- data/lib/rotulus/order.rb +28 -22
- data/lib/rotulus/page.rb +13 -21
- data/lib/rotulus/version.rb +1 -1
- data/lib/rotulus.rb +6 -4
- metadata +11 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 07fe3f3acca52daa7170d766b516bbb39b8ad133abee1a60d635ea0c1c3521c3
|
4
|
+
data.tar.gz: b31d8781affc551740fe13878e8f269e73802c1b111bf5c29d94a4629850a9e7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
*
|
18
|
-
*
|
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.
|
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
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
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
|
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
|
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
|
-
|
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
|
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::
|
301
|
-
| `Rotulus::
|
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
|
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
|
-
"
|
376
|
-
"
|
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. `
|
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
|
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/>
|
data/lib/rotulus/column.rb
CHANGED
@@ -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
|
-
|
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
|
35
|
-
@distinct = (distinct
|
36
|
-
@nullable = (nullable
|
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
|
-
|
41
|
+
return seek_to_null_direction_condition if seek_to_null_direction?
|
42
|
+
return filter_condition unless value.nil?
|
43
43
|
|
44
|
-
|
45
|
-
|
44
|
+
"#{not_null_condition} OR (#{tie_break(null_condition)})"
|
45
|
+
end
|
46
46
|
|
47
|
-
|
48
|
-
|
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
|
-
|
52
|
-
|
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 =
|
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
|
data/lib/rotulus/cursor.rb
CHANGED
@@ -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
|
13
|
-
#
|
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[
|
17
|
-
direction = data[
|
18
|
-
created_at = Time.at(data[
|
19
|
-
|
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 !=
|
24
|
-
|
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
|
-
|
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(
|
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
|
95
|
-
d
|
96
|
-
|
97
|
-
|
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
|
102
|
-
#
|
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 = "#{
|
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
|
-
|
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
|
-
|
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::
|
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::
|
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
|
-
|
158
|
-
|
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
|
-
|
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
|
-
|
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? ?
|
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
|
-
|
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
|
-
|
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
|
180
|
-
#
|
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
|
184
|
-
|
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
|
data/lib/rotulus/version.rb
CHANGED
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 '
|
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
|
23
|
+
class OrderChanged < CursorError; end
|
24
|
+
class QueryChanged < CursorError; end
|
23
25
|
class ConfigurationError < BaseError; end
|
24
|
-
class
|
25
|
-
class
|
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.
|
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-
|
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.
|
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.
|
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.
|
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.
|
52
|
+
version: '7.1'
|
53
53
|
- !ruby/object:Gem::Dependency
|
54
|
-
name:
|
54
|
+
name: multi_json
|
55
55
|
requirement: !ruby/object:Gem::Requirement
|
56
56
|
requirements:
|
57
|
-
- - "
|
57
|
+
- - "~>"
|
58
58
|
- !ruby/object:Gem::Version
|
59
|
-
version: '
|
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: '
|
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:
|