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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 149f276bc80e599488039c9858f817000766d74d6a494c1447ec58167a83c424
4
- data.tar.gz: dad1c9cad5c214e88ede97573c18fbff1b65ed4a21e46b045a388910b325b5a7
3
+ metadata.gz: 7504d29a1bd4aac2f21ffb981abd98b544b8b50f80fa7f875cc73286e967e9a2
4
+ data.tar.gz: afbd5665291c310b0fd03e87cebd0b100da71dcfdedae7953b4844c767859ce3
5
5
  SHA512:
6
- metadata.gz: aab89cbb1f43d71ceddfe8cb1bb60d2d343f3d114109cb3d97d8650bca138d31893a1d803d7563b01ec51cca5eb3d227878f1fe2569d521edf6b9fc12ac2d364
7
- data.tar.gz: 73e0d95ca8771ca86517f6fcf3def575b5536b269f3d6cdb65c7be887f633fa872bb0d8bc479c7932a592ba0bd204f29d5ba7ba2a43acb54546981c65c62c102
6
+ metadata.gz: 19d4f2b1e1a734c78b8d63954ef66ba443591a7fc2c04364eccc6e1a0780b4f3e6346af327e53f3ba773bafca9a39b3abd5ef7efc8937b71a8fc534d7d16dc84
7
+ data.tar.gz: 0a739f5977b51127934658605a304f78a6be0ffe8d4e3e0ddce8fe4abc1223e6bf0c7ef1dfd39afd6b675f7006b9b3f4074fd11cf5329d9433886dd06dc18bd8
data/CHANGELOG.md CHANGED
@@ -15,4 +15,7 @@
15
15
 
16
16
  ## 1.0.0
17
17
  - Allow changing of ar_relation and order by default.
18
- - Make error class names consistent.
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,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 being added, updated, and removed in the database through the use of an encoded cursor token.
7
+ Cursor-based pagination is an alternative to OFFSET-based pagination that provides a more stable and predictable pagination behavior as records are added, updated, and removed in the database through the use of an encoded cursor token.
8
8
 
9
9
  Some advantages of this approach are:
10
10
 
