rotulus 0.2.3 → 1.0.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: 15fae43a1fc07b2c268c1604c065a12f0d2288d733219aa563348019a872d7a0
4
- data.tar.gz: ca8548477eee36f6bed018d8ec20d5dd5217f74d36954f13b4e243fbaee6755e
3
+ metadata.gz: 149f276bc80e599488039c9858f817000766d74d6a494c1447ec58167a83c424
4
+ data.tar.gz: dad1c9cad5c214e88ede97573c18fbff1b65ed4a21e46b045a388910b325b5a7
5
5
  SHA512:
6
- metadata.gz: f9d0e4611e800688f8c10ad7347043a07e7358f79a4254ecac6359a1f5f4101d323cfe0b21d45b363984cdc681e3ffc8b8b9587311e96553ab952eb76016792b
7
- data.tar.gz: a3240e33d45cd4360c4235e94860558c584a9f1f373c9ad7451ad9d819c2616f5a2e2146bef0449b716b846144ee87c19e92596c2b39c13e34bef254f49c50af
6
+ metadata.gz: aab89cbb1f43d71ceddfe8cb1bb60d2d343f3d114109cb3d97d8650bca138d31893a1d803d7563b01ec51cca5eb3d227878f1fe2569d521edf6b9fc12ac2d364
7
+ data.tar.gz: 73e0d95ca8771ca86517f6fcf3def575b5536b269f3d6cdb65c7be887f633fa872bb0d8bc479c7932a592ba0bd204f29d5ba7ba2a43acb54546981c65c62c102
data/CHANGELOG.md CHANGED
@@ -8,4 +8,11 @@
8
8
  - Raise error when there is no non-nullable and distinct column in the configured order definition.
9
9
 
10
10
  ## 0.2.3
11
- - Replace any existing order defined on the given ar_relation
11
+ - Replace any existing order defined on the given ar_relation
12
+
13
+ ## 0.2.4
14
+ - Allow changing limit param.
15
+
16
+ ## 1.0.0
17
+ - Allow changing of ar_relation and order by default.
18
+ - Make error class names consistent.
data/README.md CHANGED
@@ -19,7 +19,7 @@ Some advantages of this approach are:
19
19
  * `NULLS FIRST`/`NULLS LAST` handling
20
20
  * Allows custom cursor format
21
21
  * Built-in cursor token expiration
22
- * Built-in cursor integrity check
22
+ * Built-in cursor integrity checking
23
23
  * Supports **MySQL**, **PostgreSQL**, and **SQLite**
24
24
  * Supports **Rails 4.2** and above
25
25
 
@@ -45,8 +45,7 @@ gem install rotulus
45
45
  ```
46
46
 
47
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:
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
49
 
51
50
  #### Create an initializer `config/initializers/rotulus.rb`:
52
51
 
@@ -57,6 +56,8 @@ Rotulus.configure do |config|
57
56
  config.secret = ENV["MY_ENV_VAR"]
58
57
  config.token_expires_in = 10800
59
58
  config.cursor_class = MyCursor
59
+ config.restrict_order_change = false
60
+ config.restrict_query_change = false
60
61
  end
61
62
  ```
62
63
 
@@ -64,8 +65,10 @@ end
64
65
  | ----------- | ----------- |
65
66
  | `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
67
  | `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
+ | `secret` | **Default: ENV['ROTULUS_SECRET']** <br/> Key needed to generate the cursor state needed for cursor integrity checking. |
68
69
  | `token_expires_in` | **Default: 259200**(3 days) <br/> Validity period of a cursor token (in seconds). Set to `nil` to disable token expiration. |
70
+ | `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
+ | `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`. |
69
72
  | `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
73
  <br/>
71
74
 
@@ -79,20 +82,19 @@ end
79
82
  ```ruby
80
83
  users = User.where('age > ?', 16)
81
84
 
82
- page = Rotulus::Page.new(users, order: { first_name: :asc, last_name: :desc }, limit: 3)
85
+ page = Rotulus::Page.new(users, order: { id: :asc })
86
+ # OR just
87
+ page = Rotulus::Page.new(users)
83
88
  ```
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
89
 
87
- ###### Example with `ORDER BY users.id asc` only:
90
+ ###### Example when sorting with multiple columns and `:limit`:
88
91
 
