rotulus 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +46 -0
- data/LICENSE +21 -0
- data/README.md +475 -0
- data/lib/rotulus/column.rb +157 -0
- data/lib/rotulus/column_condition_builder.rb +115 -0
- data/lib/rotulus/configuration.rb +74 -0
- data/lib/rotulus/cursor.rb +152 -0
- data/lib/rotulus/db/database.rb +66 -0
- data/lib/rotulus/db/mysql.rb +31 -0
- data/lib/rotulus/db/postgresql.rb +13 -0
- data/lib/rotulus/db/sqlite.rb +6 -0
- data/lib/rotulus/order.rb +171 -0
- data/lib/rotulus/page.rb +278 -0
- data/lib/rotulus/page_tableizer.rb +143 -0
- data/lib/rotulus/record.rb +66 -0
- data/lib/rotulus/version.rb +3 -0
- data/lib/rotulus.rb +42 -0
- metadata +122 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b01279029660a73c745ba2c67c703454f54f44a160b5964523c0fd521ca973c0
|
4
|
+
data.tar.gz: b1479c0f2a2b8971e4d8977f4b5e4a052829d2d3f472fe1199497266d8d3cafe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 12120dce0516e9823e891dea5bfe76af1148234e18a556f1d889d1b5ec68031746afbe6e0599955ced295de214d6eedfa3b560ffdce03e0c49e94561a3c5399a
|
7
|
+
data.tar.gz: 6b10d0bab75dffd5f1c988f09161a56cdc0688e50af403864a9b15c6d6b0b2a1191eddff5606be86e516ba3ba785a96cbbaa39dbb963593a00108fb4aee8bc23
|
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
gemspec
|
3
|
+
|
4
|
+
rails_version = ENV['RAILS_VERSION'] || '7-0-stable'
|
5
|
+
|
6
|
+
gem 'rake'
|
7
|
+
gem 'rspec'
|
8
|
+
|
9
|
+
if ENV.fetch('COVERAGE', nil) == 'true'
|
10
|
+
gem 'simplecov'
|
11
|
+
gem 'simplecov-cobertura'
|
12
|
+
end
|
13
|
+
|
14
|
+
case rails_version
|
15
|
+
when '4-2-stable'
|
16
|
+
# Ruby 2.2 or newer.
|
17
|
+
gem 'pg', '~> 0.15'
|
18
|
+
gem 'sqlite3', '~> 1.3.6'
|
19
|
+
gem 'mysql2', '~> 0.4.4'
|
20
|
+
when '5-0-stable', '5-1-stable', '5-2-stable'
|
21
|
+
# Ruby 2.2.2 or newer.
|
22
|
+
gem 'pg', '~> 0.18'
|
23
|
+
gem 'sqlite3', '~> 1.3.6'
|
24
|
+
gem 'mysql2', '~> 0.4.4'
|
25
|
+
when '6-0-stable'
|
26
|
+
# Ruby 2.5.0 or newer.
|
27
|
+
gem 'pg', '~> 0.18'
|
28
|
+
gem 'sqlite3', '~> 1.4'
|
29
|
+
gem 'mysql2', '>= 0.4.4'
|
30
|
+
when '6-1-stable'
|
31
|
+
# Ruby 2.5.0 or newer.
|
32
|
+
gem 'pg', '~> 1.1'
|
33
|
+
gem 'sqlite3', '~> 1.4'
|
34
|
+
gem 'mysql2', '~> 0.5'
|
35
|
+
when '7-0-stable'
|
36
|
+
# Ruby 2.7.0 or newer.
|
37
|
+
gem 'pg', '~> 1.1'
|
38
|
+
gem 'sqlite3', '~> 1.4'
|
39
|
+
gem 'mysql2', '~> 0.5'
|
40
|
+
else
|
41
|
+
gem 'pg'
|
42
|
+
gem 'sqlite3'
|
43
|
+
gem 'mysql2'
|
44
|
+
end
|
45
|
+
|
46
|
+
gem 'rails', git: 'https://github.com/rails/rails', branch: rails_version
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2023 Jayson Uy
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,475 @@
|
|
1
|
+
# Rotulus
|
2
|
+
|
3
|
+
[](https://github.com/jsonb-uy/rotulus/actions/workflows/ci.yml) [](https://codecov.io/gh/jsonb-uy/rotulus)
|
4
|
+
|
5
|
+
### Cursor-based pagination for apps built on Rails/ActiveRecord
|
6
|
+
|
7
|
+
Cursor-based pagination is an alternative to OFFSET-based pagination that provides a more stable and predictable pagination behavior as records are being added, updated, and removed in the database through the use of an encoded cursor token.
|
8
|
+
|
9
|
+
Some advantages of this approach are:
|
10
|
+
|
11
|
+
* Reduces inaccuracies such as duplicate/skipped records due to records being actively manipulated in the DB.
|
12
|
+
* Can significantly improve performance(with proper DB indexing on ordered columns) especially as you move forward on large datasets.
|
13
|
+
|
14
|
+
|
15
|
+
## Features
|
16
|
+
|
17
|
+
* Sort records by multiple/any number of columns
|
18
|
+
* Sort records using columns from joined tables
|
19
|
+
* `NULLS FIRST`/`NULLS LAST` handling
|
20
|
+
* Allows custom cursor format
|
21
|
+
* Built-in cursor token expiration
|
22
|
+
* Built-in cursor integrity check
|
23
|
+
* Supports **MySQL**, **PostgreSQL**, and **SQLite**
|
24
|
+
* Supports **Rails 4.2** and above
|
25
|
+
|
26
|
+
|
27
|
+
## Installation
|
28
|
+
|
29
|
+
Add this line to your application's Gemfile:
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
gem 'rotulus'
|
33
|
+
```
|
34
|
+
|
35
|
+
And then execute:
|
36
|
+
|
37
|
+
```sh
|
38
|
+
bundle install
|
39
|
+
```
|
40
|
+
|
41
|
+
Or install it yourself as:
|
42
|
+
|
43
|
+
```sh
|
44
|
+
gem install rotulus
|
45
|
+
```
|
46
|
+
|
47
|
+
## Configuration
|
48
|
+
At the very least you only need to set the environment variable `ROTULUS_SECRET` to a random string(e.g. generate via `rails secret`).
|
49
|
+
For more configuration options:
|
50
|
+
|
51
|
+
#### Create an initializer `config/initializers/rotulus.rb`:
|
52
|
+
|
53
|
+
```ruby
|
54
|
+
Rotulus.configure do |config|
|
55
|
+
config.page_default_limit = 5
|
56
|
+
config.page_max_limit = 50
|
57
|
+
config.secret = ENV["MY_ENV_VAR"]
|
58
|
+
config.token_expires_in = 10800
|
59
|
+
config.cursor_class = MyCursor
|
60
|
+
end
|
61
|
+
```
|
62
|
+
|
63
|
+
| Configuration | Description |
|
64
|
+
| ----------- | ----------- |
|
65
|
+
| `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(...)` |
|
66
|
+
| `page_max_limit` | **Default: 50** <br/> Maximum `:limit` value allowed when initializing a page.|
|
67
|
+
| `secret` | **Default: ENV['ROTULUS_SECRET']** <br/> Key needed to generate the cursor state. |
|
68
|
+
| `token_expires_in` | **Default: 259200**(3 days) <br/> Validity period of a cursor token (in seconds). Set to `nil` to disable token expiration. |
|
69
|
+
| `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). |
|
70
|
+
<br/>
|
71
|
+
|
72
|
+
|
73
|
+
## Usage
|
74
|
+
|
75
|
+
### Basic Usage
|
76
|
+
|
77
|
+
#### Initialize a page
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
users = User.where('age > ?', 16)
|
81
|
+
|
82
|
+
page = Rotulus::Page.new(users, order: { first_name: :asc, last_name: :desc }, limit: 3)
|
83
|
+
```
|
84
|
+
Example above will automatically add the table's PK(`users.id`) in the generated SQL query as tie-breaker if the PK isn't included in the `:order` column config yet.
|
85
|
+
|
86
|
+
|
87
|
+
###### Example with `ORDER BY users.id asc` only:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
page = Rotulus::Page.new(users, order: { id: :asc } limit: 3)
|
91
|
+
|
92
|
+
# OR
|
93
|
+
|
94
|
+
page = Rotulus::Page.new(users, limit: 3)
|
95
|
+
```
|
96
|
+
|
97
|
+
#### Access the page records
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
page.records
|
101
|
+
=> [#<User id: 11, first_name: 'John'...]
|
102
|
+
```
|
103
|
+
|
104
|
+
#### Check if a next page exists
|
105
|
+
|
106
|
+
```ruby
|
107
|
+
page.next?
|
108
|
+
=> true
|
109
|
+
```
|
110
|
+
#### Check if a previous page exists
|
111
|
+
|
112
|
+
```ruby
|
113
|
+
page.prev?
|
114
|
+
=> false
|
115
|
+
```
|
116
|
+
|
117
|
+
#### Get the cursor to access the next page
|
118
|
+
```ruby
|
119
|
+
page.next_token
|
120
|
+
=> "eyI6ZiI6eyJebyI6..."
|
121
|
+
```
|
122
|
+
In case there is no next page, `nil` is returned
|
123
|
+
|
124
|
+
#### Get the cursor to access the previous page
|
125
|
+
```ruby
|
126
|
+
page.prev_token
|
127
|
+
=> "eyI6ZiI6eyJebyI6..."
|
128
|
+
```
|
129
|
+
In case there is no previous page(i.e. currently in first page), `nil` is returned
|
130
|
+
|
131
|
+
|
132
|
+
#### Navigate to the page given a cursor
|
133
|
+
##### Return a new page instance pointed at the given cursor
|
134
|
+
```ruby
|
135
|
+
another_page = page.at('eyI6ZiI6eyJebyI6...')
|
136
|
+
=> #<Rotulus::Page ..>
|
137
|
+
```
|
138
|
+
|
139
|
+
Or to immediately get the records:
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
page.at(next_page_token).records
|
143
|
+
```
|
144
|
+
|
145
|
+
##### Return the same page instance pointed at the given cursor
|
146
|
+
```ruby
|
147
|
+
page.at!('eyI6ZiI6eyJebyI6...')
|
148
|
+
=> #<Rotulus::Page ..>
|
149
|
+
```
|
150
|
+
|
151
|
+
#### Get the next page
|
152
|
+
```ruby
|
153
|
+
next_page = page.next
|
154
|
+
```
|
155
|
+
This is the same as `page.at(page.next_token)`. Returns `nil` if there is no next page.
|
156
|
+
|
157
|
+
#### Get the previous page
|
158
|
+
```ruby
|
159
|
+
previous_page = page.prev
|
160
|
+
```
|
161
|
+
This is the same as `page.at(page.prev_token)`. Returns `nil` if there is no previous page.
|
162
|
+
|
163
|
+
|
164
|
+
### Extras
|
165
|
+
#### Reload page
|
166
|
+
```ruby
|
167
|
+
page.reload
|
168
|
+
|
169
|
+
# reload then return records
|
170
|
+
page.reload.records
|
171
|
+
```
|
172
|
+
|
173
|
+
#### Print page in table format for debugging
|
174
|
+
Currently, only the columns included in `ORDER BY` are shown:
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
puts page.as_table
|
178
|
+
|
179
|
+
+------------------------------------------------------------+
|
180
|
+
| users.first_name | users.last_name | users.id |
|
181
|
+
+------------------------------------------------------------+
|
182
|
+
| George | <NULL> | 1 |
|
183
|
+
| Jane | Smith | 3 |
|
184
|
+
| Jane | Doe | 2 |
|
185
|
+
+------------------------------------------------------------+
|
186
|
+
```
|
187
|
+
|
188
|
+
<br/>
|
189
|
+
|
190
|
+
### Advanced Usage
|
191
|
+
#### Expanded order definition
|
192
|
+
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:
|
193
|
+
|
194
|
+
| Column Configuration | Description |
|
195
|
+
| ----------- | ----------- |
|
196
|
+
| `direction` | **Default: :asc**. `:asc` or `:desc` |
|
197
|
+
| `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.
|
198
|
+
| `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`. |
|
199
|
+
| `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. |
|
200
|
+
| `model` | **Default:**<br/> - the model of the base AR relation passed to `Rotulus::Page.new(<ar_relation>)` if column name has no prefix(e.g. `first_name`) and the AR relation model has a column matching the column name.<br/>- the model of the base AR relation passed to `Rotulus::Page.new(<ar_relation>)` if column name has a prefix(e.g. `users.first_name`) and thre prefix matches the AR relation's table name and the table has a column matching the column name. <br/><br/>Model where this column belongs. This allows the gem to infer the nullability and uniqueness from the column definition in its table instead of manually setting the `nullable` or `distinct` options and to also automatically prefix the column name with the table name. <br/>|
|
201
|
+
|
202
|
+
|
203
|
+
##### Example:
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
order = {
|
207
|
+
first_name: :asc,
|
208
|
+
last_name: {
|
209
|
+
direction: :desc,
|
210
|
+
nullable: true,
|
211
|
+
nulls: :last
|
212
|
+
},
|
213
|
+
email: {
|
214
|
+
distinct: true
|
215
|
+
}
|
216
|
+
}
|
217
|
+
page = Rotulus::Page.new(users, order: order, limit: 3)
|
218
|
+
|
219
|
+
```
|
220
|
+
<br/>
|
221
|
+
|
222
|
+
#### Queries with `JOIN`ed tables
|
223
|
+
##### Example:
|
224
|
+
|
225
|
+
Suppose the requirement is to:<br/>
|
226
|
+
1. Get all `Item` records.<br/>
|
227
|
+
2. If an `Item` record has associated `OrderItem` records, get the order ids.<br/>
|
228
|
+
3. `Item` records with `OrderItem`s should come first.
|
229
|
+
4. `Item` records with `OrderItem`s should be sorted by `item_count` in descending order. <br/>
|
230
|
+
5. If multiple rows have the same `item_count` value, sort them by item name in ascending order. <br/>
|
231
|
+
6. If multiple rows have the same `item_count` value and the same `name`, sort them by `OrderItem` id. <br/>
|
232
|
+
7. Sort `Item` records with no `OrderItem`, based on the item name in ascending order (tie-breaker). <br/>
|
233
|
+
8. Sort `Item` records with no `OrderItem` and having the same name by the item id (also tie-breaker).
|
234
|
+
|
235
|
+
##### Our solution would be:
|
236
|
+
|
237
|
+
```ruby
|
238
|
+
items = Item.all # Requirement 1
|
239
|
+
.joins("LEFT JOIN order_items oi ON oi.item_id = items.id") # Requirement 2
|
240
|
+
.select('oi.order_id', 'items.*') # Requirement 2
|
241
|
+
|
242
|
+
order_by = {
|
243
|
+
'oi.item_count' => {
|
244
|
+
direction: :desc, # Requirement 4
|
245
|
+
nulls: :last, # Requirement 3
|
246
|
+
nullable: true, # Requirement 1
|
247
|
+
model: OrderItem
|
248
|
+
},
|
249
|
+
name: :asc, # Requirement 5, 7
|
250
|
+
'oi.id' => {
|
251
|
+
direction: :asc, # Requirement 6
|
252
|
+
distinct: true, # Requirement 6
|
253
|
+
nullable: true, # Requirement 1
|
254
|
+
model: OrderItem
|
255
|
+
},
|
256
|
+
id: :asc # Requirement 8
|
257
|
+
}
|
258
|
+
page = Rotulus::Page.new(items, order: order_by, limit: 2)
|
259
|
+
|
260
|
+
```
|
261
|
+
|
262
|
+
Some notes for the example above: <br/>
|
263
|
+
1. `oi.id` is needed to uniquely identify and serve as the tie-breaker for `Item`s that have `OrderItem`s having the same item_count and name. The combination of `oi.item_count`, `items.name`, and `oi.id` makes those record unique in the dataset. <br/>
|
264
|
+
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/>
|
265
|
+
3. Explicitly setting the `model: OrderItem` in joined table columns is required for now.
|
266
|
+
|
267
|
+
An alternate solution that would also avoid N+1 if the `OrderItem` instances are to be accessed:
|
268
|
+
|
269
|
+
```ruby
|
270
|
+
items = Item.all # Requirement 1
|
271
|
+
.eager_load(:order_items) # Requirement 2
|
272
|
+
|
273
|
+
order_by = {
|
274
|
+
item_count: {
|
275
|
+
direction: :desc, # Requirement 4
|
276
|
+
nulls: :last, # Requirement 3
|
277
|
+
nullable: true, # Requirement 1
|
278
|
+
model: OrderItem
|
279
|
+
},
|
280
|
+
name: :asc, # Requirement 5, 7
|
281
|
+
'order_items.id' => {
|
282
|
+
direction: :asc, # Requirement 6
|
283
|
+
distinct: true, # Requirement 6
|
284
|
+
nullable: true, # Requirement 1
|
285
|
+
model: OrderItem
|
286
|
+
}
|
287
|
+
}
|
288
|
+
page = Rotulus::Page.new(items, order: order_by, limit: 2)
|
289
|
+
|
290
|
+
```
|
291
|
+
|
292
|
+
<br/>
|
293
|
+
|
294
|
+
### Errors
|
295
|
+
|
296
|
+
| Class | Description |
|
297
|
+
| ----------- | ----------- |
|
298
|
+
| `Rotulus::InvalidCursor` | Cursor token received is invalid e.g., unrecognized token, token data has been tampered/updated, base ActiveRecord relation filter/sorting/limit is no longer consistent to the token/ |
|
299
|
+
| `Rotulus::Expired` | Cursor token received has expired based on the configured `token_expires_in` |
|
300
|
+
| `Rotulus::InvalidLimit` | Limit set to Rotulus::Page is not valid. e.g., exceeds the configured limit. see `config.page_max_limit` |
|
301
|
+
| `Rotulus::CursorError` | Generic error for cursor related validations |
|
302
|
+
| `Rotulus:: InvalidColumnError` | Column provided in the :order param is can't be recognized. |
|
303
|
+
| `Rotulus::ConfigurationError` | Generic error for missing/invalid configurations. |
|
304
|
+
|
305
|
+
## How it works
|
306
|
+
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:
|
307
|
+
|
308
|
+
* Records are sorted (`ORDER BY`).
|
309
|
+
* In case multiple records with the same column value(s) exists in the result, a unique 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.
|
310
|
+
* Columns used in `ORDER BY` would need to be indexed as they will be used in filtering.
|
311
|
+
|
312
|
+
|
313
|
+
#### Sample SQL generated snippets
|
314
|
+
|
315
|
+
##### Example 1: With order by `id` only
|
316
|
+
###### Ruby
|
317
|
+
```ruby
|
318
|
+
page = Rotulus::Page.new(User.all, limit: 3)
|
319
|
+
```
|
320
|
+
|
321
|
+
###### SQL:
|
322
|
+
```sql
|
323
|
+
WHERE
|
324
|
+
users.id > ?
|
325
|
+
ORDER BY
|
326
|
+
users.id asc LIMIT 3
|
327
|
+
```
|
328
|
+
|
329
|
+
##### Example 2: With non-distinct and not nullable column `first_name`
|
330
|
+
###### Ruby
|
331
|
+
```ruby
|
332
|
+
page = Rotulus::Page.new(User.all, order: { first_name: :asc }, limit: 3)
|
333
|
+
```
|
334
|
+
|
335
|
+
###### SQL:
|
336
|
+
```sql
|
337
|
+
WHERE
|
338
|
+
users.first_name >= ? AND
|
339
|
+
(users.first_name > ? OR
|
340
|
+
(users.first_name = ? AND
|
341
|
+
users.id > ?))
|
342
|
+
ORDER BY
|
343
|
+
users.first_name asc,
|
344
|
+
users.id asc LIMIT 3
|
345
|
+
```
|
346
|
+
|
347
|
+
##### Example 3: With non-distinct and nullable(nulls last) column `last_name`
|
348
|
+
###### Ruby
|
349
|
+
```ruby
|
350
|
+
page = Rotulus::Page.new(User.all, order: { first_name: { direction: :asc, nulls: :last }}, limit: 3)
|
351
|
+
```
|
352
|
+
|
353
|
+
###### SQL:
|
354
|
+
```sql
|
355
|
+
-- if first_name value of the last record on current page is not null:
|
356
|
+
WHERE ((users.last_name >= ? OR users.last_name IS NULL) AND
|
357
|
+
((users.last_name > ? OR users.last_name IS NULL)
|
358
|
+
OR (users.last_name = ? AND users.id > ?)))
|
359
|
+
ORDER BY users.last_name asc nulls last, users.id asc LIMIT 3
|
360
|
+
|
361
|
+
-- if first_name value of the last record on current page is null:
|
362
|
+
WHERE users.last_name IS NULL AND users.id > ?
|
363
|
+
ORDER BY users.last_name asc nulls last, users.id asc LIMIT 3
|
364
|
+
```
|
365
|
+
|
366
|
+
|
367
|
+
### Cursor
|
368
|
+
To navigate between pages, a cursor is used. The cursor token is a Base64 encoded string containing the data on how to filter the next/previous page's records. A decoded cursor to access the next page would look like:
|
369
|
+
|
370
|
+
#### Decoded Cursor
|
371
|
+
|
372
|
+
```json
|
373
|
+
{
|
374
|
+
"f": {"users.first_name": "Jane", "users.id": 2},
|
375
|
+
"d": "next",
|
376
|
+
"s": "251177d65873aa37057dba548ecba82f",
|
377
|
+
"c": 1672502400
|
378
|
+
}
|
379
|
+
```
|
380
|
+
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.
|
381
|
+
2. `d` - the pagination direction. `next` or `prev` set of records from the reference values in "f".
|
382
|
+
3. `s` - the cursor state needed for integrity check so we can detect whether the base ActiveRecord relation filter/sorting is no longer consistent(e.g. API request params changed) with the cursor token. Additionally, to restrict clients/third-parties from generating their own (unsafe)tokens or to tamper the data of an existing token. The gem requires a secret key configured for this through the `ROTULUS_SECRET` environment variable or the `config.secret` configuration. see [Configuration](#configuration) section.
|
383
|
+
4. `c` - the time when this cursor was generated.
|
384
|
+
|
385
|
+
A condition generated from the cursor above would look like:
|
386
|
+
|
387
|
+
```sql
|
388
|
+
WHERE users.first_name >= 'Jane' AND (
|
389
|
+
users.first_name > 'Jane' OR (
|
390
|
+
users.first_name = 'Jane' AND (users.id > 2)
|
391
|
+
)
|
392
|
+
) LIMIT N
|
393
|
+
```
|
394
|
+
|
395
|
+
#### Custom Token Format
|
396
|
+
By default, the cursor is encoded as a Base64 token. To customize how the cursor is encoded and decoded, you may just create a subclass of `Rotulus::Cursor` with `.decode` and `.encode` methods implemented.
|
397
|
+
|
398
|
+
##### Example:
|
399
|
+
The implementation below would generate tokens in UUID format where the actual cursor data is stored in memory:
|
400
|
+
|
401
|
+
```ruby
|
402
|
+
class MyCustomCursor < Rotulus::Cursor
|
403
|
+
def self.decode(token)
|
404
|
+
data = storage[token]
|
405
|
+
return data if data.present?
|
406
|
+
|
407
|
+
raise Rotulus::InvalidCursor
|
408
|
+
end
|
409
|
+
|
410
|
+
def self.encode(data)
|
411
|
+
storage_key = SecureRandom.uuid
|
412
|
+
|
413
|
+
storage[storage_key] = data
|
414
|
+
storage_key
|
415
|
+
end
|
416
|
+
|
417
|
+
def self.storage
|
418
|
+
@storage ||= {}
|
419
|
+
end
|
420
|
+
end
|
421
|
+
```
|
422
|
+
|
423
|
+
###### config/initializers/rotulus.rb
|
424
|
+
```ruby
|
425
|
+
Rotulus.configure do |config|
|
426
|
+
...
|
427
|
+
config.cursor_class = MyCustomCursor
|
428
|
+
end
|
429
|
+
```
|
430
|
+
<br/>
|
431
|
+
|
432
|
+
### Limitations
|
433
|
+
1. Custom SQL in `ORDER BY` expression other than sorting by table column values aren't supported to leverage the index usage.
|
434
|
+
2. `ORDER BY` column names with characters other than alphanumeric and underscores are not supported.
|
435
|
+
|
436
|
+
### Considerations
|
437
|
+
1. Although adding indexes improves DB read performance, it can impact write performance. Only expose/whitelist the columns that are really needed in sorting.
|
438
|
+
2. Depending on your use case, a disadvantage is that cursor-based pagination does not allow jumping to a specific page (no page numbers).
|
439
|
+
|
440
|
+
|
441
|
+
## Development
|
442
|
+
|
443
|
+
1. If testing/developing for MySQL or PG, create the database first:<br/>
|
444
|
+
|
445
|
+
###### MySQL
|
446
|
+
```sh
|
447
|
+
mysql> CREATE DATABASE rotulus;
|
448
|
+
```
|
449
|
+
|
450
|
+
###### PostgreSQL
|
451
|
+
```sh
|
452
|
+
$ createdb rotulus
|
453
|
+
```
|
454
|
+
|
455
|
+
2. After checking out the repo, run `bin/setup` to install dependencies.
|
456
|
+
3. Run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. Use the environment variables below to target the database<br/><br/>
|
457
|
+
|
458
|
+
By default, SQLite and the latest stable Rails version are used in tests and console. Refer to the environment variables below to change this:
|
459
|
+
|
460
|
+
| Environment Variable | Values | Example |
|
461
|
+
| ----------- | ----------- |----------- |
|
462
|
+
| `DB_ADAPTER` | **Default: :sqlite**. `sqlite`,`mysql2`, or `postgresql` | ```DB_ADAPTER=postgresql bundle exec rspec```<br/><br/> ```DB_ADAPTER=postgresql ./bin/console``` |
|
463
|
+
| `RAILS_VERSION` | **Default: 7-0-stable** <br/><br/> `4-2-stable`,`5-0-stable`,`5-1-stable`,<br/>`5-2-stable`,`6-0-stable`,`6-1-stable`,<br/>`7-0-stable` |```RAILS_VERSION=5-2-stable ./bin/setup```<br/><br/>```RAILS_VERSION=5-2-stable bundle exec rspec```<br/><br/> ```RAILS_VERSION=5-2-stable ./bin/console```|
|
464
|
+
|
465
|
+
|
466
|
+
<br/><br/>
|
467
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
468
|
+
|
469
|
+
## Contributing
|
470
|
+
|
471
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/jsonb-uy/rotulus.
|
472
|
+
|
473
|
+
## License
|
474
|
+
|
475
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|