rotulus 1.0.0 → 2.1.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 +4 -1
- data/README.md +100 -40
- data/lib/rotulus/column.rb +23 -7
- data/lib/rotulus/column_condition_builder.rb +10 -9
- data/lib/rotulus/cursor.rb +15 -15
- data/lib/rotulus/order.rb +22 -19
- data/lib/rotulus/page.rb +6 -16
- data/lib/rotulus/version.rb +1 -1
- data/lib/rotulus.rb +1 -1
- 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: 7504d29a1bd4aac2f21ffb981abd98b544b8b50f80fa7f875cc73286e967e9a2
|
4
|
+
data.tar.gz: afbd5665291c310b0fd03e87cebd0b100da71dcfdedae7953b4844c767859ce3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 19d4f2b1e1a734c78b8d63954ef66ba443591a7fc2c04364eccc6e1a0780b4f3e6346af327e53f3ba773bafca9a39b3abd5ef7efc8937b71a8fc534d7d16dc84
|
7
|
+
data.tar.gz: 0a739f5977b51127934658605a304f78a6be0ffe8d4e3e0ddce8fe4abc1223e6bf0c7ef1dfd39afd6b675f7006b9b3f4074fd11cf5329d9433886dd06dc18bd8
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,21 +1,23 @@
|
|
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
|
-
* Reduces inaccuracies such as duplicate
|
11
|
+
* Reduces inaccuracies such as duplicate or skipped records as records are being manipulated.
|
12
12
|
* Can significantly improve performance(with proper DB indexing on ordered columns) especially as you move forward on large datasets.
|
13
13
|
|
14
14
|
|
15
|
+
**TL;DR** See [ sample usage for Rails here ](#rails-usage).
|
16
|
+
|
15
17
|
## Features
|
16
18
|
|
17
|
-
*
|
18
|
-
*
|
19
|
+
* Paginate records sorted by any number of columns
|
20
|
+
* Paginate records sorted by columns from joined tables
|
19
21
|
* `NULLS FIRST`/`NULLS LAST` handling
|
20
22
|
* Allows custom cursor format
|
21
23
|
* Built-in cursor token expiration
|
@@ -45,21 +47,24 @@ gem install rotulus
|
|
45
47
|
```
|
46
48
|
|
47
49
|
## 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.
|
50
|
+
Setting the environment variable `ROTULUS_SECRET` to a random string value(e.g. generate via `rails secret`) is the minimum required setup needed.
|
49
51
|
|
52
|
+
<details>
|
53
|
+
<summary>More configuration options</summary>
|
54
|
+
|
50
55
|
#### Create an initializer `config/initializers/rotulus.rb`:
|
51
56
|
|
52
|
-
```ruby
|
53
|
-
Rotulus.configure do |config|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
end
|
62
|
-
```
|
57
|
+
```ruby
|
58
|
+
Rotulus.configure do |config|
|
59
|
+
config.page_default_limit = 5
|
60
|
+
config.page_max_limit = 50
|
61
|
+
config.secret = ENV["MY_ENV_VAR"]
|
62
|
+
config.token_expires_in = 10800
|
63
|
+
config.cursor_class = MyCursor
|
64
|
+
config.restrict_order_change = false
|
65
|
+
config.restrict_query_change = false
|
66
|
+
end
|
67
|
+
```
|
63
68
|
|
64
69
|
| Configuration | Description |
|
65
70
|
| ----------- | ----------- |
|
@@ -70,7 +75,8 @@ end
|
|
70
75
|
| `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. |
|
71
76
|
| `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`. |
|
72
77
|
| `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). |
|
73
|
-
<br/>
|
78
|
+
<br/>
|
79
|
+
</details>
|
74
80
|
|
75
81
|
|
76
82
|
## Usage
|
@@ -93,7 +99,7 @@ page = Rotulus::Page.new(users)
|
|
93
99
|
|
94
100
|
page = Rotulus::Page.new(users, order: { first_name: :asc, last_name: :desc }, limit: 3)
|
95
101
|
```
|
96
|
-
|
102
|
+
The 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.
|
97
103
|
|
98
104
|
|
99
105
|
#### Access the page records
|
@@ -128,7 +134,7 @@ In case there is no next page, `nil` is returned
|
|
128
134
|
page.prev_token
|
129
135
|
=> "eyI6ZiI6eyJebyI6..."
|
130
136
|
```
|
131
|
-
In case there is no previous page(i.e
|
137
|
+
In case there is no previous page(i.e., first page), `nil` is returned
|
132
138
|
|
133
139
|
|
134
140
|
#### Navigate to the page given a cursor
|
@@ -172,6 +178,14 @@ page.reload
|
|
172
178
|
page.reload.records
|
173
179
|
```
|
174
180
|
|
181
|
+
#### Cursor tokens hash
|
182
|
+
```ruby
|
183
|
+
page.links
|
184
|
+
|
185
|
+
=> { previous: "eyI6ZiI6efQ...", next: "eyI6ZiI6eyJ...."}
|
186
|
+
```
|
187
|
+
If the token is `nil`, the corresponding key(previous/next) isn't included in the hash.
|
188
|
+
|
175
189
|
#### Print page in table format for debugging
|
176
190
|
Currently, only the columns included in `ORDER BY` are shown:
|
177
191
|
|
@@ -191,15 +205,15 @@ puts page.as_table
|
|
191
205
|
|
192
206
|
### Advanced Usage
|
193
207
|
#### Expanded order definition
|
194
|
-
Instead 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:
|
208
|
+
Instead 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:
|
195
209
|
|
196
210
|
| Column Configuration | Description |
|
197
211
|
| ----------- | ----------- |
|
198
212
|
| `direction` | **Default: :asc**. `:asc` or `:desc` |
|
199
|
-
| `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.
|
213
|
+
| `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.
|
200
214
|
| `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`. |
|
201
215
|
| `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. |
|
202
|
-
| `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
|
216
|
+
| `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/>|
|
203
217
|
|
204
218
|
|
205
219
|
##### Example:
|
@@ -262,7 +276,7 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
|
|
262
276
|
```
|
263
277
|
|
264
278
|
Some notes for the example above: <br/>
|
265
|
-
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
|
279
|
+
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/>
|
266
280
|
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/>
|
267
281
|
3. Explicitly setting the `model: OrderItem` in joined table columns is required for now.
|
268
282
|
|
@@ -291,7 +305,51 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
|
|
291
305
|
|
292
306
|
```
|
293
307
|
|
294
|
-
|
308
|
+
### Rails Usage
|
309
|
+
|
310
|
+
##### Controller example 1:
|
311
|
+
|
312
|
+
```ruby
|
313
|
+
def index
|
314
|
+
page = Rotulus::Page.new(User.all, order: index_order, limit: params.dig(:page, :limit))
|
315
|
+
.at!(params[:cursor])
|
316
|
+
render json: { data: page.records }.merge!(page.links) # `page.links` contain the `cursor` value for next/prev pages.
|
317
|
+
end
|
318
|
+
|
319
|
+
private
|
320
|
+
|
321
|
+
def index_order
|
322
|
+
{ first_name: :asc,
|
323
|
+
last_name: { direction: :desc, nulls: :last },
|
324
|
+
email: { direction: :asc, distinct: true } }
|
325
|
+
end
|
326
|
+
```
|
327
|
+
|
328
|
+
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:
|
329
|
+
|
330
|
+
##### Controller example 2:
|
331
|
+
|
332
|
+
```ruby
|
333
|
+
def index
|
334
|
+
page = Rotulus::Page.new(User.all, order: index_order, limit: params.dig(:page, :limit))
|
335
|
+
.at!(params[:cursor])
|
336
|
+
render json: { data: page.records }.merge!(page.links)
|
337
|
+
end
|
338
|
+
|
339
|
+
private
|
340
|
+
|
341
|
+
# Allow clients to sort by first_name, last_name, and/or email.
|
342
|
+
# example sort values:
|
343
|
+
# a. params[:sort] = +last_name,-email
|
344
|
+
# b. params[:sort] = -first_name
|
345
|
+
def index_order
|
346
|
+
SortParam.define do
|
347
|
+
field 'first_name'
|
348
|
+
field 'last_name', nulls: :last, nullable: true
|
349
|
+
field 'email', distinct: true
|
350
|
+
end.load!(params[:sort].presence || 'first_name')
|
351
|
+
end
|
352
|
+
```
|
295
353
|
|
296
354
|
### Errors
|
297
355
|
|
@@ -303,19 +361,19 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
|
|
303
361
|
| `Rotulus::CursorError` | Generic error for cursor related validations |
|
304
362
|
| `Rotulus::InvalidColumn` | Column provided in the :order param can't be found. |
|
305
363
|
| `Rotulus::MissingTiebreaker` | There is no non-nullable and distinct column in the configured order definition. |
|
306
|
-
| `Rotulus::ConfigurationError` | Generic error for missing/invalid
|
307
|
-
| `Rotulus::OrderChanged` |
|
308
|
-
| `Rotulus::QueryChanged` |
|
364
|
+
| `Rotulus::ConfigurationError` | Generic error for missing/invalid configuration. |
|
365
|
+
| `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. |
|
366
|
+
| `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. |
|
309
367
|
|
310
368
|
## How it works
|
311
|
-
Cursor-based pagination uses a reference
|
369
|
+
Cursor-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:
|
312
370
|
|
313
|
-
* Records are sorted (`ORDER BY`).
|
371
|
+
* Records are sorted (`ORDER BY`) by columns.
|
314
372
|
* 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.
|
315
373
|
* Columns used in `ORDER BY` would need to be indexed as they will be used in filtering.
|
316
374
|
|
317
375
|
|
318
|
-
#### Sample SQL
|
376
|
+
#### Sample SQL-generated snippets to fetch the next set of records
|
319
377
|
|
320
378
|
##### Example 1: With order by `id` only
|
321
379
|
###### Ruby
|
@@ -376,20 +434,22 @@ To navigate between pages, a cursor is used. The cursor token is a Base64 encode
|
|
376
434
|
|
377
435
|
```json
|
378
436
|
{
|
379
|
-
"f": {"users.first_name": "Jane", "users.id": 2},
|
437
|
+
"f": { "users.first_name": "Jane", "users.id": 2 },
|
380
438
|
"d": "next",
|
381
|
-
"
|
382
|
-
"
|
439
|
+
"c": 1672502400,
|
440
|
+
"cs": "fe6ac1a1d6a1fc1b7f842b388639f63b",
|
441
|
+
"os": "62186497a8073f9c7072389b73c6c60c",
|
442
|
+
"qs": "7a5053198709df924dd5ec1752ee4e6b"
|
383
443
|
}
|
384
444
|
```
|
385
445
|
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.
|
386
446
|
2. `d` - the pagination direction. `next` or `prev` set of records from the reference values in "f".
|
387
|
-
3. `cs` - the cursor state needed for integrity checking,
|
447
|
+
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.
|
388
448
|
4. `os` - the order state needed to detect whether the order definition changed.
|
389
|
-
5. `qs` - the base AR relation state
|
449
|
+
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).
|
390
450
|
4. `c` - cursor token issuance time.
|
391
451
|
|
392
|
-
A condition generated from the cursor above would look like:
|
452
|
+
A condition generated from the cursor above would look like this:
|
393
453
|
|
394
454
|
```sql
|
395
455
|
WHERE users.first_name >= 'Jane' AND (
|
@@ -400,10 +460,10 @@ WHERE users.first_name >= 'Jane' AND (
|
|
400
460
|
```
|
401
461
|
|
402
462
|
#### Custom Token Format
|
403
|
-
By default, the cursor is encoded as a Base64 token. To customize how the cursor is encoded and decoded, you may just
|
463
|
+
By 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.
|
404
464
|
|
405
465
|
##### Example:
|
406
|
-
The implementation below would generate tokens in UUID format where the actual cursor data is stored in memory:
|
466
|
+
The 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):
|
407
467
|
|
408
468
|
```ruby
|
409
469
|
class MyCustomCursor < Rotulus::Cursor
|
@@ -467,7 +527,7 @@ end
|
|
467
527
|
| Environment Variable | Values | Example |
|
468
528
|
| ----------- | ----------- |----------- |
|
469
529
|
| `DB_ADAPTER` | **Default: :sqlite**. `sqlite`,`mysql2`, or `postgresql` | ```DB_ADAPTER=postgresql bundle exec rspec```<br/><br/> ```DB_ADAPTER=postgresql ./bin/console``` |
|
470
|
-
| `RAILS_VERSION` | **Default: 7-
|
530
|
+
| `RAILS_VERSION` | **Default: 7-1** <br/><br/> `4-2`,`5-0`,`5-1`,`5-2`,`6-0`,`6-1`,`7-0`, `7-1` |```RAILS_VERSION=5-2 ./bin/setup```<br/><br/>```RAILS_VERSION=5-2 bundle exec rspec```<br/><br/> ```RAILS_VERSION=5-2 ./bin/console```|
|
471
531
|
|
472
532
|
|
473
533
|
<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::InvalidColumn.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
|
data/lib/rotulus/cursor.rb
CHANGED
@@ -14,12 +14,12 @@ module Rotulus
|
|
14
14
|
# @raise [QueryChanged] if token generated from a page with a different `:ar_relation`.
|
15
15
|
def for_page_and_token!(page, token)
|
16
16
|
data = decode(token)
|
17
|
-
reference_record = Record.new(page, data[
|
18
|
-
direction = data[
|
19
|
-
created_at = Time.at(data[
|
20
|
-
cursor_state = data[
|
21
|
-
order_state = data[
|
22
|
-
query_state = data[
|
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
|
23
23
|
|
24
24
|
cursor = new(reference_record, direction, created_at: created_at)
|
25
25
|
|
@@ -48,8 +48,8 @@ module Rotulus
|
|
48
48
|
# of the previous page if page direction is `:next` or the first record of the next
|
49
49
|
# page if page direction is `:prev`.
|
50
50
|
def decode(token)
|
51
|
-
|
52
|
-
rescue ArgumentError,
|
51
|
+
MultiJson.load(Base64.urlsafe_decode64(token))
|
52
|
+
rescue ArgumentError, MultiJson::ParseError => e
|
53
53
|
raise InvalidCursor.new("Invalid Cursor: #{e.message}")
|
54
54
|
end
|
55
55
|
|
@@ -58,7 +58,7 @@ module Rotulus
|
|
58
58
|
# @param token_data [Hash] Cursor token data hash
|
59
59
|
# @return token [String] String token for this cursor that can be used as param to Page#at.
|
60
60
|
def encode(token_data)
|
61
|
-
Base64.urlsafe_encode64(
|
61
|
+
Base64.urlsafe_encode64(MultiJson.dump(token_data))
|
62
62
|
end
|
63
63
|
end
|
64
64
|
|
@@ -104,12 +104,12 @@ module Rotulus
|
|
104
104
|
#
|
105
105
|
# @return [String] the token encoded in Base64.
|
106
106
|
def to_token
|
107
|
-
@token ||= self.class.encode(f
|
108
|
-
d
|
109
|
-
c
|
110
|
-
cs
|
111
|
-
os
|
112
|
-
qs
|
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)
|
113
113
|
end
|
114
114
|
alias to_s to_token
|
115
115
|
|
data/lib/rotulus/order.rb
CHANGED
@@ -10,7 +10,7 @@ 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
|
|
@@ -80,7 +80,7 @@ module Rotulus
|
|
80
80
|
#
|
81
81
|
# @return [String] the hashed state
|
82
82
|
def state
|
83
|
-
data =
|
83
|
+
data = MultiJson.dump(to_h)
|
84
84
|
|
85
85
|
Digest::MD5.hexdigest("#{data}#{Rotulus.configuration.secret}")
|
86
86
|
end
|
@@ -153,39 +153,42 @@ module Rotulus
|
|
153
153
|
!definition["#{ar_table}.#{ar_model_primary_key}"].nil?
|
154
154
|
end
|
155
155
|
|
156
|
-
def build_column_definitions
|
156
|
+
def build_column_definitions!
|
157
157
|
raw_hash.each do |column_name, options|
|
158
158
|
column_name = column_name.to_s
|
159
159
|
|
160
|
-
|
161
|
-
|
162
|
-
{ direction: :desc }
|
163
|
-
else
|
164
|
-
{ direction: :asc }
|
165
|
-
end
|
166
|
-
end
|
167
|
-
|
168
|
-
model = column_model(options[:model].presence, column_name)
|
160
|
+
options = normalize_column_options(options)
|
161
|
+
model = column_model(options.delete(:model), column_name)
|
169
162
|
column = Column.new(model,
|
170
163
|
column_name,
|
171
164
|
direction: options[:direction],
|
172
165
|
nulls: options[:nulls],
|
173
166
|
nullable: options[:nullable],
|
174
167
|
distinct: options[:distinct])
|
175
|
-
|
176
|
-
|
177
|
-
definition[column.prefixed_name] = column
|
168
|
+
definition[column.prefixed_name] ||= column
|
178
169
|
end
|
179
170
|
|
180
171
|
# Add tie-breaker using the PK
|
181
|
-
|
182
|
-
pk_column = Column.new(ar_model, ar_model_primary_key, direction: :asc)
|
183
|
-
definition[pk_column.prefixed_name] = pk_column
|
184
|
-
end
|
172
|
+
add_pk_tiebreaker_column!
|
185
173
|
|
186
174
|
columns.first.as_leftmost!
|
187
175
|
end
|
188
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
|
+
|
189
192
|
# Returns an array of SELECT statement alias of the ordered columns
|
190
193
|
#
|
191
194
|
# @return [Array<String>] column SELECT aliases
|
data/lib/rotulus/page.rb
CHANGED
@@ -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
|
@@ -202,6 +202,8 @@ module Rotulus
|
|
202
202
|
|
203
203
|
private
|
204
204
|
|
205
|
+
delegate :model, to: :ar_relation, prefix: false
|
206
|
+
|
205
207
|
# If this is the root page or when paginating forward(#paged_forward), limit+1
|
206
208
|
# includes the first record of the next page. This lets us know whether there is a page
|
207
209
|
# succeeding the current page. When paginating backwards(#paged_back?), the limit+1 includes the
|
@@ -243,15 +245,11 @@ module Rotulus
|
|
243
245
|
# to filter next/prev page's records. Alias and normalize those columns so we can access
|
244
246
|
# the values using record#slice.
|
245
247
|
def select_columns
|
246
|
-
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)]
|
247
249
|
base_select_values << order.select_sql
|
248
250
|
base_select_values
|
249
251
|
end
|
250
252
|
|
251
|
-
def select_all_sql
|
252
|
-
Rotulus.db.select_all_sql(model.table_name)
|
253
|
-
end
|
254
|
-
|
255
253
|
def order_by_sql
|
256
254
|
return order.reversed_sql if paged_back?
|
257
255
|
|
@@ -265,16 +263,8 @@ module Rotulus
|
|
265
263
|
limit >= 1 && limit <= config.page_max_limit
|
266
264
|
end
|
267
265
|
|
268
|
-
def model
|
269
|
-
ar_relation.model
|
270
|
-
end
|
271
|
-
|
272
266
|
def config
|
273
267
|
@config ||= Rotulus.configuration
|
274
268
|
end
|
275
|
-
|
276
|
-
def cursor_clazz
|
277
|
-
@cursor_clazz ||= config.cursor_class
|
278
|
-
end
|
279
269
|
end
|
280
270
|
end
|
data/lib/rotulus/version.rb
CHANGED
data/lib/rotulus.rb
CHANGED
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: 1.0
|
4
|
+
version: 2.1.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:
|
11
|
+
date: 2024-05-16 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.2'
|
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.2'
|
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.2'
|
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.2'
|
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:
|