1use crate::db::LiveSchema;
13use crate::graph::SchemaGraph;
14use crate::types::RiskLevel;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case", tag = "kind")]
23pub enum DriftFinding {
24 ExtraTable { table: String },
26 MissingTable { table: String },
28 ColumnTypeMismatch {
30 table: String,
31 column: String,
32 in_migration: String,
33 in_database: String,
34 },
35 ExtraColumn { table: String, column: String },
37 MissingColumn { table: String, column: String },
39 ExtraIndex { table: String, index: String },
41 MissingIndex { table: String, index: String },
43 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#[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
150pub 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 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 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 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 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 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 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 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 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 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 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}