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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +45 -0
- data/CLOUD_RUN_README.md +263 -0
- data/DOCKER_README.md +327 -0
- data/Dockerfile +69 -0
- data/Dockerfile.cloudrun +76 -0
- data/Dockerfile.dev +36 -0
- data/GEM_Gemfile +16 -0
- data/GEM_README.md +421 -0
- data/GEM_Rakefile +10 -0
- data/GEM_gitignore +137 -0
- data/LICENSE.txt +21 -0
- data/PUBLISHING_GUIDE.md +269 -0
- data/README.md +392 -0
- data/app/controllers/api/v1/analysis_controller.rb +340 -0
- data/app/controllers/api/v1/api_keys_controller.rb +83 -0
- data/app/controllers/api/v1/base_controller.rb +93 -0
- data/app/controllers/api/v1/health_controller.rb +86 -0
- data/app/controllers/application_controller.rb +2 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/app_profile.rb +18 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/optimization_suggestion.rb +44 -0
- data/app/models/query_analysis.rb +47 -0
- data/app/models/query_pattern.rb +55 -0
- data/app/services/missing_index_detector_service.rb +244 -0
- data/app/services/n_plus_one_detector_service.rb +177 -0
- data/app/services/slow_query_analyzer_service.rb +225 -0
- data/app/services/sql_parser_service.rb +352 -0
- data/app/validators/query_data_validator.rb +96 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app.yaml +109 -0
- data/cloudbuild.yaml +47 -0
- data/config/application.rb +32 -0
- data/config/boot.rb +4 -0
- data/config/cable.yml +17 -0
- data/config/cache.yml +16 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +69 -0
- data/config/deploy.yml +116 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +70 -0
- data/config/environments/production.rb +87 -0
- data/config/environments/test.rb +53 -0
- data/config/initializers/cors.rb +16 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/locales/en.yml +31 -0
- data/config/master.key +1 -0
- data/config/puma.rb +41 -0
- data/config/puma_cloudrun.rb +48 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +28 -0
- data/config/storage.yml +34 -0
- data/config.ru +6 -0
- data/db/cable_schema.rb +11 -0
- data/db/cache_schema.rb +14 -0
- data/db/migrate/20250818214709_create_app_profiles.rb +13 -0
- data/db/migrate/20250818214731_create_query_analyses.rb +22 -0
- data/db/migrate/20250818214740_create_query_patterns.rb +22 -0
- data/db/migrate/20250818214805_create_optimization_suggestions.rb +20 -0
- data/db/queue_schema.rb +129 -0
- data/db/schema.rb +79 -0
- data/db/seeds.rb +9 -0
- data/init.sql +9 -0
- data/lib/query_optimizer_client/client.rb +176 -0
- data/lib/query_optimizer_client/configuration.rb +43 -0
- data/lib/query_optimizer_client/generators/install_generator.rb +43 -0
- data/lib/query_optimizer_client/generators/templates/README +46 -0
- data/lib/query_optimizer_client/generators/templates/analysis_job.rb +84 -0
- data/lib/query_optimizer_client/generators/templates/initializer.rb +30 -0
- data/lib/query_optimizer_client/middleware.rb +126 -0
- data/lib/query_optimizer_client/railtie.rb +37 -0
- data/lib/query_optimizer_client/tasks.rake +228 -0
- data/lib/query_optimizer_client/version.rb +5 -0
- data/lib/query_optimizer_client.rb +48 -0
- data/lib/tasks/.keep +0 -0
- data/public/robots.txt +1 -0
- data/query_optimizer_client.gemspec +60 -0
- data/script/.keep +0 -0
- data/storage/.keep +0 -0
- data/storage/development.sqlite3 +0 -0
- data/storage/test.sqlite3 +0 -0
- data/vendor/.keep +0 -0
- 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 @@
|
|
1
|
+
<%= yield %>
|