Skip to main content

schema_risk/
drift.rs

1//! Schema drift detection.
2//!
3//! `schema-risk diff --db-url postgres://...` connects to a live database and
4//! compares what the database actually contains against what you would expect
5//! based on the migration files you have.
6//!
7//! This answers the question: "Has someone edited the production schema by
8//! hand, or are there migrations that haven't run yet?"
9//!
10//! Compiled unconditionally; the actual DB connection is feature-gated.
11
12use crate::db::LiveSchema;
13use crate::graph::SchemaGraph;
14use crate::types::RiskLevel;
15use serde::{Deserialize, Serialize};
16
17// ─────────────────────────────────────────────
18// Drift finding types
19// ─────────────────────────────────────────────
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case", tag = "kind")]
23pub enum DriftFinding {
24    /// Table exists in the DB but not in any migration file
25    ExtraTable { table: String },
26    /// Table is in migration files but not in the DB (migration not run yet)
27    MissingTable { table: String },
28    /// Column type in DB doesn't match the migration definition
29    ColumnTypeMismatch {
30        table: String,
31        column: String,
32        in_migration: String,
33        in_database: String,
34    },
35    /// Column exists in DB but not in migration
36    ExtraColumn { table: String, column: String },
37    /// Column is in migration but not in DB
38    MissingColumn { table: String, column: String },
39    /// Index exists in DB but the migration never created it
40    ExtraIndex { table: String, index: String },
41    /// Migration creates an index that isn't in the DB (not applied)
42    MissingIndex { table: String, index: String },
43    /// Nullable mismatch
44    NullableMismatch {
45        table: String,
46        column: String,
47        in_migration: bool,
48        in_database: bool,
49    },
50}
51
52impl DriftFinding {
53    pub fn severity(&self) -> RiskLevel {
54        match self {
55            DriftFinding::ExtraTable { .. } => RiskLevel::High,
56            DriftFinding::MissingTable { .. } => RiskLevel::Critical,
57            DriftFinding::ColumnTypeMismatch { .. } => RiskLevel::Critical,
58            DriftFinding::ExtraColumn { .. } => RiskLevel::Low,
59            DriftFinding::MissingColumn { .. } => RiskLevel::High,
60            DriftFinding::ExtraIndex { .. } => RiskLevel::Low,
61            DriftFinding::MissingIndex { .. } => RiskLevel::Medium,
62            DriftFinding::NullableMismatch { .. } => RiskLevel::Medium,
63        }
64    }
65
66    pub fn description(&self) -> String {
67        match self {
68            DriftFinding::ExtraTable { table } => {
69                format!(
70                    "Table '{}' exists in the database but not in any migration file",
71                    table
72                )
73            }
74            DriftFinding::MissingTable { table } => {
75                format!(
76                    "Table '{}' is defined in migrations but not found in the live database",
77                    table
78                )
79            }
80            DriftFinding::ColumnTypeMismatch {
81                table,
82                column,
83                in_migration,
84                in_database,
85            } => {
86                format!(
87                    "Column '{}.{}': migration says '{}' but database has '{}'",
88                    table, column, in_migration, in_database
89                )
90            }
91            DriftFinding::ExtraColumn { table, column } => {
92                format!(
93                    "Column '{}.{}' exists in database but not in migration files",
94                    table, column
95                )
96            }
97            DriftFinding::MissingColumn { table, column } => {
98                format!(
99                    "Column '{}.{}' is in migration files but not in the database",
100                    table, column
101                )
102            }
103            DriftFinding::ExtraIndex { table, index } => {
104                format!(
105                    "Index '{}' on '{}' exists in database but not in migration files",
106                    index, table
107                )
108            }
109            DriftFinding::MissingIndex { table, index } => {
110                format!(
111                    "Index '{}' on '{}' is in migration files but not in the database",
112                    index, table
113                )
114            }
115            DriftFinding::NullableMismatch {
116                table,
117                column,
118                in_migration,
119                in_database,
120            } => {
121                format!(
122                    "Nullable mismatch on '{}.{}': migration says nullable={}, database says nullable={}",
123                    table, column, in_migration, in_database
124                )
125            }
126        }
127    }
128}
129
130// ─────────────────────────────────────────────
131// Drift report
132// ─────────────────────────────────────────────
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct DriftReport {
136    pub overall_drift: RiskLevel,
137    pub total_findings: usize,
138    pub findings: Vec<DriftFinding>,
139    pub migration_tables: Vec<String>,
140    pub database_tables: Vec<String>,
141    pub in_sync: bool,
142}
143
144impl DriftReport {
145    pub fn is_clean(&self) -> bool {
146        self.findings.is_empty()
147    }
148}
149
150// ─────────────────────────────────────────────
151// Diff engine
152// ─────────────────────────────────────────────
153
154/// Compare the schema graph inferred from migration files against the live
155/// database snapshot.
156pub fn diff(migration_graph: &SchemaGraph, live: &LiveSchema) -> DriftReport {
157    let mut findings: Vec<DriftFinding> = Vec::new();
158
159    let migration_tables: Vec<String> = migration_graph.all_tables();
160    let database_tables: Vec<String> = live.tables.keys().cloned().collect();
161
162    // Tables in DB but not in migrations
163    for db_table in &database_tables {
164        if !migration_tables
165            .iter()
166            .any(|t| t.eq_ignore_ascii_case(db_table))
167        {
168            findings.push(DriftFinding::ExtraTable {
169                table: db_table.clone(),
170            });
171        }
172    }
173
174    // Tables in migrations but not in DB
175    for mig_table in &migration_tables {
176        if !database_tables
177            .iter()
178            .any(|t| t.eq_ignore_ascii_case(mig_table))
179        {
180            findings.push(DriftFinding::MissingTable {
181                table: mig_table.clone(),
182            });
183        }
184    }
185
186    // For tables that exist in both, check columns and indexes
187    for mig_table in &migration_tables {
188        let live_meta = database_tables
189            .iter()
190            .find(|t| t.eq_ignore_ascii_case(mig_table))
191            .and_then(|t| live.tables.get(t));
192
193        let Some(live_meta) = live_meta else { continue };
194
195        // Get migration columns from the graph
196        let mig_column_keys: Vec<String> = migration_graph
197            .column_index
198            .keys()
199            .filter(|k| k.starts_with(&format!("{}.", mig_table)))
200            .map(|k| k.split('.').nth(1).unwrap_or("").to_string())
201            .collect();
202
203        // Columns in DB but not in migration
204        for live_col in &live_meta.columns {
205            if !mig_column_keys
206                .iter()
207                .any(|c| c.eq_ignore_ascii_case(&live_col.name))
208            {
209                findings.push(DriftFinding::ExtraColumn {
210                    table: mig_table.clone(),
211                    column: live_col.name.clone(),
212                });
213            }
214        }
215
216        // Columns in migration but not in DB
217        for mig_col in &mig_column_keys {
218            let live_col = live_meta
219                .columns
220                .iter()
221                .find(|c| c.name.eq_ignore_ascii_case(mig_col));
222
223            if live_col.is_none() {
224                findings.push(DriftFinding::MissingColumn {
225                    table: mig_table.clone(),
226                    column: mig_col.clone(),
227                });
228                continue;
229            }
230
231            // Check nullable mismatch against graph node
232            let key = format!("{}.{}", mig_table, mig_col);
233            if let Some(&node_idx) = migration_graph.column_index.get(&key) {
234                if let crate::graph::SchemaNode::Column {
235                    nullable: mig_nullable,
236                    ..
237                } = &migration_graph.graph[node_idx]
238                {
239                    let db_nullable = live_col.unwrap().is_nullable;
240                    if *mig_nullable != db_nullable {
241                        findings.push(DriftFinding::NullableMismatch {
242                            table: mig_table.clone(),
243                            column: mig_col.clone(),
244                            in_migration: *mig_nullable,
245                            in_database: db_nullable,
246                        });
247                    }
248                }
249            }
250        }
251
252        // Indexes: check DB indexes that don't appear in migration
253        for (idx_name, idx_meta) in &live.indexes {
254            if idx_meta.table.eq_ignore_ascii_case(mig_table)
255                && !idx_meta.is_primary
256                && !migration_graph.index_index.contains_key(idx_name)
257            {
258                findings.push(DriftFinding::ExtraIndex {
259                    table: mig_table.clone(),
260                    index: idx_name.clone(),
261                });
262            }
263        }
264
265        // Indexes in migration but not in DB
266        for (idx_name, &_idx_node) in &migration_graph.index_index {
267            let table_prefix_match = live.indexes.values().any(|i| {
268                i.table.eq_ignore_ascii_case(mig_table) && i.name.eq_ignore_ascii_case(idx_name)
269            });
270
271            if !table_prefix_match {
272                // Check if this index belongs to the current table
273                let migration_idx_node = migration_graph.index_index.get(idx_name);
274                if let Some(&node) = migration_idx_node {
275                    if let crate::graph::SchemaNode::Index { table, .. } =
276                        &migration_graph.graph[node]
277                    {
278                        if table.eq_ignore_ascii_case(mig_table) {
279                            findings.push(DriftFinding::MissingIndex {
280                                table: mig_table.clone(),
281                                index: idx_name.clone(),
282                            });
283                        }
284                    }
285                }
286            }
287        }
288    }
289
290    let overall_drift = findings
291        .iter()
292        .map(|f| f.severity())
293        .max()
294        .unwrap_or(RiskLevel::Low);
295
296    let total_findings = findings.len();
297
298    DriftReport {
299        overall_drift,
300        total_findings,
301        findings,
302        migration_tables,
303        database_tables,
304        in_sync: total_findings == 0,
305    }
306}