rotulus 1.0.0 → 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 +4 -1
- data/README.md +88 -29
- 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 +7 -7
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
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,21 +46,24 @@ 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
|
-
|
61
|
-
end
|
62
|
-
```
|
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
|
+
```
|
63
67
|
|
64
68
|
| Configuration | Description |
|
65
69
|
| ----------- | ----------- |
|
@@ -70,7 +74,8 @@ end
|
|
70
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. |
|
71
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`. |
|
72
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). |
|
73
|
-
<br/>
|
77
|
+
<br/>
|
78
|
+
</details>
|
74
79
|
|
75
80
|
|
76
81
|
## Usage
|
@@ -128,7 +133,7 @@ In case there is no next page, `nil` is returned
|
|
128
133
|
page.prev_token
|
129
134
|
=> "eyI6ZiI6eyJebyI6..."
|
130
135
|
```
|
131
|
-
In case there is no previous page(i.e
|
136
|
+
In case there is no previous page(i.e., first page), `nil` is returned
|
132
137
|
|
133
138
|
|
134
139
|
#### Navigate to the page given a cursor
|
@@ -172,6 +177,14 @@ page.reload
|
|
172
177
|
page.reload.records
|
173
178
|
```
|
174
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
|
+
|
175
188
|
#### Print page in table format for debugging
|
176
189
|
Currently, only the columns included in `ORDER BY` are shown:
|
177
190
|
|
@@ -196,10 +209,10 @@ Instead of just specifying the column sorting such as ```{ first_name: :asc }```
|
|
196
209
|
| Column Configuration | Description |
|
197
210
|
| ----------- | ----------- |
|
198
211
|
| `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.
|
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.
|
200
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`. |
|
201
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. |
|
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
|
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/>|
|
203
216
|
|
204
217
|
|
205
218
|
##### Example:
|
@@ -262,7 +275,7 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
|
|
262
275
|
```
|
263
276
|
|
264
277
|
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
|
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/>
|
266
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/>
|
267
280
|
3. Explicitly setting the `model: OrderItem` in joined table columns is required for now.
|
268
281
|
|
@@ -291,7 +304,51 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
|
|
291
304
|
|
292
305
|
```
|
293
306
|
|
294
|
-
|
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
|
+
```
|
295
352
|
|
296
353
|
### Errors
|
297
354
|
|
@@ -315,7 +372,7 @@ Cursor-based pagination uses a reference point/record to fetch the previous or n
|
|
315
372
|
* Columns used in `ORDER BY` would need to be indexed as they will be used in filtering.
|
316
373
|
|
317
374
|
|
318
|
-
#### Sample SQL
|
375
|
+
#### Sample SQL-generated snippets
|
319
376
|
|
320
377
|
##### Example 1: With order by `id` only
|
321
378
|
###### Ruby
|
@@ -376,20 +433,22 @@ To navigate between pages, a cursor is used. The cursor token is a Base64 encode
|
|
376
433
|
|
377
434
|
```json
|
378
435
|
{
|
379
|
-
"f": {"users.first_name": "Jane", "users.id": 2},
|
436
|
+
"f": { "users.first_name": "Jane", "users.id": 2 },
|
380
437
|
"d": "next",
|
381
|
-
"
|
382
|
-
"
|
438
|
+
"c": 1672502400,
|
439
|
+
"cs": "fe6ac1a1d6a1fc1b7f842b388639f63b",
|
440
|
+
"os": "62186497a8073f9c7072389b73c6c60c",
|
441
|
+
"qs": "7a5053198709df924dd5ec1752ee4e6b"
|
383
442
|
}
|
384
443
|
```
|
385
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.
|
386
445
|
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,
|
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.
|
388
447
|
4. `os` - the order state needed to detect whether the order definition changed.
|
389
|
-
5. `qs` - the base AR relation state
|
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).
|
390
449
|
4. `c` - cursor token issuance time.
|
391
450
|
|
392
|
-
A condition generated from the cursor above would look like:
|
451
|
+
A condition generated from the cursor above would look like this:
|
393
452
|
|
394
453
|
```sql
|
395
454
|
WHERE users.first_name >= 'Jane' AND (
|
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:
|
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
|
@@ -51,19 +51,19 @@ dependencies:
|
|
51
51
|
- !ruby/object:Gem::Version
|
52
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:
|