Skip to main content

waypoint_core/
safety.rs

1//! Migration safety analysis: lock levels, impact estimation, and verdicts.
2//!
3//! This module owns the engine-agnostic types ([`LockLevel`], [`SafetyReport`],
4//! [`SafetyConfig`], etc.), the dialect-aware dispatcher
5//! ([`analyze_migration_db`]), and a handful of shared helpers used by both
6//! engine paths. The actual per-engine analysers live in
7//! [`crate::engines::postgres::safety`] and [`crate::engines::mysql::safety`].
8
9use serde::Serialize;
10
11use crate::db::DbClient;
12use crate::dialect::DialectKind;
13use crate::error::Result;
14use crate::sql_parser::DdlOperation;
15
16// ── Re-exports of the engine-specific entry points ──────────────────────────
17
18#[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// ── Shared types ────────────────────────────────────────────────────────────
29
30/// PostgreSQL lock levels, ordered from least to most restrictive.
31///
32/// The ordering matches PostgreSQL's internal lock hierarchy so that
33/// comparisons (e.g. `lock > LockLevel::ShareLock`) work correctly.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
35pub enum LockLevel {
36    /// No lock acquired (new objects, functions, enums).
37    None,
38    /// ACCESS SHARE — acquired by SELECT.
39    AccessShareLock,
40    /// ROW SHARE — acquired by SELECT FOR UPDATE/SHARE.
41    RowShareLock,
42    /// ROW EXCLUSIVE — acquired by INSERT/UPDATE/DELETE.
43    RowExclusiveLock,
44    /// SHARE UPDATE EXCLUSIVE — acquired by VACUUM, CREATE INDEX CONCURRENTLY.
45    ShareUpdateExclusiveLock,
46    /// SHARE — acquired by CREATE INDEX (non-concurrent).
47    ShareLock,
48    /// SHARE ROW EXCLUSIVE — acquired by some constraint triggers.
49    ShareRowExclusiveLock,
50    /// EXCLUSIVE — blocks all reads/writes except ACCESS SHARE.
51    ExclusiveLock,
52    /// ACCESS EXCLUSIVE — the strongest lock; blocks everything.
53    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/// Rough classification of table size based on estimated row count.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
74pub enum TableSize {
75    /// Fewer than 10,000 rows.
76    Small,
77    /// 10,000 to 1,000,000 rows.
78    Medium,
79    /// 1,000,000 to 100,000,000 rows.
80    Large,
81    /// More than 100,000,000 rows.
82    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/// Overall safety verdict for a migration statement or script.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
98pub enum SafetyVerdict {
99    /// No significant risk detected.
100    Safe,
101    /// Moderate risk — review recommended.
102    Caution,
103    /// High risk — may cause downtime or data loss.
104    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/// Safety analysis for a single SQL statement within a migration.
118#[derive(Debug, Clone, Serialize)]
119pub struct StatementAnalysis {
120    /// A short preview of the analyzed statement.
121    pub statement_preview: String,
122    /// The lock level this statement acquires.
123    pub lock_level: LockLevel,
124    /// The table affected by this statement, if identifiable.
125    pub affected_table: Option<String>,
126    /// Estimated table size classification, if known.
127    pub table_size: Option<TableSize>,
128    /// Estimated live row count, if available from statistics.
129    pub estimated_rows: Option<i64>,
130    /// The safety verdict for this statement.
131    pub verdict: SafetyVerdict,
132    /// Actionable suggestions for reducing risk.
133    pub suggestions: Vec<String>,
134    /// Whether this statement causes irreversible data loss.
135    pub data_loss: bool,
136}
137
138/// Full safety report for a migration script.
139#[derive(Debug, Clone, Serialize)]
140pub struct SafetyReport {
141    /// The migration script filename or identifier.
142    pub script: String,
143    /// The worst-case verdict across all statements.
144    pub overall_verdict: SafetyVerdict,
145    /// Per-statement analysis results.
146    pub statements: Vec<StatementAnalysis>,
147    /// Aggregated suggestions across all statements.
148    pub suggestions: Vec<String>,
149}
150
151/// Configuration for safety analysis.
152#[derive(Debug, Clone)]
153pub struct SafetyConfig {
154    /// Whether safety analysis is enabled.
155    pub enabled: bool,
156    /// Whether to block migrations that receive a DANGER verdict.
157    pub block_on_danger: bool,
158    /// Row count threshold for classifying a table as Large.
159    pub large_table_threshold: i64,
160    /// Row count threshold for classifying a table as Huge.
161    pub huge_table_threshold: i64,
162    /// MySQL only: run `ANALYZE TABLE <name>` on each affected table before
163    /// reading `information_schema.tables.table_rows` for size classification.
164    /// Off by default because `ANALYZE TABLE` acquires a brief metadata lock
165    /// and rewrites stats. Enable when you need accurate size classification
166    /// at the cost of touching the table — typically during a CI safety check
167    /// rather than at production-migrate time.
168    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
183// ── Dispatcher ──────────────────────────────────────────────────────────────
184
185/// Analyse a migration's SQL for safety verdicts (dialect-aware entry).
186pub 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
211// ── Shared helpers (used by both engine paths) ──────────────────────────────
212
213/// Classify a row count into a [`TableSize`] using the given thresholds.
214pub(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
230/// Determine the safety verdict for a statement given its lock level,
231/// affected table size, and whether it causes data loss.
232pub(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
254/// Check whether a DDL operation causes irreversible data loss.
255pub(crate) fn is_data_loss(op: &DdlOperation) -> bool {
256    matches!(
257        op,
258        DdlOperation::DropTable { .. }
259            | DdlOperation::AlterTableDropColumn { .. }
260            | DdlOperation::TruncateTable { .. }
261    )
262}
263
264/// Extract the affected table name from a DDL operation, if applicable.
265pub(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    // ── Lock level ordering ───────────────────────────────────────────
291
292    #[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    // ── Verdict computation ───────────────────────────────────────────
305
306    #[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    // ── Data loss detection ───────────────────────────────────────────
395
396    #[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    // ── Affected table extraction ─────────────────────────────────────
454
455    #[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    // ── Display impls ─────────────────────────────────────────────────
490
491    #[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    // ── Row count classification ──────────────────────────────────────
529
530    #[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    // ── SafetyConfig defaults ─────────────────────────────────────────
595
596    #[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}