QueryWise 0.2.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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +45 -0
  3. data/CLOUD_RUN_README.md +263 -0
  4. data/DOCKER_README.md +327 -0
  5. data/Dockerfile +69 -0
  6. data/Dockerfile.cloudrun +76 -0
  7. data/Dockerfile.dev +36 -0
  8. data/GEM_Gemfile +16 -0
  9. data/GEM_README.md +421 -0
  10. data/GEM_Rakefile +10 -0
  11. data/GEM_gitignore +137 -0
  12. data/LICENSE.txt +21 -0
  13. data/PUBLISHING_GUIDE.md +269 -0
  14. data/README.md +392 -0
  15. data/app/controllers/api/v1/analysis_controller.rb +340 -0
  16. data/app/controllers/api/v1/api_keys_controller.rb +83 -0
  17. data/app/controllers/api/v1/base_controller.rb +93 -0
  18. data/app/controllers/api/v1/health_controller.rb +86 -0
  19. data/app/controllers/application_controller.rb +2 -0
  20. data/app/controllers/concerns/.keep +0 -0
  21. data/app/jobs/application_job.rb +7 -0
  22. data/app/mailers/application_mailer.rb +4 -0
  23. data/app/models/app_profile.rb +18 -0
  24. data/app/models/application_record.rb +3 -0
  25. data/app/models/concerns/.keep +0 -0
  26. data/app/models/optimization_suggestion.rb +44 -0
  27. data/app/models/query_analysis.rb +47 -0
  28. data/app/models/query_pattern.rb +55 -0
  29. data/app/services/missing_index_detector_service.rb +244 -0
  30. data/app/services/n_plus_one_detector_service.rb +177 -0
  31. data/app/services/slow_query_analyzer_service.rb +225 -0
  32. data/app/services/sql_parser_service.rb +352 -0
  33. data/app/validators/query_data_validator.rb +96 -0
  34. data/app/views/layouts/mailer.html.erb +13 -0
  35. data/app/views/layouts/mailer.text.erb +1 -0
  36. data/app.yaml +109 -0
  37. data/cloudbuild.yaml +47 -0
  38. data/config/application.rb +32 -0
  39. data/config/boot.rb +4 -0
  40. data/config/cable.yml +17 -0
  41. data/config/cache.yml +16 -0
  42. data/config/credentials.yml.enc +1 -0
  43. data/config/database.yml +69 -0
  44. data/config/deploy.yml +116 -0
  45. data/config/environment.rb +5 -0
  46. data/config/environments/development.rb +70 -0
  47. data/config/environments/production.rb +87 -0
  48. data/config/environments/test.rb +53 -0
  49. data/config/initializers/cors.rb +16 -0
  50. data/config/initializers/filter_parameter_logging.rb +8 -0
  51. data/config/initializers/inflections.rb +16 -0
  52. data/config/locales/en.yml +31 -0
  53. data/config/master.key +1 -0
  54. data/config/puma.rb +41 -0
  55. data/config/puma_cloudrun.rb +48 -0
  56. data/config/queue.yml +18 -0
  57. data/config/recurring.yml +15 -0
  58. data/config/routes.rb +28 -0
  59. data/config/storage.yml +34 -0
  60. data/config.ru +6 -0
  61. data/db/cable_schema.rb +11 -0
  62. data/db/cache_schema.rb +14 -0
  63. data/db/migrate/20250818214709_create_app_profiles.rb +13 -0
  64. data/db/migrate/20250818214731_create_query_analyses.rb +22 -0
  65. data/db/migrate/20250818214740_create_query_patterns.rb +22 -0
  66. data/db/migrate/20250818214805_create_optimization_suggestions.rb +20 -0
  67. data/db/queue_schema.rb +129 -0
  68. data/db/schema.rb +79 -0
  69. data/db/seeds.rb +9 -0
  70. data/init.sql +9 -0
  71. data/lib/query_optimizer_client/client.rb +176 -0
  72. data/lib/query_optimizer_client/configuration.rb +43 -0
  73. data/lib/query_optimizer_client/generators/install_generator.rb +43 -0
  74. data/lib/query_optimizer_client/generators/templates/README +46 -0
  75. data/lib/query_optimizer_client/generators/templates/analysis_job.rb +84 -0
  76. data/lib/query_optimizer_client/generators/templates/initializer.rb +30 -0
  77. data/lib/query_optimizer_client/middleware.rb +126 -0
  78. data/lib/query_optimizer_client/railtie.rb +37 -0
  79. data/lib/query_optimizer_client/tasks.rake +228 -0
  80. data/lib/query_optimizer_client/version.rb +5 -0
  81. data/lib/query_optimizer_client.rb +48 -0
  82. data/lib/tasks/.keep +0 -0
  83. data/public/robots.txt +1 -0
  84. data/query_optimizer_client.gemspec +60 -0
  85. data/script/.keep +0 -0
  86. data/storage/.keep +0 -0
  87. data/storage/development.sqlite3 +0 -0
  88. data/storage/test.sqlite3 +0 -0
  89. data/vendor/.keep +0 -0
  90. metadata +265 -0
