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,340 @@
|
|
1
|
+
class Api::V1::AnalysisController < Api::V1::BaseController
|
2
|
+
def analyze
|
3
|
+
if params[:queries].nil?
|
4
|
+
render_error('Missing required parameter: queries')
|
5
|
+
return
|
6
|
+
end
|
7
|
+
|
8
|
+
queries_data = params[:queries]
|
9
|
+
|
10
|
+
# Validate queries using the validator
|
11
|
+
validation_errors = QueryDataValidator.validate_queries(queries_data)
|
12
|
+
if validation_errors.any?
|
13
|
+
render_error('Validation failed', :bad_request, errors: validation_errors)
|
14
|
+
return
|
15
|
+
end
|
16
|
+
|
17
|
+
# Create QueryAnalysis records
|
18
|
+
query_analyses = []
|
19
|
+
queries_data.each do |query_data|
|
20
|
+
begin
|
21
|
+
analysis = create_query_analysis(query_data)
|
22
|
+
query_analyses << analysis if analysis
|
23
|
+
rescue => e
|
24
|
+
Rails.logger.error "Error creating query analysis: #{e.message}"
|
25
|
+
Rails.logger.error e.backtrace.join("\n")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
if query_analyses.empty?
|
30
|
+
render_error('No valid queries provided')
|
31
|
+
return
|
32
|
+
end
|
33
|
+
|
34
|
+
# Run analysis
|
35
|
+
begin
|
36
|
+
analysis_results = perform_analysis(query_analyses)
|
37
|
+
rescue => e
|
38
|
+
Rails.logger.error "Error performing analysis: #{e.message}"
|
39
|
+
Rails.logger.error e.backtrace.join("\n")
|
40
|
+
render_error('Analysis failed')
|
41
|
+
return
|
42
|
+
end
|
43
|
+
|
44
|
+
# Store optimization suggestions
|
45
|
+
begin
|
46
|
+
store_suggestions(query_analyses, analysis_results)
|
47
|
+
rescue => e
|
48
|
+
Rails.logger.error "Failed to store suggestions: #{e.message}"
|
49
|
+
Rails.logger.error e.backtrace.join("\n")
|
50
|
+
# Continue without storing suggestions for now
|
51
|
+
end
|
52
|
+
|
53
|
+
# Format response
|
54
|
+
response_data = format_analysis_response(analysis_results)
|
55
|
+
|
56
|
+
render_success(response_data, message: "Analyzed #{query_analyses.length} queries")
|
57
|
+
end
|
58
|
+
|
59
|
+
def analyze_ci
|
60
|
+
if params[:queries].nil?
|
61
|
+
render_error('Missing required parameter: queries')
|
62
|
+
return
|
63
|
+
end
|
64
|
+
|
65
|
+
queries_data = params[:queries]
|
66
|
+
threshold_score = params[:threshold_score] || 70
|
67
|
+
|
68
|
+
# Validate queries using the validator
|
69
|
+
validation_errors = QueryDataValidator.validate_queries(queries_data)
|
70
|
+
if validation_errors.any?
|
71
|
+
render_error('Validation failed', :bad_request, errors: validation_errors)
|
72
|
+
return
|
73
|
+
end
|
74
|
+
|
75
|
+
# Validate threshold score
|
76
|
+
if threshold_score.present? && (!threshold_score.is_a?(Numeric) || threshold_score < 0 || threshold_score > 100)
|
77
|
+
render_error('Threshold score must be a number between 0 and 100')
|
78
|
+
return
|
79
|
+
end
|
80
|
+
|
81
|
+
# Create temporary QueryAnalysis records (don't persist for CI)
|
82
|
+
query_analyses = []
|
83
|
+
queries_data.each do |query_data|
|
84
|
+
analysis = build_query_analysis(query_data)
|
85
|
+
query_analyses << analysis if analysis&.valid?
|
86
|
+
end
|
87
|
+
|
88
|
+
if query_analyses.empty?
|
89
|
+
render_error('No valid queries provided')
|
90
|
+
return
|
91
|
+
end
|
92
|
+
|
93
|
+
# Run analysis
|
94
|
+
analysis_results = perform_analysis(query_analyses)
|
95
|
+
|
96
|
+
# Calculate CI score
|
97
|
+
ci_results = calculate_ci_score(analysis_results, threshold_score)
|
98
|
+
|
99
|
+
render_success(ci_results)
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def create_query_analysis(query_data)
|
105
|
+
sql_query = query_data[:sql] || query_data['sql']
|
106
|
+
duration_ms = query_data[:duration_ms] || query_data['duration_ms']
|
107
|
+
|
108
|
+
if sql_query.blank?
|
109
|
+
return nil
|
110
|
+
end
|
111
|
+
|
112
|
+
parser = SqlParserService.new(sql_query)
|
113
|
+
|
114
|
+
# Ensure we have valid parsed data
|
115
|
+
unless parser.valid?
|
116
|
+
Rails.logger.error "Invalid SQL query: #{sql_query}"
|
117
|
+
return nil
|
118
|
+
end
|
119
|
+
|
120
|
+
@current_app_profile.query_analyses.create!(
|
121
|
+
sql_query: sql_query,
|
122
|
+
duration_ms: duration_ms,
|
123
|
+
table_name: parser.primary_table,
|
124
|
+
query_type: parser.query_type,
|
125
|
+
analyzed_at: Time.current,
|
126
|
+
query_hash: parser.query_signature,
|
127
|
+
parsed_data: {
|
128
|
+
where_columns: parser.where_columns,
|
129
|
+
order_by_columns: parser.order_by_columns,
|
130
|
+
all_tables: parser.all_tables
|
131
|
+
}
|
132
|
+
)
|
133
|
+
rescue => e
|
134
|
+
Rails.logger.error "Failed to create query analysis: #{e.message}"
|
135
|
+
Rails.logger.error e.backtrace.join("\n")
|
136
|
+
nil
|
137
|
+
end
|
138
|
+
|
139
|
+
def build_query_analysis(query_data)
|
140
|
+
sql_query = query_data[:sql] || query_data['sql']
|
141
|
+
duration_ms = query_data[:duration_ms] || query_data['duration_ms']
|
142
|
+
|
143
|
+
return nil if sql_query.blank?
|
144
|
+
|
145
|
+
parser = SqlParserService.new(sql_query)
|
146
|
+
|
147
|
+
@current_app_profile.query_analyses.build(
|
148
|
+
sql_query: sql_query,
|
149
|
+
duration_ms: duration_ms,
|
150
|
+
table_name: parser.primary_table,
|
151
|
+
query_type: parser.query_type,
|
152
|
+
analyzed_at: Time.current,
|
153
|
+
query_hash: parser.query_signature,
|
154
|
+
parsed_data: {
|
155
|
+
where_columns: parser.where_columns,
|
156
|
+
order_by_columns: parser.order_by_columns,
|
157
|
+
all_tables: parser.all_tables
|
158
|
+
}
|
159
|
+
)
|
160
|
+
rescue => e
|
161
|
+
Rails.logger.error "Failed to build query analysis: #{e.message}"
|
162
|
+
nil
|
163
|
+
end
|
164
|
+
|
165
|
+
def perform_analysis(query_analyses)
|
166
|
+
results = {
|
167
|
+
n_plus_one: [],
|
168
|
+
slow_queries: [],
|
169
|
+
missing_indexes: []
|
170
|
+
}
|
171
|
+
|
172
|
+
# N+1 Detection
|
173
|
+
n_plus_one_patterns = NPlusOneDetectorService.detect(query_analyses)
|
174
|
+
results[:n_plus_one] = n_plus_one_patterns
|
175
|
+
|
176
|
+
# Slow Query Analysis
|
177
|
+
slow_query_issues = SlowQueryAnalyzerService.analyze(query_analyses)
|
178
|
+
results[:slow_queries] = slow_query_issues
|
179
|
+
|
180
|
+
# Missing Index Detection
|
181
|
+
index_suggestions = MissingIndexDetectorService.detect(query_analyses)
|
182
|
+
results[:missing_indexes] = index_suggestions
|
183
|
+
|
184
|
+
results
|
185
|
+
end
|
186
|
+
|
187
|
+
def store_suggestions(query_analyses, analysis_results)
|
188
|
+
# Store N+1 suggestions
|
189
|
+
analysis_results[:n_plus_one].each do |pattern|
|
190
|
+
next unless pattern[:first_query]
|
191
|
+
|
192
|
+
OptimizationSuggestion.create!(
|
193
|
+
query_analysis: pattern[:first_query],
|
194
|
+
suggestion_type: 'n_plus_one',
|
195
|
+
title: pattern[:suggestion][:title],
|
196
|
+
description: pattern[:suggestion][:description],
|
197
|
+
sql_suggestion: pattern[:suggestion][:sql_suggestion],
|
198
|
+
priority: severity_to_priority(pattern[:severity]),
|
199
|
+
metadata: pattern.except(:first_query, :sample_queries)
|
200
|
+
)
|
201
|
+
end
|
202
|
+
|
203
|
+
# Store slow query suggestions
|
204
|
+
analysis_results[:slow_queries].each do |issue|
|
205
|
+
issue[:suggestions].each do |suggestion|
|
206
|
+
OptimizationSuggestion.create!(
|
207
|
+
query_analysis: issue[:query_analysis],
|
208
|
+
suggestion_type: 'slow_query',
|
209
|
+
title: suggestion[:title],
|
210
|
+
description: suggestion[:description],
|
211
|
+
sql_suggestion: suggestion[:sql_example],
|
212
|
+
priority: priority_to_number(suggestion[:priority]),
|
213
|
+
metadata: suggestion.except(:title, :description, :sql_example)
|
214
|
+
)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# Store index suggestions (create a representative query analysis if needed)
|
219
|
+
analysis_results[:missing_indexes].each do |suggestion|
|
220
|
+
# Find a representative query for this table
|
221
|
+
representative_query = query_analyses.find { |qa| qa.table_name == suggestion[:table_name] }
|
222
|
+
next unless representative_query
|
223
|
+
|
224
|
+
OptimizationSuggestion.create!(
|
225
|
+
query_analysis: representative_query,
|
226
|
+
suggestion_type: 'missing_index',
|
227
|
+
title: "Add index on #{suggestion[:table_name]}(#{suggestion[:columns].join(', ')})",
|
228
|
+
description: suggestion[:description],
|
229
|
+
sql_suggestion: suggestion[:sql],
|
230
|
+
priority: suggestion[:priority],
|
231
|
+
metadata: suggestion.except(:sql, :description)
|
232
|
+
)
|
233
|
+
end
|
234
|
+
rescue => e
|
235
|
+
Rails.logger.error "Failed to store suggestions: #{e.message}"
|
236
|
+
end
|
237
|
+
|
238
|
+
def format_analysis_response(results)
|
239
|
+
{
|
240
|
+
n_plus_one: {
|
241
|
+
detected: results[:n_plus_one].any?,
|
242
|
+
patterns: results[:n_plus_one].map { |pattern| format_n_plus_one_pattern(pattern) }
|
243
|
+
},
|
244
|
+
slow_queries: results[:slow_queries].map { |issue| format_slow_query_issue(issue) },
|
245
|
+
missing_indexes: results[:missing_indexes].map { |suggestion| format_index_suggestion(suggestion) },
|
246
|
+
summary: {
|
247
|
+
total_issues: results[:n_plus_one].length + results[:slow_queries].length,
|
248
|
+
index_suggestions: results[:missing_indexes].length,
|
249
|
+
severity_breakdown: calculate_severity_breakdown(results)
|
250
|
+
}
|
251
|
+
}
|
252
|
+
end
|
253
|
+
|
254
|
+
def format_n_plus_one_pattern(pattern)
|
255
|
+
{
|
256
|
+
table: pattern[:table_name],
|
257
|
+
column: pattern[:column_name],
|
258
|
+
query_count: pattern[:query_count],
|
259
|
+
severity: pattern[:severity],
|
260
|
+
suggestion: pattern[:suggestion][:rails_suggestion],
|
261
|
+
example_code: pattern[:suggestion][:example_code]
|
262
|
+
}
|
263
|
+
end
|
264
|
+
|
265
|
+
def format_slow_query_issue(issue)
|
266
|
+
{
|
267
|
+
sql: issue[:query_analysis].sql_query,
|
268
|
+
duration_ms: issue[:duration_ms],
|
269
|
+
severity: issue[:severity],
|
270
|
+
table: issue[:table_name],
|
271
|
+
suggestions: issue[:suggestions].map { |s| s.slice(:title, :description, :recommendation) }
|
272
|
+
}
|
273
|
+
end
|
274
|
+
|
275
|
+
def format_index_suggestion(suggestion)
|
276
|
+
{
|
277
|
+
table: suggestion[:table_name],
|
278
|
+
columns: suggestion[:columns],
|
279
|
+
type: suggestion[:type],
|
280
|
+
reason: suggestion[:reason],
|
281
|
+
sql: suggestion[:sql],
|
282
|
+
priority: suggestion[:priority],
|
283
|
+
impact: suggestion[:impact]
|
284
|
+
}
|
285
|
+
end
|
286
|
+
|
287
|
+
def calculate_ci_score(results, threshold)
|
288
|
+
total_issues = results[:n_plus_one].length + results[:slow_queries].length
|
289
|
+
critical_issues = results[:n_plus_one].count { |p| p[:severity] == 'critical' } +
|
290
|
+
results[:slow_queries].count { |i| i[:severity] == 'critical' }
|
291
|
+
|
292
|
+
# Calculate score (100 = perfect, 0 = terrible)
|
293
|
+
score = 100
|
294
|
+
score -= total_issues * 5 # -5 points per issue
|
295
|
+
score -= critical_issues * 15 # Additional -15 points for critical issues
|
296
|
+
score = [score, 0].max # Don't go below 0
|
297
|
+
|
298
|
+
{
|
299
|
+
score: score,
|
300
|
+
passed: score >= threshold,
|
301
|
+
threshold: threshold,
|
302
|
+
issues: {
|
303
|
+
total: total_issues,
|
304
|
+
critical: critical_issues,
|
305
|
+
n_plus_one: results[:n_plus_one].length,
|
306
|
+
slow_queries: results[:slow_queries].length
|
307
|
+
},
|
308
|
+
recommendations: results[:missing_indexes].length
|
309
|
+
}
|
310
|
+
end
|
311
|
+
|
312
|
+
def severity_to_priority(severity)
|
313
|
+
case severity
|
314
|
+
when 'low' then 1
|
315
|
+
when 'medium' then 2
|
316
|
+
when 'high' then 3
|
317
|
+
when 'critical' then 4
|
318
|
+
else 2
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
def priority_to_number(priority)
|
323
|
+
case priority
|
324
|
+
when 'low' then 1
|
325
|
+
when 'medium' then 2
|
326
|
+
when 'high' then 3
|
327
|
+
when 'critical' then 4
|
328
|
+
else 2
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
def calculate_severity_breakdown(results)
|
333
|
+
breakdown = { low: 0, medium: 0, high: 0, critical: 0 }
|
334
|
+
|
335
|
+
results[:n_plus_one].each { |p| breakdown[p[:severity].to_sym] += 1 }
|
336
|
+
results[:slow_queries].each { |i| breakdown[i[:severity].to_sym] += 1 }
|
337
|
+
|
338
|
+
breakdown
|
339
|
+
end
|
340
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
class Api::V1::ApiKeysController < Api::V1::BaseController
|
2
|
+
skip_before_action :authenticate_api_key, only: [:create]
|
3
|
+
|
4
|
+
def create
|
5
|
+
if params[:app_name].nil?
|
6
|
+
render_error('Missing required parameter: app_name')
|
7
|
+
return
|
8
|
+
end
|
9
|
+
|
10
|
+
app_name = params[:app_name]
|
11
|
+
|
12
|
+
if app_name.blank?
|
13
|
+
render_error('App name is required')
|
14
|
+
return
|
15
|
+
end
|
16
|
+
|
17
|
+
if app_name.length < 3 || app_name.length > 100
|
18
|
+
render_error('App name must be between 3 and 100 characters')
|
19
|
+
return
|
20
|
+
end
|
21
|
+
|
22
|
+
# Check if app name already exists
|
23
|
+
if AppProfile.exists?(name: app_name)
|
24
|
+
render_error('App name already exists')
|
25
|
+
return
|
26
|
+
end
|
27
|
+
|
28
|
+
app_profile = AppProfile.new(name: app_name)
|
29
|
+
api_key = app_profile.generate_api_key!
|
30
|
+
|
31
|
+
if app_profile.persisted?
|
32
|
+
render_success({
|
33
|
+
app_name: app_profile.name,
|
34
|
+
api_key: api_key,
|
35
|
+
created_at: app_profile.created_at.iso8601
|
36
|
+
}, message: 'API key created successfully')
|
37
|
+
else
|
38
|
+
render_error('Failed to create API key', :internal_server_error)
|
39
|
+
end
|
40
|
+
rescue ActionController::ParameterMissing => e
|
41
|
+
render_error("Missing required parameter: #{e.param}")
|
42
|
+
end
|
43
|
+
|
44
|
+
def show
|
45
|
+
render_success({
|
46
|
+
app_name: @current_app_profile.name,
|
47
|
+
created_at: @current_app_profile.created_at.iso8601,
|
48
|
+
last_used: @current_app_profile.updated_at.iso8601,
|
49
|
+
total_queries: @current_app_profile.query_analyses.count
|
50
|
+
})
|
51
|
+
end
|
52
|
+
|
53
|
+
def regenerate
|
54
|
+
begin
|
55
|
+
new_api_key = @current_app_profile.generate_api_key!
|
56
|
+
|
57
|
+
render_success({
|
58
|
+
app_name: @current_app_profile.name,
|
59
|
+
api_key: new_api_key,
|
60
|
+
regenerated_at: Time.current.iso8601
|
61
|
+
}, message: 'API key regenerated successfully')
|
62
|
+
rescue => e
|
63
|
+
Rails.logger.error "Failed to regenerate API key: #{e.message}"
|
64
|
+
render_error('Failed to regenerate API key', :internal_server_error)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def destroy
|
69
|
+
app_name = @current_app_profile.name
|
70
|
+
|
71
|
+
begin
|
72
|
+
@current_app_profile.destroy!
|
73
|
+
|
74
|
+
render_success({
|
75
|
+
app_name: app_name,
|
76
|
+
deleted_at: Time.current.iso8601
|
77
|
+
}, message: 'API key deleted successfully')
|
78
|
+
rescue => e
|
79
|
+
Rails.logger.error "Failed to delete API key: #{e.message}"
|
80
|
+
render_error('Failed to delete API key', :internal_server_error)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
class Api::V1::BaseController < ApplicationController
|
2
|
+
before_action :authenticate_api_key
|
3
|
+
before_action :set_default_response_format
|
4
|
+
before_action :check_rate_limit
|
5
|
+
before_action :update_last_used
|
6
|
+
|
7
|
+
rescue_from StandardError, with: :handle_standard_error
|
8
|
+
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
9
|
+
rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def authenticate_api_key
|
14
|
+
api_key = request.headers['X-API-Key'] || params[:api_key]
|
15
|
+
|
16
|
+
if api_key.blank?
|
17
|
+
render_error('API key is required', :unauthorized)
|
18
|
+
return
|
19
|
+
end
|
20
|
+
|
21
|
+
@current_app_profile = AppProfile.find_by(api_key_digest: BCrypt::Password.create(api_key))
|
22
|
+
|
23
|
+
unless @current_app_profile
|
24
|
+
render_error('Invalid API key', :unauthorized)
|
25
|
+
return
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def set_default_response_format
|
30
|
+
request.format = :json
|
31
|
+
end
|
32
|
+
|
33
|
+
def render_success(data, status: :ok, message: nil)
|
34
|
+
response = {
|
35
|
+
success: true,
|
36
|
+
data: data
|
37
|
+
}
|
38
|
+
response[:message] = message if message
|
39
|
+
|
40
|
+
render json: response, status: status
|
41
|
+
end
|
42
|
+
|
43
|
+
def render_error(message, status = :bad_request, errors: nil)
|
44
|
+
response = {
|
45
|
+
success: false,
|
46
|
+
error: message
|
47
|
+
}
|
48
|
+
response[:errors] = errors if errors
|
49
|
+
|
50
|
+
render json: response, status: status
|
51
|
+
end
|
52
|
+
|
53
|
+
def handle_standard_error(exception)
|
54
|
+
Rails.logger.error "API Error: #{exception.message}"
|
55
|
+
Rails.logger.error exception.backtrace.join("\n")
|
56
|
+
|
57
|
+
render_error('Internal server error', :internal_server_error)
|
58
|
+
end
|
59
|
+
|
60
|
+
def handle_not_found(exception)
|
61
|
+
render_error('Resource not found', :not_found)
|
62
|
+
end
|
63
|
+
|
64
|
+
def handle_parameter_missing(exception)
|
65
|
+
render_error("Missing required parameter: #{exception.param}", :bad_request)
|
66
|
+
end
|
67
|
+
|
68
|
+
def check_rate_limit
|
69
|
+
return unless @current_app_profile
|
70
|
+
|
71
|
+
# Simple rate limiting: 1000 requests per hour per API key
|
72
|
+
cache_key = "rate_limit:#{@current_app_profile.id}"
|
73
|
+
current_count = Rails.cache.read(cache_key) || 0
|
74
|
+
|
75
|
+
if current_count >= 1000
|
76
|
+
render_error('Rate limit exceeded. Maximum 1000 requests per hour.', :too_many_requests)
|
77
|
+
return
|
78
|
+
end
|
79
|
+
|
80
|
+
Rails.cache.write(cache_key, current_count + 1, expires_in: 1.hour)
|
81
|
+
end
|
82
|
+
|
83
|
+
def update_last_used
|
84
|
+
return unless @current_app_profile
|
85
|
+
|
86
|
+
# Update the last used timestamp (but not on every request to avoid too many DB writes)
|
87
|
+
last_update = Rails.cache.read("last_update:#{@current_app_profile.id}")
|
88
|
+
if last_update.nil? || last_update < 5.minutes.ago
|
89
|
+
@current_app_profile.touch(:updated_at)
|
90
|
+
Rails.cache.write("last_update:#{@current_app_profile.id}", Time.current, expires_in: 5.minutes)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
class Api::V1::HealthController < Api::V1::BaseController
|
2
|
+
# Skip authentication for health checks
|
3
|
+
skip_before_action :authenticate_api_key
|
4
|
+
|
5
|
+
def show
|
6
|
+
health_data = {
|
7
|
+
status: 'ok',
|
8
|
+
timestamp: Time.current.iso8601,
|
9
|
+
version: '1.0.0',
|
10
|
+
services: check_services
|
11
|
+
}
|
12
|
+
|
13
|
+
overall_status = health_data[:services].values.all? { |status| status == 'ok' } ? 'ok' : 'degraded'
|
14
|
+
health_data[:status] = overall_status
|
15
|
+
|
16
|
+
status_code = overall_status == 'ok' ? :ok : :service_unavailable
|
17
|
+
|
18
|
+
render json: health_data, status: status_code
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def check_services
|
24
|
+
services = {}
|
25
|
+
|
26
|
+
# Database connectivity
|
27
|
+
services[:database] = check_database
|
28
|
+
|
29
|
+
# SQL Parser
|
30
|
+
services[:sql_parser] = check_sql_parser
|
31
|
+
|
32
|
+
# Analysis services
|
33
|
+
services[:analysis_services] = check_analysis_services
|
34
|
+
|
35
|
+
services
|
36
|
+
end
|
37
|
+
|
38
|
+
def check_database
|
39
|
+
ActiveRecord::Base.connection.execute('SELECT 1')
|
40
|
+
'ok'
|
41
|
+
rescue => e
|
42
|
+
Rails.logger.error "Database health check failed: #{e.message}"
|
43
|
+
'error'
|
44
|
+
end
|
45
|
+
|
46
|
+
def check_sql_parser
|
47
|
+
test_sql = "SELECT id FROM users WHERE email = 'test@example.com'"
|
48
|
+
parser = SqlParserService.new(test_sql)
|
49
|
+
|
50
|
+
if parser.valid? && parser.query_type == 'SELECT' && parser.primary_table == 'users'
|
51
|
+
'ok'
|
52
|
+
else
|
53
|
+
'error'
|
54
|
+
end
|
55
|
+
rescue => e
|
56
|
+
Rails.logger.error "SQL Parser health check failed: #{e.message}"
|
57
|
+
'error'
|
58
|
+
end
|
59
|
+
|
60
|
+
def check_analysis_services
|
61
|
+
# Test with a simple query analysis
|
62
|
+
test_queries = [
|
63
|
+
QueryAnalysis.new(
|
64
|
+
sql_query: "SELECT * FROM users WHERE id = 1",
|
65
|
+
duration_ms: 100,
|
66
|
+
table_name: "users",
|
67
|
+
query_type: "SELECT",
|
68
|
+
analyzed_at: Time.current
|
69
|
+
)
|
70
|
+
]
|
71
|
+
|
72
|
+
# Test N+1 detector
|
73
|
+
NPlusOneDetectorService.detect(test_queries)
|
74
|
+
|
75
|
+
# Test slow query analyzer
|
76
|
+
SlowQueryAnalyzerService.analyze(test_queries)
|
77
|
+
|
78
|
+
# Test missing index detector
|
79
|
+
MissingIndexDetectorService.detect(test_queries)
|
80
|
+
|
81
|
+
'ok'
|
82
|
+
rescue => e
|
83
|
+
Rails.logger.error "Analysis services health check failed: #{e.message}"
|
84
|
+
'error'
|
85
|
+
end
|
86
|
+
end
|
File without changes
|
@@ -0,0 +1,7 @@
|
|
1
|
+
class ApplicationJob < ActiveJob::Base
|
2
|
+
# Automatically retry jobs that encountered a deadlock
|
3
|
+
# retry_on ActiveRecord::Deadlocked
|
4
|
+
|
5
|
+
# Most jobs are safe to ignore if the underlying records are no longer available
|
6
|
+
# discard_on ActiveJob::DeserializationError
|
7
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class AppProfile < ApplicationRecord
|
2
|
+
has_many :query_analyses, dependent: :destroy
|
3
|
+
has_many :optimization_suggestions, through: :query_analyses
|
4
|
+
|
5
|
+
validates :name, presence: true, length: { minimum: 1, maximum: 100 }
|
6
|
+
validates :api_key_digest, presence: true, uniqueness: true
|
7
|
+
|
8
|
+
def self.authenticate_with_api_key(api_key)
|
9
|
+
find_by(api_key_digest: BCrypt::Password.create(api_key))
|
10
|
+
end
|
11
|
+
|
12
|
+
def generate_api_key!
|
13
|
+
api_key = SecureRandom.hex(32)
|
14
|
+
self.api_key_digest = BCrypt::Password.create(api_key)
|
15
|
+
save!
|
16
|
+
api_key
|
17
|
+
end
|
18
|
+
end
|
File without changes
|
@@ -0,0 +1,44 @@
|
|
1
|
+
class OptimizationSuggestion < ApplicationRecord
|
2
|
+
belongs_to :query_analysis
|
3
|
+
has_one :app_profile, through: :query_analysis
|
4
|
+
|
5
|
+
validates :suggestion_type, presence: true,
|
6
|
+
inclusion: { in: %w[n_plus_one slow_query missing_index query_optimization] }
|
7
|
+
validates :title, presence: true, length: { maximum: 200 }
|
8
|
+
validates :description, presence: true
|
9
|
+
validates :priority, presence: true,
|
10
|
+
inclusion: { in: 1..4 } # 1=low, 2=medium, 3=high, 4=critical
|
11
|
+
|
12
|
+
scope :by_type, ->(type) { where(suggestion_type: type) }
|
13
|
+
scope :by_priority, ->(priority) { where(priority: priority) }
|
14
|
+
scope :high_priority, -> { where(priority: [3, 4]) }
|
15
|
+
scope :not_implemented, -> { where(implemented: false) }
|
16
|
+
scope :implemented, -> { where(implemented: true) }
|
17
|
+
|
18
|
+
def priority_label
|
19
|
+
case priority
|
20
|
+
when 1 then 'Low'
|
21
|
+
when 2 then 'Medium'
|
22
|
+
when 3 then 'High'
|
23
|
+
when 4 then 'Critical'
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def mark_implemented!
|
28
|
+
update!(implemented: true)
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.create_for_analysis(analysis, suggestions_data)
|
32
|
+
suggestions_data.map do |suggestion_data|
|
33
|
+
create!(
|
34
|
+
query_analysis: analysis,
|
35
|
+
suggestion_type: suggestion_data[:type],
|
36
|
+
title: suggestion_data[:title],
|
37
|
+
description: suggestion_data[:description],
|
38
|
+
sql_suggestion: suggestion_data[:sql_suggestion],
|
39
|
+
priority: suggestion_data[:priority] || 2,
|
40
|
+
metadata: suggestion_data[:metadata] || {}
|
41
|
+
)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|