11
- * Reduces inaccuracies such as duplicate/skipped records due to records being actively manipulated in the DB.
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
- * Sort records by multiple/any number of columns
18
- * Sort records using columns from joined tables
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. But for more configuration options:
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
- config.page_default_limit = 5
55
- config.page_max_limit = 50
56
- config.secret = ENV["MY_ENV_VAR"]
57
- config.token_expires_in = 10800
58
- config.cursor_class = MyCursor
59
- config.restrict_order_change = false
60
- config.restrict_query_change = false
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
- With the example above, the gem will automatically add the table's PK(`users.id`) in the generated SQL query as the tie-breaker column to ensure stable sorting and pagination.
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. currently in first page), `nil` is returned
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 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/>|
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 record unique in the dataset. <br/>
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
- <br/>
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 configurations. |
307
- | `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. |
308
- | `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. |
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 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:
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 generated snippets
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
- "s": "251177d65873aa37057dba548ecba82f",
382
- "c": 1672502400
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, restrict clients/third-parties from generating their own (unsafe)tokens, or from tampering the data of an existing token.
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 neede to detect whether the ar_relation has changed (e.g. filter/query changed due to API params).
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 create a subclass of `Rotulus::Cursor` with `.decode` and `.encode` methods implemented.
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-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```|
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/>
@@ -26,14 +26,11 @@ module Rotulus
26
26
  def initialize(model, name, direction: :asc, nullable: nil, nulls: nil, distinct: nil)
27
27
  @model = model
28
28
  @name = name.to_s
29
- unless name_valid?
30
- raise Rotulus::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.to_s.downcase == 'desc' ? :desc : :asc
35
- @distinct = (distinct.nil? ? primary_key? : distinct).presence || false
36
- @nullable = (nullable.nil? ? metadata&.null : nullable).presence || false
31
+ @direction = sort_direction(direction)
32
+ @distinct = uniqueness(distinct)
33
+ @nullable = nullability(nullable)
37
34
  @nulls = nulls_order(nulls)
38
35
  end
39
36
 
@@ -150,8 +147,27 @@ module Rotulus
150
147
  Rotulus.db.default_nulls_order(direction)
151
148
  end
152
149
 
150
+ def nullability(nullable)
151
+ (nullable.nil? ? metadata&.null : nullable).presence || false
152
+ end
153
+
153
154
  def primary_key?
154
155
  unprefixed_name == model.primary_key
155
156
  end
157
+
158
+ def sort_direction(direction)
159
+ direction.to_s.downcase == 'desc' ? :desc : :asc
160
+ end
161
+
162
+ def uniqueness(unique)
163
+ (unique.nil? ? primary_key? : unique).presence || false
164
+ end
165
+
166
+ def validate_name!
167
+ return if name_valid?
168
+
169
+ raise Rotulus::InvalidColumn.new("Column/table name must contain letters, digits (0-9), or \
170
+ underscores and must begin with a letter or underscore.".squish)
171
+ end
156
172
  end
157
173
  end
@@ -38,18 +38,19 @@ module Rotulus
38
38
  end
39
39
 
40
40
  def nullable_filter_condition
41
- if seek_to_null_direction?
42
- return tie_break null_condition if value.nil?
41
+ return seek_to_null_direction_condition if seek_to_null_direction?
42
+ return filter_condition unless value.nil?
43
43
 
44
- condition = "#{seek_condition} OR #{null_condition}"
45
- return condition if column.distinct?
44
+ "#{not_null_condition} OR (#{tie_break(null_condition)})"
45
+ end
46
46
 
47
- prefilter("(#{condition}) OR (#{tie_break(identity)})")
48
- else
49
- return filter_condition unless value.nil?
47
+ def seek_to_null_direction_condition
48
+ return tie_break null_condition if value.nil?
50
49
 
51
- "#{not_null_condition} OR (#{tie_break(null_condition)})"
52
- end
50
+ condition = "#{seek_condition} OR #{null_condition}"
51
+ return condition if column.distinct?
52
+
53
+ prefilter("(#{condition}) OR (#{tie_break(identity)})")
53
54
  end
54
55
 
55
56
  def identity
@@ -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[: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
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
- Oj.load(Base64.urlsafe_decode64(token))
52
- rescue ArgumentError, Oj::ParseError => e
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(Oj.dump(token_data, symbol_keys: true))
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: record.values,
108
- d: direction,
109
- c: created_at.to_i,
110
- cs: state,
111
- os: page.order_state,
112
- qs: page.query_state)
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 = Oj.dump(to_h, mode: :rails)
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
- unless options.is_a?(Hash)
161
- options = if options.to_s.downcase == 'desc'
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
- next unless definition[column.prefixed_name].nil?
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
- unless primary_key_ordered?
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? ? cursor_clazz.for_page_and_token!(self, token) : nil
80
+ @cursor = token.present? ? config.cursor_class.for_page_and_token!(self, token) : nil
81
81
 
82
82
  reload
83
83
  end
@@ -131,7 +131,7 @@ module Rotulus
131
131
  record = cursor_reference_record(:next)
132
132
  return if record.nil?
133
133
 
134
- cursor_clazz.new(record, :next).to_token
134
+ config.cursor_class.new(record, :next).to_token
135
135
  end
136
136
 
137
137
  # Generate the cursor token to access the previous page if one exists
@@ -143,7 +143,7 @@ module Rotulus
143
143
  record = cursor_reference_record(:prev)
144
144
  return if record.nil?
145
145
 
146
- cursor_clazz.new(record, :prev).to_token
146
+ config.cursor_class.new(record, :prev).to_token
147
147
  end
148
148
 
149
149
  # Next page instance
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Rotulus
2
- VERSION = '1.0.0'.freeze
2
+ VERSION = '2.1.0'.freeze
3
3
  end
data/lib/rotulus.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  require 'active_record'
2
2
  require 'active_support'
3
3
  require 'active_support/core_ext/string/inquiry'
4
- require 'oj'
4
+ require 'multi_json'
5
5
  require 'rotulus/version'
6
6
  require 'rotulus/configuration'
7
7
  require 'rotulus/db/database'
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.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: 2023-05-25 00:00:00.000000000 Z
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.1'
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.1'
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.1'
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.1'
52
+ version: '7.2'
53
53
  - !ruby/object:Gem::Dependency
54
- name: oj
54
+ name: multi_json
55
55
  requirement: !ruby/object:Gem::Requirement
56
56
  requirements:
57
- - - ">="
57
+ - - "~>"
58
58
  - !ruby/object:Gem::Version
59
- version: '0'
59
+ version: '1.15'
60
60
  type: :runtime
61
61
  prerelease: false
62
62
  version_requirements: !ruby/object:Gem::Requirement
63
63
  requirements:
64
- - - ">="
64
+ - - "~>"
65
65
  - !ruby/object:Gem::Version
66
- version: '0'
66
+ version: '1.15'
67
67
  description: Cursor-based pagination for Rails/ActiveRecord apps with multiple column
68
68
  sort and custom cursor format support for a more stable and predictable pagination.
69
69
  email: