activerecord_cursor_pagination 0.1.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/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +220 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/examples/jwt_cursor_serializer.rb +14 -0
- data/lib/activerecord_cursor_pagination/ascending_order.rb +21 -0
- data/lib/activerecord_cursor_pagination/class_formatter.rb +19 -0
- data/lib/activerecord_cursor_pagination/configuration.rb +45 -0
- data/lib/activerecord_cursor_pagination/cursor.rb +144 -0
- data/lib/activerecord_cursor_pagination/cursor_scope.rb +426 -0
- data/lib/activerecord_cursor_pagination/descending_order.rb +21 -0
- data/lib/activerecord_cursor_pagination/empty_cursor.rb +35 -0
- data/lib/activerecord_cursor_pagination/extension.rb +18 -0
- data/lib/activerecord_cursor_pagination/model_extension.rb +94 -0
- data/lib/activerecord_cursor_pagination/order_base.rb +275 -0
- data/lib/activerecord_cursor_pagination/secret_key_finder.rb +15 -0
- data/lib/activerecord_cursor_pagination/secure_cursor_serializer.rb +44 -0
- data/lib/activerecord_cursor_pagination/serializer.rb +39 -0
- data/lib/activerecord_cursor_pagination/sql_signer.rb +31 -0
- data/lib/activerecord_cursor_pagination/version.rb +3 -0
- data/lib/activerecord_cursor_pagination.rb +81 -0
- metadata +225 -0
@@ -0,0 +1,275 @@
|
|
1
|
+
module ActiverecordCursorPagination
|
2
|
+
class OrderBase
|
3
|
+
attr_reader :table, :name, :index, :direction
|
4
|
+
|
5
|
+
attr_accessor :base_id
|
6
|
+
|
7
|
+
##
|
8
|
+
# Initialize the OrderBase.
|
9
|
+
#
|
10
|
+
# @param [String] table The table name.
|
11
|
+
# @param [String] name The name of the column.
|
12
|
+
# @param [Integer] index The index of the order column.
|
13
|
+
def initialize(table, name, index)
|
14
|
+
@table = ActiverecordCursorPagination.strip_quotes table
|
15
|
+
@name = ActiverecordCursorPagination.strip_quotes name
|
16
|
+
@base_id = false
|
17
|
+
@index = index
|
18
|
+
end
|
19
|
+
|
20
|
+
##
|
21
|
+
# Get the direction of the order.
|
22
|
+
#
|
23
|
+
# @abstract
|
24
|
+
#
|
25
|
+
# @return [Symbol] Returns the order symbol.
|
26
|
+
def direction
|
27
|
+
raise NotImplementedError
|
28
|
+
end
|
29
|
+
|
30
|
+
##
|
31
|
+
# Get if the order column is the base id of the table.
|
32
|
+
#
|
33
|
+
# @return [Boolean] Is the base id.
|
34
|
+
def base_id?
|
35
|
+
!!@base_id
|
36
|
+
end
|
37
|
+
|
38
|
+
##
|
39
|
+
# Get if the table name is defined.
|
40
|
+
#
|
41
|
+
# @return [Boolean] True if the table is defined.
|
42
|
+
def table?
|
43
|
+
!table.nil? && !table.empty?
|
44
|
+
end
|
45
|
+
|
46
|
+
##
|
47
|
+
# Get if the table exists.
|
48
|
+
#
|
49
|
+
# @return [Boolean] True if the table exists.
|
50
|
+
def table_exists?
|
51
|
+
ActiverecordCursorPagination.table_exists? table
|
52
|
+
end
|
53
|
+
|
54
|
+
##
|
55
|
+
# Get if the table name is a valid SQL database name.
|
56
|
+
#
|
57
|
+
# @return [Boolean] True if a valid name.
|
58
|
+
def valid_table_name?
|
59
|
+
ActiverecordCursorPagination.valid_name? table
|
60
|
+
end
|
61
|
+
|
62
|
+
##
|
63
|
+
# Get if the column name is a valid SQL column name.
|
64
|
+
#
|
65
|
+
# @return [Boolean] True if a valid name.
|
66
|
+
def valid_name?
|
67
|
+
ActiverecordCursorPagination.valid_name? name
|
68
|
+
end
|
69
|
+
|
70
|
+
##
|
71
|
+
# Get the statement key for the named SQL query.
|
72
|
+
#
|
73
|
+
# @return [Symbol] The statement key.
|
74
|
+
def statement_key
|
75
|
+
:"order_field#{index}"
|
76
|
+
end
|
77
|
+
|
78
|
+
##
|
79
|
+
# Get the full SQL name.
|
80
|
+
#
|
81
|
+
# @return [String].
|
82
|
+
def full_name
|
83
|
+
table? ? "#{table}.#{name}" : name
|
84
|
+
end
|
85
|
+
|
86
|
+
##
|
87
|
+
# Get the full quoted name.
|
88
|
+
#
|
89
|
+
# @return [String].
|
90
|
+
def quote_full_name
|
91
|
+
ActiverecordCursorPagination.quote_table_column table, name
|
92
|
+
end
|
93
|
+
|
94
|
+
##
|
95
|
+
# Get the quoted table name.
|
96
|
+
#
|
97
|
+
# @return [String, nil].
|
98
|
+
def quote_table
|
99
|
+
ActiverecordCursorPagination.quote_table table
|
100
|
+
end
|
101
|
+
|
102
|
+
##
|
103
|
+
# Get the quoted column name
|
104
|
+
#
|
105
|
+
# @return [String]
|
106
|
+
def quote_name
|
107
|
+
ActiverecordCursorPagination.quote_column name
|
108
|
+
end
|
109
|
+
|
110
|
+
##
|
111
|
+
# Get the reverse column order
|
112
|
+
#
|
113
|
+
# @abstract
|
114
|
+
#
|
115
|
+
# @return [OrderBase]
|
116
|
+
def reverse
|
117
|
+
raise NotImplementedError
|
118
|
+
end
|
119
|
+
|
120
|
+
##
|
121
|
+
# Get the SQL for the equals comparison
|
122
|
+
#
|
123
|
+
# @return [String]
|
124
|
+
def equals_sql
|
125
|
+
"#{quote_full_name} = :#{statement_key}"
|
126
|
+
end
|
127
|
+
|
128
|
+
##
|
129
|
+
# Get the SQL for the greater/less than comparison depending on direction
|
130
|
+
#
|
131
|
+
# @return [String]
|
132
|
+
def than_sql
|
133
|
+
"#{quote_full_name} #{than_op} :#{statement_key}"
|
134
|
+
end
|
135
|
+
|
136
|
+
##
|
137
|
+
# Get the SQL operation for the greater/less than comparison
|
138
|
+
#
|
139
|
+
# @abstract
|
140
|
+
#
|
141
|
+
# @return [String]
|
142
|
+
def than_op
|
143
|
+
raise NotImplementedError
|
144
|
+
end
|
145
|
+
|
146
|
+
##
|
147
|
+
# Get the SQL for the greater/less than or equal to comparison depending on direction
|
148
|
+
#
|
149
|
+
# @return [String]
|
150
|
+
def than_or_equal_sql
|
151
|
+
"#{quote_full_name} #{than_or_equal_op} :#{statement_key}"
|
152
|
+
end
|
153
|
+
|
154
|
+
##
|
155
|
+
# Get the SQL operation for the greater/less than or equal comparison
|
156
|
+
#
|
157
|
+
# @abstract
|
158
|
+
#
|
159
|
+
# @return [String]
|
160
|
+
def than_or_equal_op
|
161
|
+
raise NotImplementedError
|
162
|
+
end
|
163
|
+
|
164
|
+
##
|
165
|
+
# Get the SQL literal of the column order
|
166
|
+
#
|
167
|
+
# @return [Arel::Nodes::SqlLiteral] Sql literal
|
168
|
+
def order_sql
|
169
|
+
Arel.sql "#{quote_full_name} #{direction.to_s.upcase}"
|
170
|
+
end
|
171
|
+
|
172
|
+
class << self
|
173
|
+
##
|
174
|
+
# Parse the order string or node
|
175
|
+
#
|
176
|
+
# @param [String, Arel::Nodes::Node, Arel::Nodes::SqlLiteral] string_or_sql_order_node
|
177
|
+
#
|
178
|
+
# The table, column, and/or order representation.
|
179
|
+
#
|
180
|
+
# @param [Integer] index
|
181
|
+
#
|
182
|
+
# The index of the node.
|
183
|
+
#
|
184
|
+
# @return [OrderBase]
|
185
|
+
def parse(string_or_sql_order_node, index)
|
186
|
+
if string_or_sql_order_node.is_a?(Arel::Nodes::SqlLiteral) || string_or_sql_order_node.is_a?(String)
|
187
|
+
parse_string string_or_sql_order_node, index
|
188
|
+
else
|
189
|
+
parse_order_node string_or_sql_order_node, index
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
##
|
194
|
+
# Parse the order string
|
195
|
+
#
|
196
|
+
# Limitations:
|
197
|
+
# 1. Complex queries must use +'+ quotes for strings
|
198
|
+
#
|
199
|
+
# @param [String, Arel::Nodes::SqlLiteral] string_or_sql_literal
|
200
|
+
#
|
201
|
+
# The string representation of the table, column, and/or order direction.
|
202
|
+
#
|
203
|
+
# @param [Integer] index
|
204
|
+
#
|
205
|
+
# The index of the node.
|
206
|
+
#
|
207
|
+
# @return [OrderBase]
|
208
|
+
def parse_string(string_or_sql_literal, index)
|
209
|
+
string_or_sql_literal.strip!
|
210
|
+
|
211
|
+
table_column, dir = if (match = string_or_sql_literal.match(/\A(?<rest>.*)\s+(?<order>ASC|DESC)\z/i))
|
212
|
+
[match[:rest]&.strip, match[:order]&.downcase]
|
213
|
+
else
|
214
|
+
[string_or_sql_literal, nil]
|
215
|
+
end
|
216
|
+
|
217
|
+
order_klass = order_factory dir
|
218
|
+
|
219
|
+
|
220
|
+
table, column = parse_table_column table_column.to_s.strip
|
221
|
+
|
222
|
+
if column.nil? || column.empty?
|
223
|
+
column = table
|
224
|
+
table = nil
|
225
|
+
end
|
226
|
+
|
227
|
+
order_klass.new table, column, index
|
228
|
+
end
|
229
|
+
|
230
|
+
##
|
231
|
+
# Parse the order Arel node
|
232
|
+
#
|
233
|
+
# @param [Arel::Nodes::Node] node The order node.
|
234
|
+
# @param [Integer] index The index of the order node.
|
235
|
+
#
|
236
|
+
# @return [OrderBase]
|
237
|
+
def parse_order_node(node, index)
|
238
|
+
order_klass = order_factory node.direction
|
239
|
+
|
240
|
+
table, column = if node.expr.is_a? Arel::Nodes::SqlLiteral
|
241
|
+
parse_table_column node.expr.to_s.strip
|
242
|
+
else
|
243
|
+
[node.expr.relation.name, node.expr.name]
|
244
|
+
end
|
245
|
+
|
246
|
+
if column.nil? || column.empty?
|
247
|
+
column = table
|
248
|
+
table = @table
|
249
|
+
end
|
250
|
+
|
251
|
+
order_klass.new table, column, index
|
252
|
+
end
|
253
|
+
|
254
|
+
##
|
255
|
+
# Order class factory
|
256
|
+
#
|
257
|
+
# @param [String, Symbol] direction The direction of the order column.
|
258
|
+
def order_factory(direction)
|
259
|
+
direction&.to_s&.downcase === 'desc' ? DescendingOrder : AscendingOrder
|
260
|
+
end
|
261
|
+
|
262
|
+
private
|
263
|
+
|
264
|
+
def parse_table_column(str)
|
265
|
+
# FIXME Double quoted strings vs column and table names
|
266
|
+
# Strings must be single quoted or the REGEXP will remove the double quotes creating invalid sql statement.
|
267
|
+
if str =~ /\A["']?[\w_]+['"]?\.?['"]?[\w_]+['"]?\z/
|
268
|
+
str.scan /[^".]+|"[^"]*"/
|
269
|
+
else
|
270
|
+
[nil, str]
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module ActiverecordCursorPagination
|
2
|
+
class SecretKeyFinder
|
3
|
+
def find_in(application)
|
4
|
+
if application.respond_to? :credentials
|
5
|
+
application.credentials.secret_key_base
|
6
|
+
elsif application.respond_to? :secrets
|
7
|
+
application.secrets.secret_key_base
|
8
|
+
elsif application.config.respond_to? :secret_key_base
|
9
|
+
application.config.secret_key_base
|
10
|
+
elsif application.respond_to? :secret_key_base
|
11
|
+
application.secret_key_base
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module ActiverecordCursorPagination
|
2
|
+
##
|
3
|
+
# Secure cursor serializer implementation using AES 256 encryption.
|
4
|
+
class SecureCursorSerializer < Serializer
|
5
|
+
##
|
6
|
+
# Deserializes the secure cursor.
|
7
|
+
#
|
8
|
+
# @param [String] str The AES encrypted serialized JSON string.
|
9
|
+
#
|
10
|
+
# @return [Hash]
|
11
|
+
def deserialize(str)
|
12
|
+
c = cipher.decrypt
|
13
|
+
c.key = cipher_key
|
14
|
+
decoded = Base64.strict_decode64 str
|
15
|
+
decrypted = c.update(decoded) + c.final
|
16
|
+
json = JSON.parse decrypted
|
17
|
+
json.symbolize_keys
|
18
|
+
end
|
19
|
+
|
20
|
+
##
|
21
|
+
# Serializes and secures the hash representation of a cursor.
|
22
|
+
#
|
23
|
+
# @param [Hash] hash The hash representation of a cursor.
|
24
|
+
#
|
25
|
+
# @return [String] The encrypted cursor string.
|
26
|
+
def serialize(hash)
|
27
|
+
c = cipher.encrypt
|
28
|
+
c.key = cipher_key
|
29
|
+
json = JSON.generate hash
|
30
|
+
encrypted = c.update(json) + c.final
|
31
|
+
Base64.strict_encode64 encrypted
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def cipher
|
37
|
+
OpenSSL::Cipher.new 'aes-256-cbc'
|
38
|
+
end
|
39
|
+
|
40
|
+
def cipher_key
|
41
|
+
Digest::SHA256.digest secret_key
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module ActiverecordCursorPagination
|
2
|
+
class Serializer
|
3
|
+
##
|
4
|
+
# Deserialize the cursor.
|
5
|
+
#
|
6
|
+
# @abstract
|
7
|
+
#
|
8
|
+
# @param [String] str The serialized cursor string.
|
9
|
+
#
|
10
|
+
# @return [Hash] a hash representation of the cursor.
|
11
|
+
def deserialize(str)
|
12
|
+
raise NotImplementedError
|
13
|
+
end
|
14
|
+
|
15
|
+
##
|
16
|
+
# Serialize the hash representation of the cursor.
|
17
|
+
#
|
18
|
+
# @abstract
|
19
|
+
#
|
20
|
+
# @param [Hash] hash The hash representation of the cursor.
|
21
|
+
#
|
22
|
+
# @return [String] the serialized cursor string.
|
23
|
+
def serialize(hash)
|
24
|
+
raise NotImplementedError
|
25
|
+
end
|
26
|
+
|
27
|
+
protected
|
28
|
+
|
29
|
+
##
|
30
|
+
# Gets the secret key for the application.
|
31
|
+
#
|
32
|
+
# @raise [NoSecretKeyDefined] if the key is not defined.
|
33
|
+
#
|
34
|
+
# @return [String] if the key is defined.
|
35
|
+
def secret_key
|
36
|
+
ActiverecordCursorPagination.configuration.secret_key
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module ActiverecordCursorPagination
|
2
|
+
class SqlSigner
|
3
|
+
|
4
|
+
##
|
5
|
+
# Sign SQL
|
6
|
+
#
|
7
|
+
# @param [ActiveRecord::Relation, nil] sql The SQL to sign.
|
8
|
+
#
|
9
|
+
# @return [String] The signature hash.
|
10
|
+
def sign(sql)
|
11
|
+
return nil if sql.nil?
|
12
|
+
|
13
|
+
sql = format sql
|
14
|
+
digest = OpenSSL::Digest.new 'sha1'
|
15
|
+
hmac = OpenSSL::HMAC.digest digest, secret_key, sql
|
16
|
+
hash = Base64.encode64 hmac
|
17
|
+
hash.gsub /\n+/, ""
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def secret_key
|
23
|
+
ActiverecordCursorPagination.configuration.secret_key
|
24
|
+
end
|
25
|
+
|
26
|
+
def format(sql)
|
27
|
+
sql_str = sql.only(:joins, :where, :order).to_sql
|
28
|
+
sql_str.gsub(/[\s\t]*/, ' ').gsub(/\n+/, ' ')
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'openssl'
|
3
|
+
require 'digest'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
require_relative "activerecord_cursor_pagination/version"
|
7
|
+
require_relative "activerecord_cursor_pagination/secret_key_finder"
|
8
|
+
require_relative "activerecord_cursor_pagination/configuration"
|
9
|
+
require_relative "activerecord_cursor_pagination/serializer"
|
10
|
+
require_relative "activerecord_cursor_pagination/secure_cursor_serializer"
|
11
|
+
require_relative "activerecord_cursor_pagination/class_formatter"
|
12
|
+
require_relative "activerecord_cursor_pagination/sql_signer"
|
13
|
+
require_relative "activerecord_cursor_pagination/empty_cursor"
|
14
|
+
require_relative "activerecord_cursor_pagination/cursor"
|
15
|
+
require_relative "activerecord_cursor_pagination/order_base"
|
16
|
+
require_relative "activerecord_cursor_pagination/ascending_order"
|
17
|
+
require_relative "activerecord_cursor_pagination/descending_order"
|
18
|
+
require_relative "activerecord_cursor_pagination/cursor_scope"
|
19
|
+
require_relative "activerecord_cursor_pagination/model_extension"
|
20
|
+
require_relative "activerecord_cursor_pagination/extension"
|
21
|
+
|
22
|
+
module ActiverecordCursorPagination
|
23
|
+
class Error < StandardError; end
|
24
|
+
class NoSecretKeyError < Error; end
|
25
|
+
class NotSingleRecordError < Error; end
|
26
|
+
|
27
|
+
class CursorError < Error
|
28
|
+
attr_reader :cursor
|
29
|
+
|
30
|
+
def initialize(msg='Cursor error',cursor=nil)
|
31
|
+
super(msg)
|
32
|
+
@cursor = cursor
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class InvalidCursorError < CursorError; end
|
37
|
+
|
38
|
+
class << self
|
39
|
+
attr_reader :configuration
|
40
|
+
|
41
|
+
def configuration
|
42
|
+
@configuration ||= Configuration.new
|
43
|
+
end
|
44
|
+
|
45
|
+
def setup(&block)
|
46
|
+
block.call configuration if block
|
47
|
+
end
|
48
|
+
|
49
|
+
def quote_table_column(table, name)
|
50
|
+
table.nil? || table.empty? ? quote_column(name) : "#{quote_table table}.#{quote_column name}"
|
51
|
+
end
|
52
|
+
|
53
|
+
def quote_table(table)
|
54
|
+
table_exists?(table) ? connection.quote_table_name(table) : table
|
55
|
+
end
|
56
|
+
|
57
|
+
def quote_column(name)
|
58
|
+
valid_name?(name) ? connection.quote_column_name(name) : name
|
59
|
+
end
|
60
|
+
|
61
|
+
def strip_quotes(name)
|
62
|
+
name&.gsub '"', ''
|
63
|
+
end
|
64
|
+
|
65
|
+
def valid_name?(name)
|
66
|
+
/\A[\w_]+\z/.match? name
|
67
|
+
end
|
68
|
+
|
69
|
+
def table_exists?(table)
|
70
|
+
valid_name?(table) && connection.table_exists?(table)
|
71
|
+
end
|
72
|
+
|
73
|
+
def connection
|
74
|
+
ActiveRecord::Base.connection
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
ActiveSupport.on_load(:active_record) do
|
80
|
+
ActiveRecord::Base.send :include, ActiverecordCursorPagination::Extension
|
81
|
+
end
|