1use serde::Serialize;
10
11use crate::db::DbClient;
12use crate::dialect::DialectKind;
13use crate::error::Result;
14use crate::sql_parser::DdlOperation;
15
16#[cfg(feature = "mysql")]
19pub use crate::engines::mysql::safety::{
20 analyze_migration as analyze_migration_mysql, classify_table_size as classify_table_size_mysql,
21 lock_level_for_ddl as lock_level_for_ddl_mysql,
22};
23#[cfg(feature = "postgres")]
24pub use crate::engines::postgres::safety::{
25 analyze_migration, classify_table_size, lock_level_for_ddl,
26};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
35pub enum LockLevel {
36 None,
38 AccessShareLock,
40 RowShareLock,
42 RowExclusiveLock,
44 ShareUpdateExclusiveLock,
46 ShareLock,
48 ShareRowExclusiveLock,
50 ExclusiveLock,
52 AccessExclusiveLock,
54}
55
56impl std::fmt::Display for LockLevel {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 LockLevel::None => write!(f, "None"),
60 LockLevel::AccessShareLock => write!(f, "ACCESS SHARE"),
61 LockLevel::RowShareLock => write!(f, "ROW SHARE"),
62 LockLevel::RowExclusiveLock => write!(f, "ROW EXCLUSIVE"),
63 LockLevel::ShareUpdateExclusiveLock => write!(f, "SHARE UPDATE EXCLUSIVE"),
64 LockLevel::ShareLock => write!(f, "SHARE"),
65 LockLevel::ShareRowExclusiveLock => write!(f, "SHARE ROW EXCLUSIVE"),
66 LockLevel::ExclusiveLock => write!(f, "EXCLUSIVE"),
67 LockLevel::AccessExclusiveLock => write!(f, "ACCESS EXCLUSIVE"),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
74pub enum TableSize {
75 Small,
77 Medium,
79 Large,
81 Huge,
83}
84
85impl std::fmt::Display for TableSize {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 TableSize::Small => write!(f, "Small (<10k rows)"),
89 TableSize::Medium => write!(f, "Medium (10k-1M rows)"),
90 TableSize::Large => write!(f, "Large (1M-100M rows)"),
91 TableSize::Huge => write!(f, "Huge (>100M rows)"),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
98pub enum SafetyVerdict {
99 Safe,
101 Caution,
103 Danger,
105}
106
107impl std::fmt::Display for SafetyVerdict {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 SafetyVerdict::Safe => write!(f, "SAFE"),
111 SafetyVerdict::Caution => write!(f, "CAUTION"),
112 SafetyVerdict::Danger => write!(f, "DANGER"),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Serialize)]
119pub struct StatementAnalysis {
120 pub statement_preview: String,
122 pub lock_level: LockLevel,
124 pub affected_table: Option<String>,
126 pub table_size: Option<TableSize>,
128 pub estimated_rows: Option<i64>,
130 pub verdict: SafetyVerdict,
132 pub suggestions: Vec<String>,
134 pub data_loss: bool,
136}
137
138#[derive(Debug, Clone, Serialize)]
140pub struct SafetyReport {
141 pub script: String,
143 pub overall_verdict: SafetyVerdict,
145 pub statements: Vec<StatementAnalysis>,
147 pub suggestions: Vec<String>,
149}
150
151#[derive(Debug, Clone)]
153pub struct SafetyConfig {
154 pub enabled: bool,
156 pub block_on_danger: bool,
158 pub large_table_threshold: i64,
160 pub huge_table_threshold: i64,
162 pub refresh_stats_mysql: bool,
169}
170
171impl Default for SafetyConfig {
172 fn default() -> Self {
173 Self {
174 enabled: true,
175 block_on_danger: false,
176 large_table_threshold: 1_000_000,
177 huge_table_threshold: 100_000_000,
178 refresh_stats_mysql: false,
179 }
180 }
181}
182
183pub async fn analyze_migration_db(
187 client: &DbClient,
188 schema: &str,
189 sql: &str,
190 script: &str,
191 config: &SafetyConfig,
192) -> Result<SafetyReport> {
193 match client.dialect_kind() {
194 #[cfg(feature = "postgres")]
195 DialectKind::Postgres => {
196 analyze_migration(client.as_postgres()?, schema, sql, script, config).await
197 }
198 #[cfg(not(feature = "postgres"))]
199 DialectKind::Postgres => Err(crate::error::WaypointError::ConfigError(
200 "PostgreSQL support is not compiled in".into(),
201 )),
202 #[cfg(feature = "mysql")]
203 DialectKind::Mysql => analyze_migration_mysql(client, schema, sql, script, config).await,
204 #[cfg(not(feature = "mysql"))]
205 DialectKind::Mysql => Err(crate::error::WaypointError::ConfigError(
206 "MySQL support is not compiled in".into(),
207 )),
208 }
209}
210
211pub(crate) fn classify_row_count(
215 rows: i64,
216 large_threshold: i64,
217 huge_threshold: i64,
218) -> TableSize {
219 if rows > huge_threshold {
220 TableSize::Huge
221 } else if rows > large_threshold {
222 TableSize::Large
223 } else if rows >= 10_000 {
224 TableSize::Medium
225 } else {
226 TableSize::Small
227 }
228}
229
230pub(crate) fn compute_verdict(lock: LockLevel, size: TableSize, data_loss: bool) -> SafetyVerdict {
233 if lock == LockLevel::AccessExclusiveLock
234 && (size == TableSize::Large || size == TableSize::Huge)
235 {
236 return SafetyVerdict::Danger;
237 }
238
239 if data_loss && (size == TableSize::Large || size == TableSize::Huge) {
240 return SafetyVerdict::Danger;
241 }
242
243 if lock == LockLevel::AccessExclusiveLock {
244 return SafetyVerdict::Caution;
245 }
246
247 if lock == LockLevel::ShareLock && (size == TableSize::Large || size == TableSize::Huge) {
248 return SafetyVerdict::Caution;
249 }
250
251 SafetyVerdict::Safe
252}
253
254pub(crate) fn is_data_loss(op: &DdlOperation) -> bool {
256 matches!(
257 op,
258 DdlOperation::DropTable { .. }
259 | DdlOperation::AlterTableDropColumn { .. }
260 | DdlOperation::TruncateTable { .. }
261 )
262}
263
264pub(crate) fn affected_table(op: &DdlOperation) -> Option<String> {
266 match op {
267 DdlOperation::CreateTable { table, .. }
268 | DdlOperation::DropTable { table }
269 | DdlOperation::AlterTableAddColumn { table, .. }
270 | DdlOperation::AlterTableDropColumn { table, .. }
271 | DdlOperation::AlterTableAlterColumn { table, .. }
272 | DdlOperation::CreateIndex { table, .. }
273 | DdlOperation::AddConstraint { table, .. }
274 | DdlOperation::DropConstraint { table, .. }
275 | DdlOperation::TruncateTable { table } => Some(table.clone()),
276 DdlOperation::DropIndex { .. }
277 | DdlOperation::CreateView { .. }
278 | DdlOperation::DropView { .. }
279 | DdlOperation::CreateFunction { .. }
280 | DdlOperation::DropFunction { .. }
281 | DdlOperation::CreateEnum { .. }
282 | DdlOperation::Other { .. } => None,
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
293 fn test_lock_level_ordering() {
294 assert!(LockLevel::None < LockLevel::AccessShareLock);
295 assert!(LockLevel::AccessShareLock < LockLevel::RowShareLock);
296 assert!(LockLevel::RowShareLock < LockLevel::RowExclusiveLock);
297 assert!(LockLevel::RowExclusiveLock < LockLevel::ShareUpdateExclusiveLock);
298 assert!(LockLevel::ShareUpdateExclusiveLock < LockLevel::ShareLock);
299 assert!(LockLevel::ShareLock < LockLevel::ShareRowExclusiveLock);
300 assert!(LockLevel::ShareRowExclusiveLock < LockLevel::ExclusiveLock);
301 assert!(LockLevel::ExclusiveLock < LockLevel::AccessExclusiveLock);
302 }
303
304 #[test]
307 fn test_verdict_access_exclusive_large_is_danger() {
308 assert_eq!(
309 compute_verdict(LockLevel::AccessExclusiveLock, TableSize::Large, false),
310 SafetyVerdict::Danger
311 );
312 }
313
314 #[test]
315 fn test_verdict_access_exclusive_huge_is_danger() {
316 assert_eq!(
317 compute_verdict(LockLevel::AccessExclusiveLock, TableSize::Huge, false),
318 SafetyVerdict::Danger
319 );
320 }
321
322 #[test]
323 fn test_verdict_data_loss_on_large_is_danger() {
324 assert_eq!(
325 compute_verdict(LockLevel::AccessExclusiveLock, TableSize::Large, true),
326 SafetyVerdict::Danger
327 );
328 }
329
330 #[test]
331 fn test_verdict_data_loss_on_huge_is_danger() {
332 assert_eq!(
333 compute_verdict(LockLevel::None, TableSize::Huge, true),
334 SafetyVerdict::Danger
335 );
336 }
337
338 #[test]
339 fn test_verdict_access_exclusive_small_is_caution() {
340 assert_eq!(
341 compute_verdict(LockLevel::AccessExclusiveLock, TableSize::Small, false),
342 SafetyVerdict::Caution
343 );
344 }
345
346 #[test]
347 fn test_verdict_access_exclusive_medium_is_caution() {
348 assert_eq!(
349 compute_verdict(LockLevel::AccessExclusiveLock, TableSize::Medium, false),
350 SafetyVerdict::Caution
351 );
352 }
353
354 #[test]
355 fn test_verdict_share_lock_large_is_caution() {
356 assert_eq!(
357 compute_verdict(LockLevel::ShareLock, TableSize::Large, false),
358 SafetyVerdict::Caution
359 );
360 }
361
362 #[test]
363 fn test_verdict_share_lock_huge_is_caution() {
364 assert_eq!(
365 compute_verdict(LockLevel::ShareLock, TableSize::Huge, false),
366 SafetyVerdict::Caution
367 );
368 }
369
370 #[test]
371 fn test_verdict_share_lock_small_is_safe() {
372 assert_eq!(
373 compute_verdict(LockLevel::ShareLock, TableSize::Small, false),
374 SafetyVerdict::Safe
375 );
376 }
377
378 #[test]
379 fn test_verdict_none_lock_small_is_safe() {
380 assert_eq!(
381 compute_verdict(LockLevel::None, TableSize::Small, false),
382 SafetyVerdict::Safe
383 );
384 }
385
386 #[test]
387 fn test_verdict_concurrent_index_large_is_safe() {
388 assert_eq!(
389 compute_verdict(LockLevel::ShareUpdateExclusiveLock, TableSize::Large, false),
390 SafetyVerdict::Safe
391 );
392 }
393
394 #[test]
397 fn test_data_loss_drop_table() {
398 let op = DdlOperation::DropTable {
399 table: "users".into(),
400 };
401 assert!(is_data_loss(&op));
402 }
403
404 #[test]
405 fn test_data_loss_drop_column() {
406 let op = DdlOperation::AlterTableDropColumn {
407 table: "users".into(),
408 column: "email".into(),
409 };
410 assert!(is_data_loss(&op));
411 }
412
413 #[test]
414 fn test_data_loss_truncate() {
415 let op = DdlOperation::TruncateTable {
416 table: "logs".into(),
417 };
418 assert!(is_data_loss(&op));
419 }
420
421 #[test]
422 fn test_no_data_loss_create_table() {
423 let op = DdlOperation::CreateTable {
424 table: "users".into(),
425 if_not_exists: false,
426 };
427 assert!(!is_data_loss(&op));
428 }
429
430 #[test]
431 fn test_no_data_loss_add_column() {
432 let op = DdlOperation::AlterTableAddColumn {
433 table: "users".into(),
434 column: "email".into(),
435 data_type: "text".into(),
436 has_default: false,
437 is_not_null: false,
438 };
439 assert!(!is_data_loss(&op));
440 }
441
442 #[test]
443 fn test_no_data_loss_create_index() {
444 let op = DdlOperation::CreateIndex {
445 name: "idx".into(),
446 table: "users".into(),
447 is_concurrent: true,
448 is_unique: false,
449 };
450 assert!(!is_data_loss(&op));
451 }
452
453 #[test]
456 fn test_affected_table_create_table() {
457 let op = DdlOperation::CreateTable {
458 table: "orders".into(),
459 if_not_exists: false,
460 };
461 assert_eq!(affected_table(&op), Some("orders".into()));
462 }
463
464 #[test]
465 fn test_affected_table_create_view_is_none() {
466 let op = DdlOperation::CreateView {
467 name: "v_stats".into(),
468 is_materialized: false,
469 };
470 assert_eq!(affected_table(&op), None);
471 }
472
473 #[test]
474 fn test_affected_table_create_function_is_none() {
475 let op = DdlOperation::CreateFunction {
476 name: "my_func".into(),
477 };
478 assert_eq!(affected_table(&op), None);
479 }
480
481 #[test]
482 fn test_affected_table_other_is_none() {
483 let op = DdlOperation::Other {
484 statement_preview: "GRANT SELECT ON ...".into(),
485 };
486 assert_eq!(affected_table(&op), None);
487 }
488
489 #[test]
492 fn test_lock_level_display() {
493 assert_eq!(LockLevel::None.to_string(), "None");
494 assert_eq!(LockLevel::AccessShareLock.to_string(), "ACCESS SHARE");
495 assert_eq!(LockLevel::RowShareLock.to_string(), "ROW SHARE");
496 assert_eq!(LockLevel::RowExclusiveLock.to_string(), "ROW EXCLUSIVE");
497 assert_eq!(
498 LockLevel::ShareUpdateExclusiveLock.to_string(),
499 "SHARE UPDATE EXCLUSIVE"
500 );
501 assert_eq!(LockLevel::ShareLock.to_string(), "SHARE");
502 assert_eq!(
503 LockLevel::ShareRowExclusiveLock.to_string(),
504 "SHARE ROW EXCLUSIVE"
505 );
506 assert_eq!(LockLevel::ExclusiveLock.to_string(), "EXCLUSIVE");
507 assert_eq!(
508 LockLevel::AccessExclusiveLock.to_string(),
509 "ACCESS EXCLUSIVE"
510 );
511 }
512
513 #[test]
514 fn test_safety_verdict_display() {
515 assert_eq!(SafetyVerdict::Safe.to_string(), "SAFE");
516 assert_eq!(SafetyVerdict::Caution.to_string(), "CAUTION");
517 assert_eq!(SafetyVerdict::Danger.to_string(), "DANGER");
518 }
519
520 #[test]
521 fn test_table_size_display() {
522 assert_eq!(TableSize::Small.to_string(), "Small (<10k rows)");
523 assert_eq!(TableSize::Medium.to_string(), "Medium (10k-1M rows)");
524 assert_eq!(TableSize::Large.to_string(), "Large (1M-100M rows)");
525 assert_eq!(TableSize::Huge.to_string(), "Huge (>100M rows)");
526 }
527
528 #[test]
531 fn test_classify_row_count_small() {
532 assert_eq!(
533 classify_row_count(0, 1_000_000, 100_000_000),
534 TableSize::Small
535 );
536 assert_eq!(
537 classify_row_count(9_999, 1_000_000, 100_000_000),
538 TableSize::Small
539 );
540 }
541
542 #[test]
543 fn test_classify_row_count_medium() {
544 assert_eq!(
545 classify_row_count(10_000, 1_000_000, 100_000_000),
546 TableSize::Medium
547 );
548 assert_eq!(
549 classify_row_count(500_000, 1_000_000, 100_000_000),
550 TableSize::Medium
551 );
552 assert_eq!(
553 classify_row_count(1_000_000, 1_000_000, 100_000_000),
554 TableSize::Medium
555 );
556 }
557
558 #[test]
559 fn test_classify_row_count_large() {
560 assert_eq!(
561 classify_row_count(1_000_001, 1_000_000, 100_000_000),
562 TableSize::Large
563 );
564 assert_eq!(
565 classify_row_count(50_000_000, 1_000_000, 100_000_000),
566 TableSize::Large
567 );
568 assert_eq!(
569 classify_row_count(100_000_000, 1_000_000, 100_000_000),
570 TableSize::Large
571 );
572 }
573
574 #[test]
575 fn test_classify_row_count_huge() {
576 assert_eq!(
577 classify_row_count(100_000_001, 1_000_000, 100_000_000),
578 TableSize::Huge
579 );
580 assert_eq!(
581 classify_row_count(1_000_000_000, 1_000_000, 100_000_000),
582 TableSize::Huge
583 );
584 }
585
586 #[test]
587 fn test_classify_custom_thresholds() {
588 assert_eq!(classify_row_count(500, 1_000, 10_000), TableSize::Small);
589 assert_eq!(classify_row_count(1_001, 1_000, 10_000), TableSize::Large);
590 assert_eq!(classify_row_count(10_000, 1_000, 10_000), TableSize::Large);
591 assert_eq!(classify_row_count(10_001, 1_000, 10_000), TableSize::Huge);
592 }
593
594 #[test]
597 fn test_safety_config_defaults() {
598 let config = SafetyConfig::default();
599 assert!(config.enabled);
600 assert!(!config.block_on_danger);
601 assert_eq!(config.large_table_threshold, 1_000_000);
602 assert_eq!(config.huge_table_threshold, 100_000_000);
603 }
604}