activerecord_cursor_pagination 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,3 @@
1
+ module ActiverecordCursorPagination
2
+ VERSION = "0.1.0"
3
+ 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