query_guard 0.4.1 → 0.5.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 +4 -4
- data/CHANGELOG.md +89 -1
- data/DESIGN.md +420 -0
- data/INDEX.md +309 -0
- data/README.md +579 -30
- data/exe/queryguard +23 -0
- data/lib/query_guard/action_controller_subscriber.rb +27 -0
- data/lib/query_guard/analysis/query_risk_classifier.rb +124 -0
- data/lib/query_guard/analysis/risk_detectors.rb +258 -0
- data/lib/query_guard/analysis/risk_level.rb +35 -0
- data/lib/query_guard/analyzers/base.rb +30 -0
- data/lib/query_guard/analyzers/query_count_analyzer.rb +31 -0
- data/lib/query_guard/analyzers/query_risk_analyzer.rb +146 -0
- data/lib/query_guard/analyzers/registry.rb +57 -0
- data/lib/query_guard/analyzers/select_star_analyzer.rb +42 -0
- data/lib/query_guard/analyzers/slow_query_analyzer.rb +39 -0
- data/lib/query_guard/budget.rb +148 -0
- data/lib/query_guard/cli/batch_report_formatter.rb +129 -0
- data/lib/query_guard/cli/command.rb +93 -0
- data/lib/query_guard/cli/commands/analyze.rb +52 -0
- data/lib/query_guard/cli/commands/check.rb +58 -0
- data/lib/query_guard/cli/formatter.rb +278 -0
- data/lib/query_guard/cli/json_reporter.rb +247 -0
- data/lib/query_guard/cli/paged_report_formatter.rb +137 -0
- data/lib/query_guard/cli/source_metadata_collector.rb +297 -0
- data/lib/query_guard/cli.rb +197 -0
- data/lib/query_guard/client.rb +4 -6
- data/lib/query_guard/config.rb +145 -6
- data/lib/query_guard/core/context.rb +80 -0
- data/lib/query_guard/core/finding.rb +162 -0
- data/lib/query_guard/core/finding_builders.rb +152 -0
- data/lib/query_guard/core/query.rb +40 -0
- data/lib/query_guard/explain/adapter_interface.rb +89 -0
- data/lib/query_guard/explain/explain_enricher.rb +367 -0
- data/lib/query_guard/explain/plan_signals.rb +385 -0
- data/lib/query_guard/explain/postgresql_adapter.rb +208 -0
- data/lib/query_guard/exporter.rb +124 -0
- data/lib/query_guard/fingerprint.rb +96 -0
- data/lib/query_guard/middleware.rb +101 -15
- data/lib/query_guard/migrations/database_adapter.rb +88 -0
- data/lib/query_guard/migrations/migration_analyzer.rb +100 -0
- data/lib/query_guard/migrations/migration_risk_detectors.rb +287 -0
- data/lib/query_guard/migrations/postgresql_adapter.rb +157 -0
- data/lib/query_guard/migrations/table_risk_analyzer.rb +154 -0
- data/lib/query_guard/migrations/table_size_resolver.rb +152 -0
- data/lib/query_guard/publish.rb +38 -0
- data/lib/query_guard/rspec.rb +119 -0
- data/lib/query_guard/security.rb +99 -0
- data/lib/query_guard/store.rb +38 -0
- data/lib/query_guard/subscriber.rb +46 -15
- data/lib/query_guard/suggest/index_suggester.rb +176 -0
- data/lib/query_guard/suggest/pattern_extractors.rb +137 -0
- data/lib/query_guard/trace.rb +106 -0
- data/lib/query_guard/uploader/http_uploader.rb +166 -0
- data/lib/query_guard/uploader/interface.rb +79 -0
- data/lib/query_guard/uploader/no_op_uploader.rb +46 -0
- data/lib/query_guard/uploader/registry.rb +37 -0
- data/lib/query_guard/uploader/upload_service.rb +80 -0
- data/lib/query_guard/version.rb +1 -1
- data/lib/query_guard.rb +54 -7
- metadata +78 -10
- data/.rspec +0 -3
- data/Rakefile +0 -21
- data/config/initializers/query_guard.rb +0 -9
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Migrations
|
|
5
|
+
# Detects risky patterns in Rails migration files.
|
|
6
|
+
# Uses pragmatic line-by-line analysis rather than complex regex.
|
|
7
|
+
# Returns structured finding data for each detected risk.
|
|
8
|
+
class MigrationRiskDetectors
|
|
9
|
+
# Detects migration risks from file content
|
|
10
|
+
def self.detect_risks(migration_content, migration_name)
|
|
11
|
+
risks = []
|
|
12
|
+
|
|
13
|
+
# Run all detectors
|
|
14
|
+
risks.concat(detect_unsafe_index_additions(migration_content, migration_name))
|
|
15
|
+
risks.concat(detect_table_locking_operations(migration_content, migration_name))
|
|
16
|
+
risks.concat(detect_non_null_additions(migration_content, migration_name))
|
|
17
|
+
risks.concat(detect_full_table_updates(migration_content, migration_name))
|
|
18
|
+
risks.concat(detect_unsafe_raw_sql(migration_content, migration_name))
|
|
19
|
+
|
|
20
|
+
risks
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Detect index additions without algorithm: :concurrently or add_index without safe options
|
|
26
|
+
def self.detect_unsafe_index_additions(content, migration_name)
|
|
27
|
+
risks = []
|
|
28
|
+
|
|
29
|
+
# Find all add_index occurrences
|
|
30
|
+
lines = content.lines
|
|
31
|
+
lines.each_with_index do |line, index|
|
|
32
|
+
next unless line.include?("add_index")
|
|
33
|
+
|
|
34
|
+
# Check if line includes algorithm: :concurrently
|
|
35
|
+
unless line.include?("algorithm: :concurrently") || line.include?("algorithm: :concurrent")
|
|
36
|
+
risks << {
|
|
37
|
+
type: :index_not_concurrent,
|
|
38
|
+
severity: :error,
|
|
39
|
+
line_number: index + 1,
|
|
40
|
+
migration_name: migration_name,
|
|
41
|
+
title: "Index Addition Without CONCURRENTLY",
|
|
42
|
+
description: "Adding an index locks the table. Use algorithm: :concurrently for PostgreSQL.",
|
|
43
|
+
message: "add_index without algorithm: :concurrently",
|
|
44
|
+
recommendation: "Add algorithm: :concurrently to allow concurrent queries during index creation",
|
|
45
|
+
metadata: {
|
|
46
|
+
operation: "add_index",
|
|
47
|
+
risk_level: :high,
|
|
48
|
+
locking: true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
risks
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Detect operations that lock the table: remove_column, change_column, rename_column
|
|
58
|
+
def self.detect_table_locking_operations(content, migration_name)
|
|
59
|
+
risks = []
|
|
60
|
+
lines = content.lines
|
|
61
|
+
|
|
62
|
+
lines.each_with_index do |line, index|
|
|
63
|
+
next if line.strip.start_with?("#")
|
|
64
|
+
next if line.strip.empty?
|
|
65
|
+
|
|
66
|
+
# Check with word boundary to match method calls with or without parentheses
|
|
67
|
+
case line
|
|
68
|
+
when /\bremove_column\b/
|
|
69
|
+
risks << {
|
|
70
|
+
type: :remove_column_lock,
|
|
71
|
+
severity: :error,
|
|
72
|
+
line_number: index + 1,
|
|
73
|
+
migration_name: migration_name,
|
|
74
|
+
title: "Remove Column Locks Table",
|
|
75
|
+
description: "Removing a column rewrites the entire table, causing extended lock.",
|
|
76
|
+
message: "remove_column operation locks table",
|
|
77
|
+
recommendation: "Use safe_remove_column from a migration-safe gem, or manually soft-delete the column first",
|
|
78
|
+
metadata: { operation: "remove_column", risk_level: :high, locking: true }
|
|
79
|
+
}
|
|
80
|
+
when /\bchange_column\b/
|
|
81
|
+
risks << {
|
|
82
|
+
type: :change_column_lock,
|
|
83
|
+
severity: :error,
|
|
84
|
+
line_number: index + 1,
|
|
85
|
+
migration_name: migration_name,
|
|
86
|
+
title: "Change Column Locks Table",
|
|
87
|
+
description: "Changing a column type rewrites the entire table, causing extended lock.",
|
|
88
|
+
message: "change_column operation locks table",
|
|
89
|
+
recommendation: "Create new column, migrate data with backfill, then drop old column in separate migration",
|
|
90
|
+
metadata: { operation: "change_column", risk_level: :high, locking: true }
|
|
91
|
+
}
|
|
92
|
+
when /\brename_column\b/
|
|
93
|
+
risks << {
|
|
94
|
+
type: :rename_column_lock,
|
|
95
|
+
severity: :warn,
|
|
96
|
+
line_number: index + 1,
|
|
97
|
+
migration_name: migration_name,
|
|
98
|
+
title: "Rename Column Brief Lock",
|
|
99
|
+
description: "Renaming a column briefly locks the table during metadata update.",
|
|
100
|
+
message: "rename_column operation locks table briefly",
|
|
101
|
+
recommendation: "Use with caution in large tables; consider aliasing instead",
|
|
102
|
+
metadata: { operation: "rename_column", risk_level: :medium, locking: true }
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
risks
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Detect adding NOT NULL columns without safe rollout pattern
|
|
111
|
+
def self.detect_non_null_additions(content, migration_name)
|
|
112
|
+
risks = []
|
|
113
|
+
lines = content.lines
|
|
114
|
+
|
|
115
|
+
lines.each_with_index do |line, index|
|
|
116
|
+
next unless line.include?("add_column")
|
|
117
|
+
next if line.strip.start_with?("#")
|
|
118
|
+
|
|
119
|
+
# Check for null: false without default
|
|
120
|
+
if line.include?("null: false") && !line.include?("default:")
|
|
121
|
+
risks << {
|
|
122
|
+
type: :non_null_no_default,
|
|
123
|
+
severity: :error,
|
|
124
|
+
line_number: index + 1,
|
|
125
|
+
migration_name: migration_name,
|
|
126
|
+
title: "Non-NULL Column Without Default",
|
|
127
|
+
description: "Adding a NOT NULL column without a default value will fail on populated tables.",
|
|
128
|
+
message: "add_column null: false (no default)",
|
|
129
|
+
recommendation: "Provide a default value, or add the column as nullable and backfill in separate step.",
|
|
130
|
+
metadata: { operation: "add_column", risk_level: :high }
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
risks
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Detect full-table updates inside migrations
|
|
139
|
+
def self.detect_full_table_updates(content, migration_name)
|
|
140
|
+
risks = []
|
|
141
|
+
lines = content.lines
|
|
142
|
+
|
|
143
|
+
lines.each_with_index do |line, index|
|
|
144
|
+
next if line.strip.start_with?("#")
|
|
145
|
+
|
|
146
|
+
# Detect Model.update_all or delete_all calls
|
|
147
|
+
if line.include?(".update_all") || line.include?(".delete_all")
|
|
148
|
+
risks << {
|
|
149
|
+
type: :full_table_update,
|
|
150
|
+
severity: :error,
|
|
151
|
+
line_number: index + 1,
|
|
152
|
+
migration_name: migration_name,
|
|
153
|
+
title: "Full-Table Update in Migration",
|
|
154
|
+
description: "Updating all rows in a migration locks the table and is slow on large tables.",
|
|
155
|
+
message: "update_all or delete_all in migration",
|
|
156
|
+
recommendation: "Use a separate rake task or background job for large updates. Process rows in batches with pauses.",
|
|
157
|
+
metadata: { operation: "updateall", risk_level: :high, locking: true }
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
risks
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Detect unsafe raw SQL statements
|
|
166
|
+
def self.detect_unsafe_raw_sql(content, migration_name)
|
|
167
|
+
risks = []
|
|
168
|
+
lines = content.lines
|
|
169
|
+
|
|
170
|
+
lines.each_with_index do |line, index|
|
|
171
|
+
next if line.strip.start_with?("#")
|
|
172
|
+
next if line.strip.empty?
|
|
173
|
+
|
|
174
|
+
# Check for dangerous SQL keywords in quoted strings in execute() calls
|
|
175
|
+
if line.include?("execute") && (line.include?('"') || line.include?("'"))
|
|
176
|
+
# Extract the SQL string from the line
|
|
177
|
+
sql_match = line.match(/"([^"]+)"|'([^']+)'/)
|
|
178
|
+
if sql_match
|
|
179
|
+
sql = sql_match[1] || sql_match[2]
|
|
180
|
+
upcase_sql = sql.upcase
|
|
181
|
+
|
|
182
|
+
if upcase_sql.include?("TRUNCATE")
|
|
183
|
+
risks << {
|
|
184
|
+
type: :dangerous_raw_sql,
|
|
185
|
+
severity: :error,
|
|
186
|
+
line_number: index + 1,
|
|
187
|
+
migration_name: migration_name,
|
|
188
|
+
title: "TRUNCATE in Migration",
|
|
189
|
+
description: "TRUNCATE operations are dangerous and can cause data loss.",
|
|
190
|
+
message: "TRUNCATE detected in raw SQL",
|
|
191
|
+
recommendation: "Avoid TRUNCATE; use safer alternatives or manual database cleanup",
|
|
192
|
+
metadata: { operation: "raw_sql", risk_level: :critical }
|
|
193
|
+
}
|
|
194
|
+
elsif upcase_sql.include?("DROP")
|
|
195
|
+
risks << {
|
|
196
|
+
type: :dangerous_raw_sql,
|
|
197
|
+
severity: :error,
|
|
198
|
+
line_number: index + 1,
|
|
199
|
+
migration_name: migration_name,
|
|
200
|
+
title: "DROP in Migration",
|
|
201
|
+
description: "DROP operations are dangerous and can cause data loss.",
|
|
202
|
+
message: "DROP detected in raw SQL",
|
|
203
|
+
recommendation: "Avoid DROP in migrations; use Rails schema helpers instead",
|
|
204
|
+
metadata: { operation: "raw_sql", risk_level: :critical }
|
|
205
|
+
}
|
|
206
|
+
elsif upcase_sql.include?("LOCK TABLE")
|
|
207
|
+
risks << {
|
|
208
|
+
type: :explicit_table_lock,
|
|
209
|
+
severity: :warn,
|
|
210
|
+
line_number: index + 1,
|
|
211
|
+
migration_name: migration_name,
|
|
212
|
+
title: "Explicit Table Lock",
|
|
213
|
+
description: "Explicit LOCK TABLE statements block all table access.",
|
|
214
|
+
message: "LOCK TABLE detected in raw SQL",
|
|
215
|
+
recommendation: "Avoid explicit locks; let migrations handle locking implicitly",
|
|
216
|
+
metadata: { operation: "raw_sql", risk_level: :medium }
|
|
217
|
+
}
|
|
218
|
+
elsif (upcase_sql.include?("UPDATE") || upcase_sql.include?("DELETE")) && !upcase_sql.include?("WHERE")
|
|
219
|
+
risks << {
|
|
220
|
+
type: :unsafe_raw_sql_full_table,
|
|
221
|
+
severity: :error,
|
|
222
|
+
line_number: index + 1,
|
|
223
|
+
migration_name: migration_name,
|
|
224
|
+
title: "Full Table SQL Without WHERE",
|
|
225
|
+
description: "UPDATE/DELETE without WHERE clause affects entire table.",
|
|
226
|
+
message: "Full table operation without WHERE clause",
|
|
227
|
+
recommendation: "Always include WHERE clause; batch operations for safety",
|
|
228
|
+
metadata: { operation: "raw_sql", risk_level: :critical }
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Also check for dangerous keywords on their own line
|
|
235
|
+
upcase_line = line.upcase
|
|
236
|
+
|
|
237
|
+
if upcase_line.include?("TRUNCATE") && !line.strip.start_with?("#")
|
|
238
|
+
# Skip if already reported from execute() parsing
|
|
239
|
+
next if line.include?("execute")
|
|
240
|
+
|
|
241
|
+
risks << {
|
|
242
|
+
type: :dangerous_raw_sql,
|
|
243
|
+
severity: :error,
|
|
244
|
+
line_number: index + 1,
|
|
245
|
+
migration_name: migration_name,
|
|
246
|
+
title: "TRUNCATE in Migration",
|
|
247
|
+
description: "TRUNCATE operations are dangerous and can cause data loss.",
|
|
248
|
+
message: "TRUNCATE detected",
|
|
249
|
+
recommendation: "Avoid TRUNCATE; use safer alternatives or manual database cleanup",
|
|
250
|
+
metadata: { operation: "raw_sql", risk_level: :critical }
|
|
251
|
+
}
|
|
252
|
+
elsif upcase_line.include?("DROP TABLE") || upcase_line.include?("DROP COLUMN")
|
|
253
|
+
next if line.include?("execute")
|
|
254
|
+
|
|
255
|
+
risks << {
|
|
256
|
+
type: :dangerous_raw_sql,
|
|
257
|
+
severity: :error,
|
|
258
|
+
line_number: index + 1,
|
|
259
|
+
migration_name: migration_name,
|
|
260
|
+
title: "DROP in Migration",
|
|
261
|
+
description: "DROP operations are dangerous and can cause data loss.",
|
|
262
|
+
message: "DROP detected",
|
|
263
|
+
recommendation: "Avoid DROP in migrations; use Rails schema helpers instead",
|
|
264
|
+
metadata: { operation: "raw_sql", risk_level: :critical }
|
|
265
|
+
}
|
|
266
|
+
elsif upcase_line.include?("LOCK TABLE")
|
|
267
|
+
next if line.include?("execute")
|
|
268
|
+
|
|
269
|
+
risks << {
|
|
270
|
+
type: :explicit_table_lock,
|
|
271
|
+
severity: :warn,
|
|
272
|
+
line_number: index + 1,
|
|
273
|
+
migration_name: migration_name,
|
|
274
|
+
title: "Explicit Table Lock",
|
|
275
|
+
description: "Explicit LOCK TABLE statements block all table access.",
|
|
276
|
+
message: "LOCK TABLE detected",
|
|
277
|
+
recommendation: "Avoid explicit locks; let migrations handle locking implicitly",
|
|
278
|
+
metadata: { operation: "raw_sql", risk_level: :medium }
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
risks
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Migrations
|
|
5
|
+
# PostgreSQL-specific metadata adapter
|
|
6
|
+
#
|
|
7
|
+
# Queries PostgreSQL system catalog to estimate table sizes
|
|
8
|
+
# and determine lock risk based on row count and write activity.
|
|
9
|
+
#
|
|
10
|
+
# Row count thresholds (from PostgreSQL best practices):
|
|
11
|
+
# - < 1M rows: Low lock risk, fast schema changes
|
|
12
|
+
# - 1M - 10M rows: Medium lock risk, monitor lock times
|
|
13
|
+
# - 10M - 100M rows: High lock risk, use concurrent operations
|
|
14
|
+
# - > 100M rows: Critical risk, requires special handling
|
|
15
|
+
class PostgreSQLAdapter < DatabaseAdapter
|
|
16
|
+
# Lock duration thresholds (milliseconds)
|
|
17
|
+
# Based on typical PostgreSQL behavior
|
|
18
|
+
LOCK_THRESHOLDS = {
|
|
19
|
+
low: 1_000_000, # < 1M rows
|
|
20
|
+
medium: 10_000_000, # 1M - 10M rows
|
|
21
|
+
high: 100_000_000, # 10M - 100M rows
|
|
22
|
+
critical: Float::INFINITY # > 100M rows
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Initialize PostgreSQL adapter
|
|
26
|
+
#
|
|
27
|
+
# @param connection [Object] Active database connection (responds to exec_query)
|
|
28
|
+
# @param schema [String] Schema to query (default: public)
|
|
29
|
+
# @option cache [Boolean] Whether to cache row count queries (default: true)
|
|
30
|
+
def initialize(connection, schema: "public", cache: true)
|
|
31
|
+
@connection = connection
|
|
32
|
+
@schema = schema
|
|
33
|
+
@cache_enabled = cache
|
|
34
|
+
@cache = {} if cache
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Estimate row count using PostgreSQL statistics
|
|
38
|
+
#
|
|
39
|
+
# Uses pg_class.reltuples for estimated row count.
|
|
40
|
+
# Falls back to COUNT(*) if stats unavailable.
|
|
41
|
+
#
|
|
42
|
+
# @param table_name [String, Symbol] Table name
|
|
43
|
+
# @return [Integer, nil] Estimated rows, or nil if unavailable
|
|
44
|
+
def estimate_table_rows(table_name)
|
|
45
|
+
return nil unless connected?
|
|
46
|
+
return @cache[table_name.to_s] if @cache_enabled && @cache.key?(table_name.to_s)
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
# Use PostgreSQL statistics (faster, less accurate)
|
|
50
|
+
result = @connection.exec_query(
|
|
51
|
+
"SELECT reltuples::bigint as estimated_rows FROM pg_class
|
|
52
|
+
WHERE relname = $1 AND relnamespace =
|
|
53
|
+
(SELECT oid FROM pg_namespace WHERE nspname = $2)
|
|
54
|
+
LIMIT 1",
|
|
55
|
+
"GetTableRows",
|
|
56
|
+
[[table_name.to_s, :string], [@schema, :string]]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
rows = result.rows.dig(0, 0)&.to_i
|
|
60
|
+
@cache[table_name.to_s] = rows if @cache_enabled
|
|
61
|
+
rows
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
# Fail gracefully - log if possible, return nil
|
|
64
|
+
warn "Failed to get row count for #{table_name}: #{e.message}"
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Estimate lock risk based on table size
|
|
70
|
+
#
|
|
71
|
+
# Larger tables take longer to lock and rewrite,
|
|
72
|
+
# increasing risk of production impact.
|
|
73
|
+
#
|
|
74
|
+
# @param table_name [String, Symbol] Table name
|
|
75
|
+
# @return [Symbol, nil] Risk level: :low, :medium, :high, :critical
|
|
76
|
+
def estimate_lock_risk(table_name)
|
|
77
|
+
rows = estimate_table_rows(table_name)
|
|
78
|
+
return nil if rows.nil?
|
|
79
|
+
|
|
80
|
+
case rows
|
|
81
|
+
when 0...LOCK_THRESHOLDS[:low]
|
|
82
|
+
:low
|
|
83
|
+
when LOCK_THRESHOLDS[:low]...LOCK_THRESHOLDS[:medium]
|
|
84
|
+
:medium
|
|
85
|
+
when LOCK_THRESHOLDS[:medium]...LOCK_THRESHOLDS[:high]
|
|
86
|
+
:high
|
|
87
|
+
else
|
|
88
|
+
:critical
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if table exists
|
|
93
|
+
#
|
|
94
|
+
# @param table_name [String, Symbol] Table name
|
|
95
|
+
# @return [Boolean] True if table exists in schema
|
|
96
|
+
def table_exists?(table_name)
|
|
97
|
+
return false unless connected?
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
result = @connection.exec_query(
|
|
101
|
+
"SELECT 1 FROM information_schema.tables
|
|
102
|
+
WHERE table_schema = $1 AND table_name = $2 LIMIT 1",
|
|
103
|
+
"CheckTableExists",
|
|
104
|
+
[[@schema, :string], [table_name.to_s, :string]]
|
|
105
|
+
)
|
|
106
|
+
result.rows.any?
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
warn "Failed to check if table exists #{table_name}: #{e.message}"
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# List all tables in schema
|
|
114
|
+
#
|
|
115
|
+
# @return [Array<String>] Table names
|
|
116
|
+
def list_tables
|
|
117
|
+
return [] unless connected?
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
result = @connection.exec_query(
|
|
121
|
+
"SELECT table_name FROM information_schema.tables
|
|
122
|
+
WHERE table_schema = $1
|
|
123
|
+
ORDER BY table_name",
|
|
124
|
+
"ListTables",
|
|
125
|
+
[[@schema, :string]]
|
|
126
|
+
)
|
|
127
|
+
result.rows.flatten
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
warn "Failed to list tables: #{e.message}"
|
|
130
|
+
[]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check if connection is healthy
|
|
135
|
+
#
|
|
136
|
+
# @return [Boolean] True if we can query the database
|
|
137
|
+
def connected?
|
|
138
|
+
return false if @connection.nil?
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
@connection.exec_query("SELECT 1 LIMIT 1")
|
|
142
|
+
true
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
warn "Database connection check failed: #{e.message}"
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Clear the row count cache
|
|
150
|
+
#
|
|
151
|
+
# Call this if you've made schema changes and want fresh stats.
|
|
152
|
+
def clear_cache
|
|
153
|
+
@cache.clear if @cache_enabled && @cache
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Migrations
|
|
5
|
+
# Enhances risk analysis with table-size-aware context
|
|
6
|
+
#
|
|
7
|
+
# When database metadata is available, escalates risk severity
|
|
8
|
+
# for operations on large tables.
|
|
9
|
+
#
|
|
10
|
+
# For example:
|
|
11
|
+
# - add_index on 10M row table = CRITICAL (will lock for minutes)
|
|
12
|
+
# - add_index on 100K row table = ERROR (will lock briefly)
|
|
13
|
+
#
|
|
14
|
+
# Works gracefully if database unavailable by falling back to
|
|
15
|
+
# static analysis.
|
|
16
|
+
class TableRiskAnalyzer
|
|
17
|
+
# Risk escalation factors based on table size
|
|
18
|
+
ESCALATION_FACTORS = {
|
|
19
|
+
low: 0, # < 1M rows: no escalation
|
|
20
|
+
medium: 1, # 1M - 10M rows: escalate 1 level (error -> critical)
|
|
21
|
+
high: 2, # 10M - 100M rows: escalate 2 levels (warn -> critical)
|
|
22
|
+
critical: 3 # > 100M rows: already critical
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Initialize analyzer
|
|
26
|
+
#
|
|
27
|
+
# @param adapter [DatabaseAdapter] Optional database adapter
|
|
28
|
+
def initialize(adapter = nil)
|
|
29
|
+
@adapter = adapter || NullDatabaseAdapter.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Enhance risks with table size information
|
|
33
|
+
#
|
|
34
|
+
# @param migration_content [String] Migration file content
|
|
35
|
+
# @param risks [Array<Hash>] Risks from MigrationRiskDetectors
|
|
36
|
+
# @return [Array<Hash>] Risks with table metadata added
|
|
37
|
+
def enhance_risks(migration_content, risks)
|
|
38
|
+
return risks unless @adapter.connected?
|
|
39
|
+
|
|
40
|
+
# Extract affected tables from migration
|
|
41
|
+
affected_tables = TableSizeResolver.affected_tables(migration_content)
|
|
42
|
+
return risks if affected_tables.empty?
|
|
43
|
+
|
|
44
|
+
# Build table metadata cache
|
|
45
|
+
table_metadata = {}
|
|
46
|
+
affected_tables.each do |table_name|
|
|
47
|
+
row_count = @adapter.estimate_table_rows(table_name)
|
|
48
|
+
lock_risk = @adapter.estimate_lock_risk(table_name)
|
|
49
|
+
|
|
50
|
+
table_metadata[table_name] = {
|
|
51
|
+
estimated_rows: row_count,
|
|
52
|
+
lock_risk: lock_risk
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Enhance each risk with table metadata
|
|
57
|
+
risks.map do |risk|
|
|
58
|
+
enhance_single_risk(risk, migration_content, table_metadata)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
protected
|
|
63
|
+
|
|
64
|
+
def enhance_single_risk(risk, migration_content, table_metadata)
|
|
65
|
+
# Copy risk hash so we don't mutate original
|
|
66
|
+
enhanced_risk = risk.dup
|
|
67
|
+
metadata = (risk[:metadata] || {}).dup
|
|
68
|
+
|
|
69
|
+
# Try to determine which table this risk refers to
|
|
70
|
+
affected_tables = TableSizeResolver.affected_tables(migration_content)
|
|
71
|
+
risk_type = risk[:type]
|
|
72
|
+
|
|
73
|
+
# Different risk types affect different tables
|
|
74
|
+
table_name = determine_risk_table(risk_type, migration_content, affected_tables)
|
|
75
|
+
|
|
76
|
+
if table_name && table_metadata[table_name]
|
|
77
|
+
table_info = table_metadata[table_name]
|
|
78
|
+
|
|
79
|
+
# Add table metadata to risk
|
|
80
|
+
metadata[:table_name] = table_name
|
|
81
|
+
metadata[:estimated_table_rows] = table_info[:estimated_rows]
|
|
82
|
+
metadata[:table_lock_risk] = table_info[:lock_risk]
|
|
83
|
+
|
|
84
|
+
enhanced_risk[:metadata] = metadata
|
|
85
|
+
|
|
86
|
+
# Escalate severity if table is large
|
|
87
|
+
if table_info[:lock_risk]
|
|
88
|
+
enhanced_risk = escalate_risk_for_table(enhanced_risk, table_info[:lock_risk])
|
|
89
|
+
# Preserve our table metadata additions
|
|
90
|
+
enhanced_risk[:metadata].merge!(metadata)
|
|
91
|
+
enhanced_risk[:metadata][:severity_escalated] = true
|
|
92
|
+
enhanced_risk[:metadata][:escalation_reason] = "Large table (#{format_row_count(table_info[:estimated_rows])} rows)"
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
enhanced_risk[:metadata] = metadata
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
enhanced_risk
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def determine_risk_table(risk_type, migration_content, affected_tables)
|
|
102
|
+
# Heuristic: find the most likely table for this risk
|
|
103
|
+
# Could be enhanced with more sophisticated analysis
|
|
104
|
+
|
|
105
|
+
affected_tables.first # Simple: return first affected table for now
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def escalate_risk_for_table(risk, lock_risk)
|
|
109
|
+
return risk if lock_risk.nil?
|
|
110
|
+
|
|
111
|
+
# Only escalate errors and warnings, not info
|
|
112
|
+
return risk if risk[:severity] == :info
|
|
113
|
+
|
|
114
|
+
escalation = ESCALATION_FACTORS[lock_risk] || 0
|
|
115
|
+
return risk if escalation.zero?
|
|
116
|
+
|
|
117
|
+
# Escalate severity based on table size
|
|
118
|
+
escalated_risk = risk.dup
|
|
119
|
+
original_severity = risk[:severity]
|
|
120
|
+
|
|
121
|
+
escalated_risk[:severity] = case original_severity
|
|
122
|
+
when :warn
|
|
123
|
+
# Escalate WARN to ERROR for medium+ tables (escalation >= 1)
|
|
124
|
+
escalation >= 1 ? :error : :warn
|
|
125
|
+
when :error
|
|
126
|
+
# Escalate ERROR to CRITICAL for medium+ tables (escalation >= 1)
|
|
127
|
+
escalation >= 1 ? :critical : :error
|
|
128
|
+
else
|
|
129
|
+
original_severity
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
escalated_risk[:metadata] = (risk[:metadata] || {}).dup
|
|
133
|
+
escalated_risk[:metadata][:original_severity] = original_severity
|
|
134
|
+
|
|
135
|
+
escalated_risk
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def format_row_count(count)
|
|
139
|
+
return "unknown" if count.nil?
|
|
140
|
+
|
|
141
|
+
case count
|
|
142
|
+
when 0..999
|
|
143
|
+
"#{count}"
|
|
144
|
+
when 1000..999_999
|
|
145
|
+
"#{(count / 1000.0).round(1)}K"
|
|
146
|
+
when 1_000_000..999_999_999
|
|
147
|
+
"#{(count / 1_000_000.0).round(1)}M"
|
|
148
|
+
else
|
|
149
|
+
"#{(count / 1_000_000_000.0).round(1)}B"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|