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.
@@ -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