1use crate::analysis::mutations::Mutation;
3use crate::analysis::resolver::Resolver;
4use crate::analysis::state::AnalysisState;
5use crate::ast::visitor::AstVisitor;
6use crate::engine::config::Config;
7use crate::report::violations::{ReportFinding, SourceLocation, Violation};
8use crate::rules::Rule;
9use crate::rules::conflict::ConflictRule;
10use crate::rules::constraints::BlockingConstraintRule;
11use crate::rules::destructive::{
12 CascadingDropRule, CreateTableAsSelectRule, DropDatabaseRule, DropSchemaCascadeRule,
13 GeneralCascadeRule, ReversibilityRule, SizeAwareAddColumnRule, TypeChangeRewriteRule,
14};
15use crate::rules::drift::DriftDetectionRule;
16use crate::rules::expressions::VolatileDefaultRule;
17use crate::rules::functions::{BrokenComputeRule, FunctionVolatilityRule};
18use crate::rules::idempotency::IdempotencyRule;
19use crate::rules::indexes::ConcurrentIndexRule;
20use crate::rules::opaque::OpaqueDynamicSqlRule;
21use crate::rules::partitions::{PartitionLockRule, PartitionStrategyMismatchRule};
22use crate::rules::policies::RestrictivePolicyRule;
23use crate::rules::security::OverbroadGrantRule;
24use crate::rules::transactions::{
25 AlterTypeAddValueRule, ConcurrentInsideTransactionRule, VacuumFullRule,
26};
27use crate::rules::triggers::DisableTriggerRule;
28use crate::rules::views::MaterializedViewRefreshRule;
29use squawk_syntax::{
30 SyntaxKind,
31 ast::{AstNode, SourceFile},
32};
33use std::collections::HashSet;
34
35pub struct SafeMigrateEngine {
36 config: Config,
37 rules: Vec<Box<dyn Rule>>,
38}
39
40impl SafeMigrateEngine {
41 pub fn new(config: Config) -> Self {
42 Self {
43 config,
44 rules: vec![
45 Box::new(ReversibilityRule),
46 Box::new(DropDatabaseRule),
47 Box::new(DropSchemaCascadeRule),
48 Box::new(GeneralCascadeRule),
49 Box::new(CascadingDropRule),
50 Box::new(CreateTableAsSelectRule),
51 Box::new(SizeAwareAddColumnRule),
52 Box::new(TypeChangeRewriteRule),
53 Box::new(BlockingConstraintRule),
54 Box::new(ConcurrentIndexRule),
55 Box::new(MaterializedViewRefreshRule),
56 Box::new(PartitionLockRule),
57 Box::new(PartitionStrategyMismatchRule),
58 Box::new(RestrictivePolicyRule),
59 Box::new(DisableTriggerRule),
60 Box::new(BrokenComputeRule),
61 Box::new(FunctionVolatilityRule),
62 Box::new(IdempotencyRule),
63 Box::new(ConcurrentInsideTransactionRule),
64 Box::new(AlterTypeAddValueRule),
65 Box::new(VacuumFullRule),
66 Box::new(OpaqueDynamicSqlRule),
67 Box::new(VolatileDefaultRule),
68 Box::new(OverbroadGrantRule),
69 Box::new(DriftDetectionRule),
70 Box::new(ConflictRule),
71 ],
72 }
73 }
74
75 pub fn primary_rule_ids(&self) -> Vec<&'static str> {
78 self.rules.iter().map(|rule| rule.id()).collect()
79 }
80
81 pub fn analyze_chain(
82 &self,
83 files: &[(String, String)],
84 state: &mut AnalysisState,
85 ) -> Result<Vec<Violation>, Vec<String>> {
86 let mut all_violations = Vec::new();
87 for (filename, sql) in files {
88 let violations = self.analyze_single_file(filename, sql, state)?;
89 all_violations.extend(violations);
90 }
91 all_violations.sort_by(|a, b| {
93 a.tier
94 .cmp(&b.tier)
95 .then_with(|| match (&a.source_range, &b.source_range) {
96 (Some(ar), Some(br)) => ar
97 .start()
98 .cmp(&br.start())
99 .then_with(|| ar.end().cmp(&br.end())),
100 (Some(_), None) => std::cmp::Ordering::Less,
101 (None, Some(_)) => std::cmp::Ordering::Greater,
102 (None, None) => std::cmp::Ordering::Equal,
103 })
104 .then_with(|| a.object_name.cmp(&b.object_name))
105 .then_with(|| a.rule_id.cmp(b.rule_id))
106 });
107 Ok(all_violations)
108 }
109
110 pub fn analyze(
111 &self,
112 sql: &str,
113 state: &mut AnalysisState,
114 ) -> Result<Vec<Violation>, Vec<String>> {
115 self.analyze_chain(&[("<inline>".to_string(), sql.to_string())], state)
116 }
117
118 pub fn analyze_chain_with_locations(
122 &self,
123 files: &[(String, String)],
124 state: &mut AnalysisState,
125 ) -> Result<Vec<ReportFinding>, Vec<String>> {
126 let mut findings = Vec::new();
127
128 for (file_index, (filename, sql)) in files.iter().enumerate() {
129 let normalized_sql = Self::normalize_execute(sql);
130 let violations = self.analyze_normalized_file(filename, &normalized_sql, state)?;
131 findings.extend(
132 violations
133 .into_iter()
134 .map(|violation| ReportFinding {
135 location: Self::source_location(
136 filename,
137 &normalized_sql,
138 violation.source_range,
139 ),
140 violation,
141 })
142 .map(|finding| (file_index, finding)),
143 );
144 }
145
146 findings.sort_by(|(a_index, a), (b_index, b)| {
147 a.violation
148 .tier
149 .cmp(&b.violation.tier)
150 .then_with(|| a_index.cmp(b_index))
151 .then_with(|| match (&a.location, &b.location) {
152 (Some(a_location), Some(b_location)) => a_location
153 .line
154 .cmp(&b_location.line)
155 .then_with(|| a_location.column.cmp(&b_location.column)),
156 (Some(_), None) => std::cmp::Ordering::Less,
157 (None, Some(_)) => std::cmp::Ordering::Greater,
158 (None, None) => std::cmp::Ordering::Equal,
159 })
160 .then_with(|| a.violation.object_name.cmp(&b.violation.object_name))
161 .then_with(|| a.violation.rule_id.cmp(b.violation.rule_id))
162 });
163
164 Ok(findings.into_iter().map(|(_, finding)| finding).collect())
165 }
166
167 pub fn analyze_with_locations(
168 &self,
169 filename: String,
170 sql: String,
171 state: &mut AnalysisState,
172 ) -> Result<Vec<ReportFinding>, Vec<String>> {
173 self.analyze_chain_with_locations(&[(filename, sql)], state)
174 }
175
176 fn analyze_single_file(
177 &self,
178 filename: &str,
179 sql: &str,
180 state: &mut AnalysisState,
181 ) -> Result<Vec<Violation>, Vec<String>> {
182 let sql = Self::normalize_execute(sql);
183 self.analyze_normalized_file(filename, &sql, state)
184 }
185
186 fn analyze_normalized_file(
187 &self,
188 _filename: &str,
189 sql: &str,
190 state: &mut AnalysisState,
191 ) -> Result<Vec<Violation>, Vec<String>> {
192 let parsed = SourceFile::parse(sql);
193 let errors: Vec<String> = parsed.errors().iter().map(|e| e.to_string()).collect();
194 if !errors.is_empty() {
195 return Err(errors);
196 }
197
198 let mut all_violations = Vec::new();
199 let mut warned_keys = HashSet::new();
200
201 let mut file_ignores = HashSet::new();
202 for token in parsed
203 .tree()
204 .syntax()
205 .descendants_with_tokens()
206 .filter_map(|it| it.into_token())
207 .filter(|token| token.kind() == SyntaxKind::COMMENT)
208 {
209 let mut dummy = HashSet::new();
210 Self::parse_directives(token.text(), &mut file_ignores, &mut dummy);
211 }
212
213 for stmt in parsed.tree().stmts() {
214 let mut stmt_ignores = HashSet::new();
215
216 let mut prev = stmt.syntax().prev_sibling_or_token();
217 while let Some(element) = prev {
218 if element.as_node().is_some() {
219 break;
220 }
221 if let Some(token) = element.as_token()
222 && token.kind() == SyntaxKind::COMMENT
223 {
224 let mut dummy = HashSet::new();
225 Self::parse_directives(token.text(), &mut dummy, &mut stmt_ignores);
226 }
227 prev = element.prev_sibling_or_token();
228 }
229
230 for token in stmt
231 .syntax()
232 .descendants_with_tokens()
233 .filter_map(|it| it.into_token())
234 .filter(|token| token.kind() == SyntaxKind::COMMENT)
235 {
236 let mut dummy = HashSet::new();
237 Self::parse_directives(token.text(), &mut dummy, &mut stmt_ignores);
238 }
239
240 let stmt_text = Self::strip_sql_leading_comments(&stmt.syntax().text().to_string());
242
243 let statement_checkpoint = state.clone();
250 let statement_confidence = state.local.confidence.clone();
251 let mut statement_violations = Vec::new();
252 let mut statement_warned_keys = HashSet::new();
253 let mutations = match AstVisitor::extract(&stmt) {
254 Some(fact) => Resolver::resolve(&fact, state),
255 None => vec![Mutation::Opaque(
256 crate::analysis::mutations::OpaqueMutation::UnsupportedStatement,
257 )],
258 };
259
260 for mutation in mutations {
261 let pre_cascade = match &mutation {
262 Mutation::DropTable(d) if d.cascade => Some(state.get_cascade_closure(&d.id)),
263 _ => None,
264 };
265
266 let pre_state = state.capture_pre_state();
267 let result = state.apply(&mutation, pre_cascade.as_ref());
268
269 let statement_failed = matches!(
270 result,
271 crate::analysis::state::MutationResult::Conflict { .. }
272 );
273 if statement_failed {
274 let transaction_aborted = state.local.transaction_aborted;
275 *state = statement_checkpoint.clone();
276 if transaction_aborted && !state.local.transactions.is_empty() {
277 state.local.transaction_aborted = true;
278 }
279 statement_violations.clear();
280 statement_warned_keys.clear();
281 }
282
283 if result == crate::analysis::state::MutationResult::NotExecuted {
284 continue;
285 }
286
287 for rule in &self.rules {
288 if file_ignores.contains(rule.id())
289 || stmt_ignores.contains(rule.id())
290 || self.config.is_rule_disabled(rule.id())
291 {
292 continue;
293 }
294
295 let violations = rule.evaluate(
296 &mutation,
297 &result,
298 &pre_state,
299 state,
300 &self.config,
301 pre_cascade.as_ref(),
302 );
303
304 for v in violations {
305 if let Some(key) = &v.dedup_key
306 && (warned_keys.contains(key)
307 || !statement_warned_keys.insert(key.clone()))
308 {
309 continue;
310 }
311 let mut v = v;
312 if v.source_range.is_none() {
313 let start = stmt
314 .syntax()
315 .descendants_with_tokens()
316 .filter_map(|element| element.into_token())
317 .find(|token| {
318 let text = token.text().trim();
319 !text.is_empty()
320 && !text.starts_with("--")
321 && !text.starts_with("/*")
322 })
323 .map(|token| token.text_range().start())
324 .unwrap_or_else(|| stmt.syntax().text_range().start());
325 let end = stmt.syntax().text_range().end();
326 v.source_range = Some(rowan::TextRange::new(start, end));
327 }
328 if v.sql.is_none() {
329 if let Some(range) = v.source_range {
330 let start = usize::from(range.start());
331 let end = usize::from(range.end());
332 if start < sql.len() && end <= sql.len() {
333 v.sql = Some(sql[start..end].trim().to_string());
334 } else {
335 v.sql = Some(stmt_text.trim().to_string());
336 }
337 } else {
338 v.sql = Some(stmt_text.trim().to_string());
339 }
340 }
341 if statement_confidence == crate::analysis::state::Confidence::Tainted
344 && v.tier == crate::report::violations::ViolationTier::Tier1
345 {
346 v.tier = crate::report::violations::ViolationTier::Tier2;
347 }
348 statement_violations.push(v);
349 }
350 }
351
352 if statement_failed {
353 break;
354 }
355 }
356
357 warned_keys.extend(statement_warned_keys);
358 all_violations.extend(statement_violations);
359 }
360
361 Ok(all_violations)
362 }
363
364 fn source_location(
365 filename: &str,
366 sql: &str,
367 source_range: Option<rowan::TextRange>,
368 ) -> Option<SourceLocation> {
369 let start = usize::from(source_range?.start());
370 if start > sql.len() || !sql.is_char_boundary(start) {
371 return None;
372 }
373
374 let before = &sql[..start];
375 let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1;
376 let column = before
377 .rsplit_once('\n')
378 .map_or(before, |(_, final_line)| final_line)
379 .chars()
380 .count()
381 + 1;
382 Some(SourceLocation {
383 file: filename.to_string(),
384 line,
385 column,
386 })
387 }
388
389 fn normalize_execute(sql: &str) -> String {
395 let mut out = String::with_capacity(sql.len());
396 for line in sql.split_inclusive('\n') {
397 let trimmed = line.trim_start();
398 let bytes = trimmed.as_bytes();
399 if bytes.len() > 9 && bytes[..9].eq_ignore_ascii_case(b"EXECUTE '") {
400 let indent = &line[..line.len() - trimmed.len()];
401 out.push_str(indent);
402 out.push_str("DO '");
403 out.push_str(&trimmed[9..]);
404 } else if bytes.len() > 10 && bytes[..10].eq_ignore_ascii_case(b"EXECUTE $$") {
405 let indent = &line[..line.len() - trimmed.len()];
406 out.push_str(indent);
407 out.push_str("DO $$");
408 out.push_str(&trimmed[10..]);
409 } else {
410 out.push_str(line);
411 }
412 }
413 out
414 }
415
416 fn parse_directives(
417 text: &str,
418 file_ignores: &mut HashSet<String>,
419 stmt_ignores: &mut HashSet<String>,
420 ) {
421 let marker = "safe-migrate:";
422 let mut pos = 0;
423
424 while let Some(start) = text[pos..].find(marker) {
425 let after = text[pos + start + marker.len()..].trim_start();
426
427 if let Some(rest) = after.strip_prefix("ignore-file") {
428 let rest = rest.trim_start();
429 if let Some(inner) = rest
430 .strip_prefix('(')
431 .and_then(|s| s.find(')').map(|e| &s[..e]))
432 {
433 file_ignores.insert(inner.trim().to_string());
434 }
435 } else if let Some(rest) = after.strip_prefix("ignore") {
436 let rest = rest.trim_start();
437 if let Some(inner) = rest
438 .strip_prefix('(')
439 .and_then(|s| s.find(')').map(|e| &s[..e]))
440 {
441 stmt_ignores.insert(inner.trim().to_string());
442 }
443 }
444
445 pos = pos + start + marker.len();
446 }
447 }
448
449 fn strip_sql_leading_comments(s: &str) -> String {
450 let mut pos = 0;
451 let bytes = s.as_bytes();
452 while pos < bytes.len() {
453 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
454 pos += 1;
455 }
456 if pos + 1 < bytes.len() && bytes[pos] == b'-' && bytes[pos + 1] == b'-' {
457 while pos < bytes.len() && bytes[pos] != b'\n' {
458 pos += 1;
459 }
460 continue;
461 }
462 if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'*' {
463 pos += 2;
464 while pos + 1 < bytes.len() && !(bytes[pos] == b'*' && bytes[pos + 1] == b'/') {
465 pos += 1;
466 }
467 if pos + 1 < bytes.len() {
468 pos += 2;
469 }
470 continue;
471 }
472 break;
473 }
474 s[pos..].to_string()
475 }
476}