@@ -0,0 +1,225 @@
1
+ class SlowQueryAnalyzerService
2
+ attr_reader :queries, :slow_threshold, :very_slow_threshold, :critical_threshold
3
+
4
+ def initialize(queries, slow_threshold: 200, very_slow_threshold: 1000, critical_threshold: 5000)
5
+ @queries = Array(queries)
6
+ @slow_threshold = slow_threshold
7
+ @very_slow_threshold = very_slow_threshold
8
+ @critical_threshold = critical_threshold
9
+ end
10
+
11
+ def self.analyze(queries, **options)
12
+ new(queries, **options).analyze
13
+ end
14
+
15
+ def analyze
16
+ slow_query_issues = []
17
+
18
+ queries.each do |query|
19
+ next unless query.duration_ms && query.duration_ms > slow_threshold
20
+
21
+ issue = analyze_slow_query(query)
22
+ slow_query_issues << issue if issue
23
+ end
24
+
25
+ slow_query_issues
26
+ end
27
+
28
+ private
29
+
30
+ def analyze_slow_query(query)
31
+ parser = SqlParserService.new(query.sql_query)
32
+ return nil unless parser.valid?
33
+
34
+ severity = calculate_severity(query.duration_ms)
35
+ complexity_issues = analyze_query_complexity(parser)
36
+ optimization_suggestions = generate_optimization_suggestions(parser, query, complexity_issues)
37
+
38
+ {
39
+ type: 'slow_query',
40
+ query_analysis: query,
41
+ duration_ms: query.duration_ms,
42
+ severity: severity,
43
+ table_name: parser.primary_table,
44
+ query_type: parser.query_type,
45
+ complexity_issues: complexity_issues,
46
+ suggestions: optimization_suggestions,
47
+ pattern_signature: parser.query_signature
48
+ }
49
+ end
50
+
51
+ def calculate_severity(duration_ms)
52
+ case duration_ms
53
+ when 0...slow_threshold
54
+ 'normal'
55
+ when slow_threshold...very_slow_threshold
56
+ 'slow'
57
+ when very_slow_threshold...critical_threshold
58
+ 'very_slow'
59
+ else
60
+ 'critical'
61
+ end
62
+ end
63
+
64
+ def analyze_query_complexity(parser)
65
+ issues = []
66
+
67
+ # Check for SELECT * queries
68
+ if parser.sql_query.match?(/SELECT\s+\*/i)
69
+ issues << {
70
+ type: 'select_star',
71
+ description: 'Using SELECT * can be inefficient',
72
+ impact: 'medium'
73
+ }
74
+ end
75
+
76
+ # Check for missing WHERE clause on large tables
77
+ if parser.query_type == 'SELECT' && parser.where_conditions.empty?
78
+ issues << {
79
+ type: 'no_where_clause',
80
+ description: 'Query without WHERE clause may scan entire table',
81
+ impact: 'high'
82
+ }
83
+ end
84
+
85
+ # Check for complex WHERE conditions
86
+ where_conditions = parser.where_conditions
87
+ if where_conditions.length > 5
88
+ issues << {
89
+ type: 'complex_where',
90
+ description: 'Query has many WHERE conditions which may be inefficient',
91
+ impact: 'medium'
92
+ }
93
+ end
94
+
95
+ # Check for function calls in WHERE clause
96
+ if parser.sql_query.match?(/WHERE.*\w+\(/i)
97
+ issues << {
98
+ type: 'function_in_where',
99
+ description: 'Function calls in WHERE clause prevent index usage',
100
+ impact: 'high'
101
+ }
102
+ end
103
+
104
+ # Check for LIKE with leading wildcard
105
+ if parser.sql_query.match?(/LIKE\s+['"]%/i)
106
+ issues << {
107
+ type: 'leading_wildcard_like',
108
+ description: 'LIKE with leading wildcard cannot use indexes efficiently',
109
+ impact: 'high'
110
+ }
111
+ end
112
+
113
+ # Check for OR conditions
114
+ if parser.sql_query.match?(/\bOR\b/i)
115
+ issues << {
116
+ type: 'or_conditions',
117
+ description: 'OR conditions can prevent efficient index usage',
118
+ impact: 'medium'
119
+ }
120
+ end
121
+
122
+ # Check for subqueries
123
+ if parser.sql_query.match?(/\(\s*SELECT/i)
124
+ issues << {
125
+ type: 'subquery',
126
+ description: 'Subqueries may be less efficient than JOINs',
127
+ impact: 'medium'
128
+ }
129
+ end
130
+
131
+ issues
132
+ end
133
+
134
+ def generate_optimization_suggestions(parser, query, complexity_issues)
135
+ suggestions = []
136
+
137
+ # Duration-based suggestions
138
+ if query.duration_ms > critical_threshold
139
+ suggestions << {
140
+ title: 'Critical Performance Issue',
141
+ description: "Query takes #{query.duration_ms}ms which is extremely slow",
142
+ priority: 'critical',
143
+ recommendation: 'Immediate optimization required - consider query rewrite, indexing, or caching'
144
+ }
145
+ elsif query.duration_ms > very_slow_threshold
146
+ suggestions << {
147
+ title: 'Very Slow Query',
148
+ description: "Query takes #{query.duration_ms}ms which significantly impacts performance",
149
+ priority: 'high',
150
+ recommendation: 'High priority optimization needed'
151
+ }
152
+ elsif query.duration_ms > slow_threshold
153
+ suggestions << {
154
+ title: 'Slow Query',
155
+ description: "Query takes #{query.duration_ms}ms which may impact user experience",
156
+ priority: 'medium',
157
+ recommendation: 'Consider optimization when possible'
158
+ }
159
+ end
160
+
161
+ # Complexity-based suggestions
162
+ complexity_issues.each do |issue|
163
+ case issue[:type]
164
+ when 'select_star'
165
+ suggestions << {
166
+ title: 'Avoid SELECT *',
167
+ description: 'SELECT * retrieves all columns, including unnecessary ones',
168
+ priority: 'medium',
169
+ recommendation: 'Specify only the columns you need: SELECT id, name, email FROM users',
170
+ sql_example: parser.sql_query.gsub(/SELECT\s+\*/i, 'SELECT id, name, created_at')
171
+ }
172
+ when 'no_where_clause'
173
+ suggestions << {
174
+ title: 'Add WHERE clause',
175
+ description: 'Queries without WHERE clauses scan entire tables',
176
+ priority: 'high',
177
+ recommendation: 'Add appropriate WHERE conditions to limit the result set',
178
+ sql_example: "#{parser.sql_query.chomp} WHERE created_at > '2023-01-01'"
179
+ }
180
+ when 'function_in_where'
181
+ suggestions << {
182
+ title: 'Avoid functions in WHERE clause',
183
+ description: 'Functions in WHERE prevent index usage',
184
+ priority: 'high',
185
+ recommendation: 'Move function calls out of WHERE or create functional indexes'
186
+ }
187
+ when 'leading_wildcard_like'
188
+ suggestions << {
189
+ title: 'Optimize LIKE patterns',
190
+ description: 'Leading wildcards in LIKE prevent index usage',
191
+ priority: 'high',
192
+ recommendation: 'Use full-text search or avoid leading wildcards when possible'
193
+ }
194
+ when 'or_conditions'
195
+ suggestions << {
196
+ title: 'Consider alternatives to OR',
197
+ description: 'OR conditions can prevent efficient index usage',
198
+ priority: 'medium',
199
+ recommendation: 'Consider using UNION or IN clauses instead of OR'
200
+ }
201
+ when 'subquery'
202
+ suggestions << {
203
+ title: 'Consider JOIN instead of subquery',
204
+ description: 'JOINs are often more efficient than subqueries',
205
+ priority: 'medium',
206
+ recommendation: 'Rewrite subqueries as JOINs when possible'
207
+ }
208
+ end
209
+ end
210
+
211
+ # Index suggestions based on WHERE conditions
212
+ where_columns = parser.where_columns
213
+ if where_columns.any?
214
+ suggestions << {
215
+ title: 'Consider adding indexes',
216
+ description: "Query filters on columns: #{where_columns.join(', ')}",
217
+ priority: 'medium',
218
+ recommendation: "Consider adding indexes on frequently queried columns",
219
+ sql_example: where_columns.map { |col| "CREATE INDEX idx_#{parser.primary_table}_#{col} ON #{parser.primary_table}(#{col});" }.join("\n")
220
+ }
221
+ end
222
+
223
+ suggestions
224
+ end
225
+ end
@@ -0,0 +1,352 @@
1
+ class SqlParserService
2
+ attr_reader :sql_query, :parsed_query, :parse_tree
3
+
4
+ def initialize(sql_query)
5
+ @sql_query = sql_query.strip
6
+ @parsed_query = nil
7
+ @parse_tree = nil
8
+ parse_query
9
+ end
10
+
11
+ def self.parse(sql_query)
12
+ new(sql_query)
13
+ end
14
+
15
+ # Extract the main table being queried
16
+ def primary_table
17
+ return nil unless parsed_query
18
+
19
+ case query_type.downcase
20
+ when 'select'
21
+ extract_from_table
22
+ when 'insert'
23
+ extract_insert_table
24
+ when 'update'
25
+ extract_update_table
26
+ when 'delete'
27
+ extract_delete_table
28
+ else
29
+ nil
30
+ end
31
+ end
32
+
33
+ # Get all tables referenced in the query
34
+ def all_tables
35
+ return [] unless parsed_query
36
+
37
+ tables = []
38
+
39
+ # Extract from FROM clauses
40
+ tables.concat(extract_from_tables)
41
+
42
+ # Extract from JOIN clauses
43
+ tables.concat(extract_join_tables)
44
+
45
+ # Extract from subqueries
46
+ tables.concat(extract_subquery_tables)
47
+
48
+ tables.uniq.compact
49
+ end
50
+
51
+ # Extract WHERE clause conditions
52
+ def where_conditions
53
+ return [] unless parsed_query
54
+
55
+ conditions = []
56
+ stmt = first_statement
57
+
58
+ if stmt&.select_stmt&.where_clause
59
+ extract_where_conditions_recursive(stmt.select_stmt.where_clause, conditions)
60
+ elsif stmt&.update_stmt&.where_clause
61
+ extract_where_conditions_recursive(stmt.update_stmt.where_clause, conditions)
62
+ elsif stmt&.delete_stmt&.where_clause
63
+ extract_where_conditions_recursive(stmt.delete_stmt.where_clause, conditions)
64
+ end
65
+
66
+ conditions
67
+ end
68
+
69
+ # Extract columns used in WHERE clauses
70
+ def where_columns
71
+ where_conditions.map { |condition| condition[:column] }.compact.uniq
72
+ end
73
+
74
+ # Extract JOIN conditions
75
+ def join_conditions
76
+ return [] unless parsed_query
77
+
78
+ joins = []
79
+ extract_join_conditions_recursive(parse_tree, joins)
80
+ joins
81
+ end
82
+
83
+ # Determine query type (SELECT, INSERT, UPDATE, DELETE)
84
+ def query_type
85
+ return nil unless parsed_query
86
+
87
+ stmt = first_statement
88
+ return nil unless stmt
89
+
90
+ if stmt.select_stmt
91
+ 'SELECT'
92
+ elsif stmt.insert_stmt
93
+ 'INSERT'
94
+ elsif stmt.update_stmt
95
+ 'UPDATE'
96
+ elsif stmt.delete_stmt
97
+ 'DELETE'
98
+ else
99
+ 'UNKNOWN'
100
+ end
101
+ end
102
+
103
+ # Check if query has potential for N+1 pattern
104
+ def potential_n_plus_one?
105
+ return false unless query_type == 'SELECT'
106
+
107
+ # Look for single-row lookups by ID or foreign key
108
+ where_conditions.any? do |condition|
109
+ column = condition[:column]
110
+ next false unless column
111
+
112
+ # Match patterns like: id, user_id, posts.id, table.column_id
113
+ id_pattern = column.match?(/\bid\b|_id$/i)
114
+
115
+ id_pattern &&
116
+ condition[:operator] == '=' &&
117
+ (condition[:value_type] == 'param' || condition[:value_type] == 'integer')
118
+ end
119
+ end
120
+
121
+ # Extract ORDER BY columns
122
+ def order_by_columns
123
+ return [] unless parsed_query && query_type == 'SELECT'
124
+
125
+ columns = []
126
+
127
+ # Use regex to extract ORDER BY columns
128
+ if sql_query.match?(/ORDER\s+BY/i)
129
+ # Extract everything after ORDER BY
130
+ order_part = sql_query.split(/ORDER\s+BY/i)[1]
131
+ return [] unless order_part
132
+
133
+ # Remove everything after LIMIT, OFFSET, or end of string
134
+ order_clause = order_part.split(/\s+(?:LIMIT|OFFSET|;)/i)[0].strip
135
+
136
+ # Split by comma and extract column names
137
+ order_clause.split(',').each do |item|
138
+ # Remove ASC/DESC and whitespace, extract column name
139
+ column = item.strip.gsub(/\s+(ASC|DESC)\s*$/i, '').strip
140
+ # Handle table.column format
141
+ column = column.split('.').last if column.include?('.')
142
+ # Remove any remaining whitespace or quotes
143
+ column = column.gsub(/["`']/, '').strip
144
+ columns << column unless column.empty?
145
+ end
146
+ end
147
+
148
+ columns.uniq
149
+ end
150
+
151
+ # Check if query uses indexes (basic heuristic)
152
+ def likely_uses_index?
153
+ # Simple heuristic: queries with WHERE on id columns likely use indexes
154
+ where_columns.any? { |col| col.match?(/(_id|\.id)$/i) }
155
+ end
156
+
157
+ # Generate a normalized query signature for similarity detection
158
+ def query_signature
159
+ normalized = sql_query.gsub(/\$\d+/, '?') # Replace parameter placeholders first
160
+ .gsub(/\b\d+\b/, '?') # Replace numbers
161
+ .gsub(/'[^']*'/, '?') # Replace string literals
162
+ .gsub(/\s+/, ' ') # Normalize whitespace
163
+ .strip
164
+ .downcase
165
+
166
+ Digest::SHA256.hexdigest(normalized)
167
+ end
168
+
169
+ # Check if query is valid
170
+ def valid?
171
+ !parsed_query.nil?
172
+ end
173
+
174
+ # Get parsing errors
175
+ def errors
176
+ @errors ||= []
177
+ end
178
+
179
+ private
180
+
181
+ def first_statement
182
+ return nil unless parsed_query&.tree&.stmts&.first
183
+ parsed_query.tree.stmts.first.stmt
184
+ end
185
+
186
+ def parse_query
187
+ begin
188
+ @parsed_query = PgQuery.parse(sql_query)
189
+ @parse_tree = @parsed_query
190
+ @errors = []
191
+ rescue PgQuery::ParseError => e
192
+ @errors = [e.message]
193
+ @parsed_query = nil
194
+ @parse_tree = nil
195
+ rescue => e
196
+ @errors = ["Unexpected parsing error: #{e.message}"]
197
+ @parsed_query = nil
198
+ @parse_tree = nil
199
+ end
200
+ end
201
+
202
+ def extract_from_table
203
+ stmt = first_statement
204
+ return nil unless stmt&.select_stmt&.from_clause&.first
205
+
206
+ range_var = stmt.select_stmt.from_clause.first.range_var
207
+ range_var&.relname
208
+ end
209
+
210
+ def extract_insert_table
211
+ stmt = first_statement
212
+ stmt&.insert_stmt&.relation&.relname
213
+ end
214
+
215
+ def extract_update_table
216
+ stmt = first_statement
217
+ stmt&.update_stmt&.relation&.relname
218
+ end
219
+
220
+ def extract_delete_table
221
+ stmt = first_statement
222
+ stmt&.delete_stmt&.relation&.relname
223
+ end
224
+
225
+ def extract_from_tables
226
+ tables = []
227
+ stmt = first_statement
228
+ return tables unless stmt&.select_stmt&.from_clause
229
+
230
+ stmt.select_stmt.from_clause.each do |item|
231
+ if item.range_var
232
+ tables << item.range_var.relname
233
+ end
234
+ end
235
+
236
+ tables
237
+ end
238
+
239
+ def extract_join_tables
240
+ # This would need more complex parsing for JOIN clauses
241
+ # For now, return empty array - can be enhanced
242
+ []
243
+ end
244
+
245
+ def extract_subquery_tables
246
+ # This would need recursive parsing for subqueries
247
+ # For now, return empty array - can be enhanced
248
+ []
249
+ end
250
+
251
+ def extract_where_conditions_recursive(node, conditions)
252
+ return unless node
253
+
254
+ if node.respond_to?(:a_expr) && node.a_expr
255
+ condition = parse_a_expr(node.a_expr)
256
+ conditions << condition if condition
257
+ elsif node.respond_to?(:bool_expr) && node.bool_expr
258
+ # Handle AND/OR expressions
259
+ node.bool_expr.args&.each do |arg|
260
+ extract_where_conditions_recursive(arg, conditions)
261
+ end
262
+ end
263
+
264
+ # Recursively search through the node structure
265
+ if node.respond_to?(:each)
266
+ node.each do |item|
267
+ extract_where_conditions_recursive(item, conditions)
268
+ end
269
+ elsif node.class.name.start_with?('PgQuery::')
270
+ # For PgQuery objects, iterate through their attributes
271
+ node.instance_variables.each do |var|
272
+ value = node.instance_variable_get(var)
273
+ extract_where_conditions_recursive(value, conditions)
274
+ end
275
+ end
276
+ end
277
+
278
+ def parse_a_expr(expr)
279
+ return nil unless expr.kind == :AEXPR_OP
280
+
281
+ left = expr.lexpr
282
+ right = expr.rexpr
283
+ operator = expr.name&.first&.string&.sval
284
+
285
+ column = extract_column_ref(left)
286
+ value_info = extract_value_info(right)
287
+
288
+ {
289
+ column: column,
290
+ operator: operator,
291
+ value: value_info[:value],
292
+ value_type: value_info[:type]
293
+ }
294
+ end
295
+
296
+ def extract_column_ref(node)
297
+ return nil unless node&.column_ref
298
+
299
+ fields = node.column_ref.fields
300
+ return nil unless fields
301
+
302
+ # Handle table.column or just column
303
+ if fields.length == 2
304
+ table = fields[0]&.string&.sval
305
+ column = fields[1]&.string&.sval
306
+ "#{table}.#{column}"
307
+ elsif fields.length == 1
308
+ fields[0]&.string&.sval
309
+ end
310
+ end
311
+
312
+ def extract_value_info(node)
313
+ if node&.a_const
314
+ const = node.a_const
315
+ if const.isnull
316
+ { value: nil, type: 'null' }
317
+ elsif const.ival
318
+ { value: const.ival.ival, type: 'integer' }
319
+ elsif const.sval
320
+ { value: const.sval.sval, type: 'string' }
321
+ elsif const.boolval
322
+ { value: const.boolval.boolval, type: 'boolean' }
323
+ else
324
+ { value: const.to_s, type: 'unknown' }
325
+ end
326
+ elsif node&.param_ref
327
+ { value: "$#{node.param_ref.number}", type: 'param' }
328
+ else
329
+ { value: node.to_s, type: 'expression' }
330
+ end
331
+ end
332
+
333
+ def extract_join_conditions_recursive(node, joins)
334
+ # Implementation for JOIN condition extraction
335
+ # This is complex and would need detailed parsing
336
+ # For now, placeholder
337
+ end
338
+
339
+ def extract_order_by_recursive(node, columns)
340
+ return unless node
341
+
342
+ stmt = first_statement
343
+ return unless stmt&.select_stmt&.sort_clause
344
+
345
+ stmt.select_stmt.sort_clause.each do |sort_item|
346
+ if sort_item.node&.column_ref
347
+ column_ref = extract_column_ref(sort_item.node)
348
+ columns << column_ref if column_ref
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,96 @@
1
+ class QueryDataValidator
2
+ include ActiveModel::Validations
3
+
4
+ attr_accessor :sql, :duration_ms
5
+
6
+ validates :sql, presence: { message: "SQL query is required" }
7
+ validates :sql, length: {
8
+ minimum: 10,
9
+ maximum: 10000,
10
+ message: "SQL query must be between 10 and 10,000 characters"
11
+ }
12
+ validates :duration_ms, numericality: {
13
+ greater_than_or_equal_to: 0,
14
+ less_than: 3600000, # 1 hour max
15
+ message: "Duration must be between 0 and 3,600,000 milliseconds"
16
+ }, allow_nil: true
17
+
18
+ def initialize(query_data)
19
+ @sql = query_data[:sql] || query_data['sql']
20
+ @duration_ms = query_data[:duration_ms] || query_data['duration_ms']
21
+ end
22
+
23
+ def self.validate_queries(queries_data)
24
+ errors = []
25
+
26
+ unless queries_data.is_a?(Array)
27
+ return ["Queries must be an array"]
28
+ end
29
+
30
+ if queries_data.empty?
31
+ return ["At least one query is required"]
32
+ end
33
+
34
+ if queries_data.length > 100
35
+ return ["Maximum 100 queries allowed per request"]
36
+ end
37
+
38
+ queries_data.each_with_index do |query_data, index|
39
+ validator = new(query_data)
40
+ unless validator.valid?
41
+ validator.errors.messages.each do |attribute, messages|
42
+ messages.each do |message|
43
+ errors << "Query #{index + 1}: #{message}"
44
+ end
45
+ end
46
+ end
47
+
48
+ # Additional SQL validation
49
+ sql = query_data[:sql] || query_data['sql']
50
+ if sql.present?
51
+ sql_errors = validate_sql_content(sql, index + 1)
52
+ errors.concat(sql_errors)
53
+ end
54
+ end
55
+
56
+ errors
57
+ end
58
+
59
+ private
60
+
61
+ def self.validate_sql_content(sql, index)
62
+ errors = []
63
+
64
+ # Check for dangerous SQL patterns
65
+ dangerous_patterns = [
66
+ /\bDROP\s+/i,
67
+ /\bDELETE\s+FROM\s+(?!.*WHERE)/i, # DELETE without WHERE
68
+ /\bTRUNCATE\s+/i,
69
+ /\bALTER\s+/i,
70
+ /\bCREATE\s+/i,
71
+ /\bINSERT\s+INTO\s+(?!.*VALUES)/i, # INSERT without VALUES
72
+ /\bUPDATE\s+(?!.*WHERE)/i, # UPDATE without WHERE
73
+ /\bGRANT\s+/i,
74
+ /\bREVOKE\s+/i
75
+ ]
76
+
77
+ dangerous_patterns.each do |pattern|
78
+ if sql.match?(pattern)
79
+ errors << "Query #{index}: Contains potentially dangerous SQL pattern"
80
+ break
81
+ end
82
+ end
83
+
84
+ # Check for basic SQL structure
85
+ unless sql.match?(/\b(SELECT|INSERT|UPDATE|DELETE)\b/i)
86
+ errors << "Query #{index}: Must be a valid SQL statement (SELECT, INSERT, UPDATE, or DELETE)"
87
+ end
88
+
89
+ # Check for excessive complexity
90
+ if sql.scan(/\bSELECT\b/i).length > 5
91
+ errors << "Query #{index}: Query appears to be too complex (too many subqueries)"
92
+ end
93
+
94
+ errors
95
+ end
96
+ end
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
5
+ <style>
6
+ /* Email styles need to be inline */
7
+ </style>
8
+ </head>
9
+
10
+ <body>
11
+ <%= yield %>
12
+ </body>
13
+ </html>
@@ -0,0 +1 @@
1
+ <%= yield %>