89
92
  ```ruby
90
- page = Rotulus::Page.new(users, order: { id: :asc } limit: 3)
91
-
92
- # OR
93
93
 
94
- page = Rotulus::Page.new(users, limit: 3)
94
+ page = Rotulus::Page.new(users, order: { first_name: :asc, last_name: :desc }, limit: 3)
95
95
  ```
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.
97
+
96
98
 
97
99
  #### Access the page records
98
100
 
@@ -295,13 +297,15 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
295
297
 
296
298
  | Class | Description |
297
299
  | ----------- | ----------- |
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. |
300
+ | `Rotulus::InvalidCursor` | Cursor token received is invalid e.g., unrecognized token, token data has been tampered/updated. |
299
301
  | `Rotulus::Expired` | Cursor token received has expired based on the configured `token_expires_in` |
300
302
  | `Rotulus::InvalidLimit` | Limit set to Rotulus::Page is not valid. e.g., exceeds the configured limit. see `config.page_max_limit` |
301
303
  | `Rotulus::CursorError` | Generic error for cursor related validations |
302
- | `Rotulus::InvalidColumnError` | Column provided in the :order param can't be found. |
303
- | `Rotulus::MissingTiebreakerError` | There is no non-nullable and distinct column in the configured order definition. |
304
+ | `Rotulus::InvalidColumn` | Column provided in the :order param can't be found. |
305
+ | `Rotulus::MissingTiebreaker` | There is no non-nullable and distinct column in the configured order definition. |
304
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. |
305
309
 
306
310
  ## How it works
307
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:
@@ -380,8 +384,10 @@ To navigate between pages, a cursor is used. The cursor token is a Base64 encode
380
384
  ```
381
385
  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.
382
386
  2. `d` - the pagination direction. `next` or `prev` set of records from the reference values in "f".
383
- 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.
384
- 4. `c` - the time when this cursor was generated.
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.
388
+ 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).
390
+ 4. `c` - cursor token issuance time.
385
391
 
386
392
  A condition generated from the cursor above would look like:
387
393
 
@@ -461,7 +467,7 @@ end
461
467
  | Environment Variable | Values | Example |
462
468
  | ----------- | ----------- |----------- |
463
469
  | `DB_ADAPTER` | **Default: :sqlite**. `sqlite`,`mysql2`, or `postgresql` | ```DB_ADAPTER=postgresql bundle exec rspec```<br/><br/> ```DB_ADAPTER=postgresql ./bin/console``` |
464
- | `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```|
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```|
465
471
 
466
472
 
467
473
  <br/><br/>
@@ -27,7 +27,7 @@ module Rotulus
27
27
  @model = model
28
28
  @name = name.to_s
29
29
  unless name_valid?
