rotulus 0.2.4 → 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: dabab916c4cd609c84cf4dea85172a337df2501f846d3747e072ddc4c2ea5995
4
- data.tar.gz: acd18440bd31048eec4cc7aa7afefb4609265a6bbe6002d6e3bea65ebc331eec
3
+ metadata.gz: 149f276bc80e599488039c9858f817000766d74d6a494c1447ec58167a83c424
4
+ data.tar.gz: dad1c9cad5c214e88ede97573c18fbff1b65ed4a21e46b045a388910b325b5a7
5
5
  SHA512:
6
- metadata.gz: c951e3a06482187d8d340d1f66f67b71b8aadf955a48fa4f190fb1edeb921615c7ec47977faf57019c645ca27c3eb66dc31e9b5b4e57aac61323e0a356fdc875
7
- data.tar.gz: fc5ba3d91f64aff2aa0a9594beffe646c99955ef369f590de1f538cc31e31ef6903a3321f4e7adaa1ad62f3e47db3c8a6e7450f97a7a5209d2784d5286ad5f3b
6
+ metadata.gz: aab89cbb1f43d71ceddfe8cb1bb60d2d343f3d114109cb3d97d8650bca138d31893a1d803d7563b01ec51cca5eb3d227878f1fe2569d521edf6b9fc12ac2d364
7
+ data.tar.gz: 73e0d95ca8771ca86517f6fcf3def575b5536b269f3d6cdb65c7be887f633fa872bb0d8bc479c7932a592ba0bd204f29d5ba7ba2a43acb54546981c65c62c102
data/CHANGELOG.md CHANGED
@@ -11,4 +11,8 @@
11
11
  - Replace any existing order defined on the given ar_relation
12
12
 
13
13
  ## 0.2.4
14
- - Allow changing limit param
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
@@ -56,6 +56,8 @@ Rotulus.configure do |config|
56
56
  config.secret = ENV["MY_ENV_VAR"]
57
57
  config.token_expires_in = 10800
58
58
  config.cursor_class = MyCursor
59
+ config.restrict_order_change = false
60
+ config.restrict_query_change = false
59
61
  end
60
62
  ```
61
63
 
@@ -63,8 +65,10 @@ end
63
65
  | ----------- | ----------- |
64
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(...)` |
65
67
  | `page_max_limit` | **Default: 50** <br/> Maximum `:limit` value allowed when initializing a page.|
66
- | `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. |
67
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`. |
68
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). |
69
73
  <br/>
70
74
 
@@ -293,13 +297,15 @@ page = Rotulus::Page.new(items, order: order_by, limit: 2)
293
297
 
294
298
  | Class | Description |
295
299
  | ----------- | ----------- |
296
- | `Rotulus::InvalidCursor` | Cursor token received is invalid e.g., unrecognized token, token data has been tampered/updated, base ActiveRecord relation filter/sorting 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. |
297
301
  | `Rotulus::Expired` | Cursor token received has expired based on the configured `token_expires_in` |
298
302
  | `Rotulus::InvalidLimit` | Limit set to Rotulus::Page is not valid. e.g., exceeds the configured limit. see `config.page_max_limit` |
299
303
  | `Rotulus::CursorError` | Generic error for cursor related validations |
300
- | `Rotulus::InvalidColumnError` | Column provided in the :order param can't be found. |
301
- | `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. |
302
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. |
303
309
 
304
310
  ## How it works
305
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:
@@ -378,7 +384,9 @@ To navigate between pages, a cursor is used. The cursor token is a Base64 encode
378
384
  ```
379
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.
380
386
  2. `d` - the pagination direction. `next` or `prev` set of records from the reference values in "f".
381
- 3. `s` - the cursor state needed for integrity checking so we can detect whether the base ActiveRecord relation filter(initial `WHERE` conditions) are no longer consistent with the cursor (e.g., API parameters have changed). Additionally, to restrict clients/third-parties from generating their own (unsafe)tokens or from tampering 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.
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).
382
390
  4. `c` - cursor token issuance time.
383
391
 
384
392
  A condition generated from the cursor above would look like:
@@ -459,7 +467,7 @@ end
459
467
  | Environment Variable | Values | Example |
460
468
  | ----------- | ----------- |----------- |
461
469
  | `DB_ADAPTER` | **Default: :sqlite**. `sqlite`,`mysql2`, or `postgresql` | ```DB_ADAPTER=postgresql bundle exec rspec```<br/><br/> ```DB_ADAPTER=postgresql ./bin/console``` |
462
- | `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```|
463
471
 
464
472
 
465
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, 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 or sorting 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 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 and
180
- # order definition 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}")
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.4'.freeze
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.4
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-24 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