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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/DESIGN.md +420 -0
  4. data/INDEX.md +309 -0
  5. data/README.md +579 -30
  6. data/exe/queryguard +23 -0
  7. data/lib/query_guard/action_controller_subscriber.rb +27 -0
  8. data/lib/query_guard/analysis/query_risk_classifier.rb +124 -0
  9. data/lib/query_guard/analysis/risk_detectors.rb +258 -0
  10. data/lib/query_guard/analysis/risk_level.rb +35 -0
  11. data/lib/query_guard/analyzers/base.rb +30 -0
  12. data/lib/query_guard/analyzers/query_count_analyzer.rb +31 -0
  13. data/lib/query_guard/analyzers/query_risk_analyzer.rb +146 -0
  14. data/lib/query_guard/analyzers/registry.rb +57 -0
  15. data/lib/query_guard/analyzers/select_star_analyzer.rb +42 -0
  16. data/lib/query_guard/analyzers/slow_query_analyzer.rb +39 -0
  17. data/lib/query_guard/budget.rb +148 -0
  18. data/lib/query_guard/cli/batch_report_formatter.rb +129 -0
  19. data/lib/query_guard/cli/command.rb +93 -0
  20. data/lib/query_guard/cli/commands/analyze.rb +52 -0
  21. data/lib/query_guard/cli/commands/check.rb +58 -0
  22. data/lib/query_guard/cli/formatter.rb +278 -0
  23. data/lib/query_guard/cli/json_reporter.rb +247 -0
  24. data/lib/query_guard/cli/paged_report_formatter.rb +137 -0
  25. data/lib/query_guard/cli/source_metadata_collector.rb +297 -0
  26. data/lib/query_guard/cli.rb +197 -0
  27. data/lib/query_guard/client.rb +4 -6
  28. data/lib/query_guard/config.rb +145 -6
  29. data/lib/query_guard/core/context.rb +80 -0
  30. data/lib/query_guard/core/finding.rb +162 -0
  31. data/lib/query_guard/core/finding_builders.rb +152 -0
  32. data/lib/query_guard/core/query.rb +40 -0
  33. data/lib/query_guard/explain/adapter_interface.rb +89 -0
  34. data/lib/query_guard/explain/explain_enricher.rb +367 -0
  35. data/lib/query_guard/explain/plan_signals.rb +385 -0
  36. data/lib/query_guard/explain/postgresql_adapter.rb +208 -0
  37. data/lib/query_guard/exporter.rb +124 -0
  38. data/lib/query_guard/fingerprint.rb +96 -0
  39. data/lib/query_guard/middleware.rb +101 -15
  40. data/lib/query_guard/migrations/database_adapter.rb +88 -0
  41. data/lib/query_guard/migrations/migration_analyzer.rb +100 -0
  42. data/lib/query_guard/migrations/migration_risk_detectors.rb +287 -0
  43. data/lib/query_guard/migrations/postgresql_adapter.rb +157 -0
  44. data/lib/query_guard/migrations/table_risk_analyzer.rb +154 -0
  45. data/lib/query_guard/migrations/table_size_resolver.rb +152 -0
  46. data/lib/query_guard/publish.rb +38 -0
  47. data/lib/query_guard/rspec.rb +119 -0
  48. data/lib/query_guard/security.rb +99 -0
  49. data/lib/query_guard/store.rb +38 -0
  50. data/lib/query_guard/subscriber.rb +46 -15
  51. data/lib/query_guard/suggest/index_suggester.rb +176 -0
  52. data/lib/query_guard/suggest/pattern_extractors.rb +137 -0
  53. data/lib/query_guard/trace.rb +106 -0
  54. data/lib/query_guard/uploader/http_uploader.rb +166 -0
  55. data/lib/query_guard/uploader/interface.rb +79 -0
  56. data/lib/query_guard/uploader/no_op_uploader.rb +46 -0
  57. data/lib/query_guard/uploader/registry.rb +37 -0
  58. data/lib/query_guard/uploader/upload_service.rb +80 -0
  59. data/lib/query_guard/version.rb +1 -1
  60. data/lib/query_guard.rb +54 -7
  61. metadata +78 -10
  62. data/.rspec +0 -3
  63. data/Rakefile +0 -21
  64. 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