30
- raise Rotulus::InvalidColumnError.new("Column/table name must contain letters, digits (0-9), or \
30
+ raise Rotulus::InvalidColumn.new("Column/table name must contain letters, digits (0-9), or \
31
31
  underscores and must begin with a letter or underscore.".squish)
32
32
  end
33
33
 
@@ -6,8 +6,10 @@ module Rotulus
6
6
  @page_default_limit = default_limit
7
7
  @page_max_limit = default_max_limit
8
8
  @secret = ENV['ROTULUS_SECRET']
9
- @token_expires_in = 259200
9
+ @token_expires_in = 259_200
10
10
  @cursor_class = default_cursor_class
11
+ @restrict_order_change = false
12
+ @restrict_query_change = false
11
13
  end
12
14
 
13
15
  def page_default_limit=(limit)
@@ -49,6 +51,22 @@ module Rotulus
49
51
  @cursor_class || default_cursor_class
50
52
  end
51
53
 
54
+ def restrict_order_change=(restrict)
55
+ @restrict_order_change = !!restrict
56
+ end
57
+
58
+ def restrict_order_change?
59
+ @restrict_order_change
60
+ end
61
+
62
+ def restrict_query_change=(restrict)
63
+ @restrict_query_change = !!restrict
64
+ end
65
+
66
+ def restrict_query_change?
67
+ @restrict_query_change
68
+ end
69
+
52
70
  private
53
71
 
54
72
  def default_cursor_class
@@ -9,19 +9,32 @@ module Rotulus
9
9
  # @param token [String] Base64-encoded string data
10
10
  # @return [Cursor] Cursor
11
11
  #
12
- # @raise [InvalidCursor] if the cursor is no longer consistent to the page's ActiveRecord
13
- # relation filters, sorting, limit, or if the encoded cursor data was tampered.
12
+ # @raise [InvalidCursor] if the token can't be decoded or if the cursor data was tampered.
13
+ # @raise [OrderChanged] if token generated from a page with a different `:order` definition.
14
+ # @raise [QueryChanged] if token generated from a page with a different `:ar_relation`.
14
15
  def for_page_and_token!(page, token)
15
16
  data = decode(token)
16
17
  reference_record = Record.new(page, data[:f])
17
18
  direction = data[:d]
18
19
  created_at = Time.at(data[:c]).utc
19
- state = data[:s].presence
20
+ cursor_state = data[:cs].presence
21
+ order_state = data[:os].presence
22
+ query_state = data[:qs].presence
20
23
 
21
24
  cursor = new(reference_record, direction, created_at: created_at)
22
25
 
23
- if cursor.state != state
24
- raise InvalidCursor.new('Invalid cursor possibly due to filter, order, or limit changed')
26
+ raise InvalidCursor if cursor.state != cursor_state
27
+
28
+ if page.order_state != order_state
29
+ raise OrderChanged if Rotulus.configuration.restrict_order_change?
30
+
31
+ return nil
32
+ end
33
+
34
+ if page.query_state != query_state
35
+ raise QueryChanged if Rotulus.configuration.restrict_query_change?
36
+
37
+ return nil
25
38
  end
26
39
 
27
40
  cursor
@@ -36,7 +49,7 @@ module Rotulus
36
49
  # page if page direction is `:prev`.
37
50
  def decode(token)
38
51
  Oj.load(Base64.urlsafe_decode64(token))
39
- rescue ArgumentError => e
52
+ rescue ArgumentError, Oj::ParseError => e
40
53
  raise InvalidCursor.new("Invalid Cursor: #{e.message}")
41
54
  end
42
55
 
@@ -93,19 +106,19 @@ module Rotulus
93
106
  def to_token
94
107
  @token ||= self.class.encode(f: record.values,
95
108
  d: direction,
96
- s: state,
97
- c: created_at.to_i)
109
+ c: created_at.to_i,
110
+ cs: state,
111
+ os: page.order_state,
112
+ qs: page.query_state)
98
113
  end
99
114
  alias to_s to_token
100
115
 
101
- # Generate a 'state' string so we can detect whether the cursor data is no longer consistent to
102
- # the AR filter, limit, or order definition. This also provides a mechanism to detect if
103
- # any token data was tampered.
116
+ # Generate a 'state' string for integrity checking of the reference record, direction,
117
+ # and created_at data from a decoded Cursor token.
104
118
  #
105
119
  # @return [String] the hashed state
106
120
  def state
107
- state_data = "#{page.state}#{record.state}"
108
- state_data << "#{direction}#{created_at.to_i}#{secret}"
121
+ state_data = "#{record.state}#{direction}#{created_at.to_i}#{secret}"
109
122
 
110
123
  Digest::MD5.hexdigest(state_data)
111
124
  end
data/lib/rotulus/order.rb CHANGED
@@ -13,7 +13,8 @@ module Rotulus
13
13
  build_column_definitions
14
14
 
15
15
  return if has_tiebreaker?
16
- raise Rotulus::MissingTiebreakerError.new('A non-nullable and distinct column is required.')
16
+
17
+ raise Rotulus::MissingTiebreaker.new('A non-nullable and distinct column is required.')
17
18
  end
18
19
 
19
20
  # Returns an array of the ordered columns
@@ -79,7 +80,9 @@ module Rotulus
79
80
  #
80
81
  # @return [String] the hashed state
81
82
  def state
82
- Digest::MD5.hexdigest(Oj.dump(to_h, mode: :rails))
83
+ data = Oj.dump(to_h, mode: :rails)
84
+
85
+ Digest::MD5.hexdigest("#{data}#{Rotulus.configuration.secret}")
83
86
  end
84
87
 
85
88
  # Returns a hash containing the hash representation of the ordered columns.
@@ -123,7 +126,7 @@ module Rotulus
123
126
  unless model_override.nil?
124
127
  return model_override unless model_override.columns_hash[unprefixed_name].nil?
125
128
 
126
- raise Rotulus::InvalidColumnError.new(
129
+ raise Rotulus::InvalidColumn.new(
127
130
  "Model '#{model_override}' doesnt have a '#{name}' column. \
128
131
  Tip: check the :model option value in the column's order configuration.".squish
129
132
  )
@@ -134,7 +137,7 @@ module Rotulus
134
137
  return ar_model
135
138
  end
136
139
 
137
- raise Rotulus::InvalidColumnError.new(
140
+ raise Rotulus::InvalidColumn.new(
138
141
  "Unable determine which model the column '#{name}' belongs to. \
139
142
  Tip: set/check the :model option value in the column's order configuration.".squish
140
143
  )
data/lib/rotulus/page.rb CHANGED
@@ -2,7 +2,7 @@ module Rotulus
2
2
  class Page
3
3
  attr_reader :ar_relation, :order, :limit, :cursor
4
4
 
5
- delegate :columns, to: :order, prefix: true
5
+ delegate :columns, :state, to: :order, prefix: true
6
6
 
7
7
  # Creates a new Page instance representing a subset of the given ActiveRecord::Relation
8
8
  # records sorted using the given 'order' definition param.
@@ -176,12 +176,14 @@ module Rotulus
176
176
  }.delete_if { |_, token| token.nil? }
177
177
  end
178
178
 
179
- # Return Hashed value of this page's state so we can check whether the ar_relation's filter,
180
- # order definition, and limit are still consistent to the cursor. see Cursor.state_valid?.
179
+ # Return Hashed value of this page's state so we can check whether the base ar_relation has
180
+ # changed(e.g. SQL/filters from API params). see Cursor.for_page_and_token!
181
181
  #
182
182
  # @return [String] the hashed state
183
- def state
184
- Digest::MD5.hexdigest("#{ar_relation.to_sql}~#{order.state}~#{limit}")
183
+ def query_state
184
+ data = ar_relation.to_sql
185
+
186
+ Digest::MD5.hexdigest("#{data}#{Rotulus.configuration.secret}")
185
187
  end
186
188
 
187
189
  # Returns a string showing the page's records in table form with the ordered columns
@@ -1,3 +1,3 @@
1
1
  module Rotulus
2
- VERSION = '0.2.3'
2
+ VERSION = '1.0.0'.freeze
3
3
  end
data/lib/rotulus.rb CHANGED
@@ -15,14 +15,16 @@ require 'rotulus/page'
15
15
 
16
16
  module Rotulus
17
17
  class BaseError < StandardError; end
18
+ class InvalidLimit < BaseError; end
18
19
  class CursorError < BaseError; end
19
20
  class InvalidCursor < CursorError; end
20
21
  class ExpiredCursor < CursorError; end
21
22
  class InvalidCursorDirection < CursorError; end
22
- class InvalidLimit < BaseError; end
23
+ class OrderChanged < CursorError; end
24
+ class QueryChanged < CursorError; end
23
25
  class ConfigurationError < BaseError; end
24
- class MissingTiebreakerError < ConfigurationError; end
25
- class InvalidColumnError < ConfigurationError; end
26
+ class MissingTiebreaker < ConfigurationError; end
27
+ class InvalidColumn < ConfigurationError; end
26
28
 
27
29
  def self.db
28
30
  @db ||= case ActiveRecord::Base.connection.adapter_name.downcase
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rotulus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 1.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-05-23 00:00:00.000000000 Z
11
+ date: 2023-05-25 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.0.5
22
+ version: '7.1'
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -29,7 +29,7 @@ dependencies:
29
29
  version: '4.2'
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
- version: 7.0.5
32
+ version: '7.1'
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: activesupport
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -39,7 +39,7 @@ dependencies:
39
39
  version: '4.2'
40
40
  - - "<"
41
41
  - !ruby/object:Gem::Version
42
- version: 7.0.5
42
+ version: '7.1'
43
43
  type: :runtime
44
44
  prerelease: false
45
45
  version_requirements: !ruby/object:Gem::Requirement
@@ -49,7 +49,7 @@ dependencies:
49
49
  version: '4.2'
50
50
  - - "<"
51
51
  - !ruby/object:Gem::Version
52
- version: 7.0.5
52
+ version: '7.1'
53
53
  - !ruby/object:Gem::Dependency
54
54
  name: oj
55
55
  requirement: !ruby/object:Gem::Requirement