1use std::collections::HashMap;
46
47use crate::physical::DeclaredColumnContract;
48use crate::storage::query::ast::CreateColumnDef;
49
50#[derive(Debug, Clone)]
52pub struct SchemaDiff {
53 pub table: String,
54 pub drifted: bool,
57 pub operations: Vec<DiffOp>,
58 pub rename_candidates: Vec<RenameCandidate>,
59 pub summary: DiffSummary,
60}
61
62#[derive(Debug, Clone)]
64pub enum DiffOp {
65 AddColumn(DeclaredColumnContract),
67 DropColumn(String),
69 TypeChange {
73 name: String,
74 from: DeclaredColumnContract,
75 to: DeclaredColumnContract,
76 },
77}
78
79#[derive(Debug, Clone)]
82pub struct RenameCandidate {
83 pub from: String,
84 pub to: String,
85 pub confidence: RenameConfidence,
86 pub basis: &'static str,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum RenameConfidence {
93 Low,
94 Medium,
95 High,
96}
97
98impl RenameConfidence {
99 pub fn as_str(self) -> &'static str {
100 match self {
101 Self::Low => "low",
102 Self::Medium => "medium",
103 Self::High => "high",
104 }
105 }
106}
107
108#[derive(Debug, Clone, Default)]
112pub struct DiffSummary {
113 pub add_columns: usize,
114 pub drop_columns: usize,
115 pub type_changes: usize,
116 pub rename_candidates: usize,
117}
118
119pub fn compute_column_diff(
127 table: &str,
128 current: &[DeclaredColumnContract],
129 target: &[CreateColumnDef],
130) -> SchemaDiff {
131 let current_by_name: HashMap<&str, &DeclaredColumnContract> =
132 current.iter().map(|c| (c.name.as_str(), c)).collect();
133 let target_by_name: HashMap<&str, &CreateColumnDef> =
134 target.iter().map(|c| (c.name.as_str(), c)).collect();
135
136 let mut operations: Vec<DiffOp> = Vec::new();
137 let mut unpaired_drops: Vec<&DeclaredColumnContract> = Vec::new();
138 let mut unpaired_adds: Vec<&CreateColumnDef> = Vec::new();
139
140 for (name, t) in &target_by_name {
142 match current_by_name.get(name) {
143 None => {
144 unpaired_adds.push(t);
145 }
146 Some(c) => {
147 if !column_equivalent(c, t) {
148 operations.push(DiffOp::TypeChange {
149 name: name.to_string(),
150 from: (*c).clone(),
151 to: declared_column_contract_from_create(t),
152 });
153 }
154 }
155 }
156 }
157
158 for (name, c) in ¤t_by_name {
160 if !target_by_name.contains_key(name) {
161 unpaired_drops.push(*c);
162 }
163 }
164
165 let rename_candidates = detect_rename_candidates(&unpaired_drops, &unpaired_adds);
172
173 for c in unpaired_drops {
176 operations.push(DiffOp::DropColumn(c.name.clone()));
177 }
178 for t in unpaired_adds {
179 operations.push(DiffOp::AddColumn(declared_column_contract_from_create(t)));
180 }
181
182 let summary = DiffSummary {
183 add_columns: operations
184 .iter()
185 .filter(|o| matches!(o, DiffOp::AddColumn(_)))
186 .count(),
187 drop_columns: operations
188 .iter()
189 .filter(|o| matches!(o, DiffOp::DropColumn(_)))
190 .count(),
191 type_changes: operations
192 .iter()
193 .filter(|o| matches!(o, DiffOp::TypeChange { .. }))
194 .count(),
195 rename_candidates: rename_candidates.len(),
196 };
197 let drifted = !operations.is_empty();
198
199 SchemaDiff {
200 table: table.to_string(),
201 drifted,
202 operations,
203 rename_candidates,
204 summary,
205 }
206}
207
208pub fn column_equivalent(c: &DeclaredColumnContract, t: &CreateColumnDef) -> bool {
235 let type_match = match c.sql_type.as_ref() {
237 Some(cur_sql_type) => *cur_sql_type == t.sql_type,
238 None => c.data_type.eq_ignore_ascii_case(&t.data_type),
239 };
240 if !type_match {
241 return false;
242 }
243
244 if c.not_null != t.not_null
246 || c.unique != t.unique
247 || c.primary_key != t.primary_key
248 || c.compress != t.compress
249 {
250 return false;
251 }
252
253 if normalize_default(&c.default) != normalize_default(&t.default) {
255 return false;
256 }
257
258 if c.enum_variants != t.enum_variants {
260 return false;
261 }
262
263 if c.array_element != t.array_element {
265 return false;
266 }
267
268 if c.decimal_precision != t.decimal_precision {
270 return false;
271 }
272
273 true
274}
275
276fn normalize_default(d: &Option<String>) -> Option<String> {
280 let s = d.as_ref()?;
281 let trimmed = s.trim();
282 if trimmed.is_empty() {
283 return None;
284 }
285 let stripped = if (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
286 || (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
287 {
288 &trimmed[1..trimmed.len() - 1]
289 } else {
290 trimmed
291 };
292 Some(stripped.to_string())
293}
294
295fn detect_rename_candidates(
304 drops: &[&DeclaredColumnContract],
305 adds: &[&CreateColumnDef],
306) -> Vec<RenameCandidate> {
307 let mut candidates = Vec::new();
308 let mut taken_adds: Vec<bool> = vec![false; adds.len()];
309
310 for drop_col in drops {
311 let mut best: Option<(usize, RenameConfidence, &'static str)> = None;
315 for (i, add_col) in adds.iter().enumerate() {
316 if taken_adds[i] {
317 continue;
318 }
319 let pair_score = score_rename_pair(drop_col, add_col);
320 if let Some((conf, basis)) = pair_score {
321 let better = match (&best, conf) {
322 (None, _) => true,
323 (Some((_, prev, _)), new) => confidence_rank(new) > confidence_rank(*prev),
324 };
325 if better {
326 best = Some((i, conf, basis));
327 }
328 }
329 }
330
331 if let Some((idx, conf, basis)) = best {
332 taken_adds[idx] = true;
333 candidates.push(RenameCandidate {
334 from: drop_col.name.clone(),
335 to: adds[idx].name.clone(),
336 confidence: conf,
337 basis,
338 });
339 }
340 }
341
342 candidates
343}
344
345fn confidence_rank(c: RenameConfidence) -> u8 {
346 match c {
347 RenameConfidence::Low => 1,
348 RenameConfidence::Medium => 2,
349 RenameConfidence::High => 3,
350 }
351}
352
353fn score_rename_pair(
358 drop_col: &DeclaredColumnContract,
359 add_col: &CreateColumnDef,
360) -> Option<(RenameConfidence, &'static str)> {
361 let type_match = match drop_col.sql_type.as_ref() {
363 Some(cur) => *cur == add_col.sql_type,
364 None => drop_col.data_type.eq_ignore_ascii_case(&add_col.data_type),
365 };
366 if !type_match {
367 return None;
368 }
369
370 let constraints_match = drop_col.not_null == add_col.not_null
372 && drop_col.unique == add_col.unique
373 && drop_col.primary_key == add_col.primary_key
374 && drop_col.compress == add_col.compress;
375
376 if constraints_match
377 && normalize_default(&drop_col.default) == normalize_default(&add_col.default)
378 {
379 return Some((RenameConfidence::High, "type_match+constraints+default"));
380 }
381
382 if constraints_match {
384 return Some((RenameConfidence::Medium, "type_match+constraints"));
385 }
386
387 Some((RenameConfidence::Low, "type_match"))
389}
390
391fn declared_column_contract_from_create(column: &CreateColumnDef) -> DeclaredColumnContract {
402 DeclaredColumnContract {
403 name: column.name.clone(),
404 data_type: column.data_type.clone(),
405 sql_type: Some(column.sql_type.clone()),
406 not_null: column.not_null,
407 default: column.default.clone(),
408 compress: column.compress,
409 unique: column.unique,
410 primary_key: column.primary_key,
411 enum_variants: column.enum_variants.clone(),
412 array_element: column.array_element.clone(),
413 decimal_precision: column.decimal_precision,
414 }
415}
416
417pub fn format_as_sql(diff: &SchemaDiff) -> String {
437 let mut out = String::new();
438 out.push_str(&format!("-- EXPLAIN ALTER FOR {}\n", diff.table));
439 let total = diff.summary.add_columns + diff.summary.drop_columns + diff.summary.type_changes;
440 out.push_str(&format!(
441 "-- {} changes detected ({} adds, {} drops, {} type changes)\n",
442 total, diff.summary.add_columns, diff.summary.drop_columns, diff.summary.type_changes
443 ));
444 if !diff.rename_candidates.is_empty() {
445 out.push_str(&format!(
446 "-- rename candidates: {}\n",
447 diff.rename_candidates.len()
448 ));
449 }
450 if !diff.drifted {
451 out.push_str("-- no drift detected\n");
452 return out;
453 }
454
455 for op in &diff.operations {
456 match op {
457 DiffOp::AddColumn(col) => {
458 out.push_str(&format!(
459 "ALTER TABLE {} ADD COLUMN {} {};\n",
460 diff.table,
461 col.name,
462 render_column_type(col)
463 ));
464 }
465 DiffOp::DropColumn(name) => {
466 out.push_str(&format!(
467 "ALTER TABLE {} DROP COLUMN {};\n",
468 diff.table, name
469 ));
470 }
471 DiffOp::TypeChange { name, to, .. } => {
472 out.push_str(&format!(
476 "-- type change on `{}`: emitting DROP + ADD\n",
477 name
478 ));
479 out.push_str(&format!(
480 "ALTER TABLE {} DROP COLUMN {};\n",
481 diff.table, name
482 ));
483 out.push_str(&format!(
484 "ALTER TABLE {} ADD COLUMN {} {};\n",
485 diff.table,
486 name,
487 render_column_type(to)
488 ));
489 }
490 }
491 }
492
493 for cand in &diff.rename_candidates {
494 out.push_str(&format!(
495 "-- hint: `{}` -> `{}` could be a rename (confidence: {}, basis: {})\n",
496 cand.from,
497 cand.to,
498 cand.confidence.as_str(),
499 cand.basis
500 ));
501 }
502
503 out
504}
505
506fn render_column_type(col: &DeclaredColumnContract) -> String {
510 let base = match col.sql_type.as_ref() {
511 Some(t) => t.to_string(),
512 None => col.data_type.clone(),
513 };
514 let mut out = base;
515 if col.primary_key {
516 out.push_str(" PRIMARY KEY");
517 }
518 if col.not_null && !col.primary_key {
519 out.push_str(" NOT NULL");
520 }
521 if col.unique && !col.primary_key {
522 out.push_str(" UNIQUE");
523 }
524 if let Some(default) = col.default.as_ref() {
525 out.push_str(&format!(" DEFAULT {}", default));
526 }
527 out
528}
529
530pub fn format_as_json(diff: &SchemaDiff) -> String {
541 let sql = format_as_sql(diff);
542 let mut out = String::with_capacity(512);
543 out.push_str("{\n");
544 out.push_str(&format!(" \"table\": {},\n", json_string(&diff.table)));
545 out.push_str(&format!(" \"drifted\": {},\n", diff.drifted));
546 out.push_str(&format!(" \"sql\": {},\n", json_string(&sql)));
547 out.push_str(" \"operations\": [\n");
548 for (i, op) in diff.operations.iter().enumerate() {
549 let comma = if i + 1 < diff.operations.len() {
550 ","
551 } else {
552 ""
553 };
554 out.push_str(&format!(" {}{}\n", json_op(op), comma));
555 }
556 out.push_str(" ],\n");
557 out.push_str(" \"rename_candidates\": [\n");
558 for (i, cand) in diff.rename_candidates.iter().enumerate() {
559 let comma = if i + 1 < diff.rename_candidates.len() {
560 ","
561 } else {
562 ""
563 };
564 out.push_str(&format!(" {}{}\n", json_rename(cand), comma));
565 }
566 out.push_str(" ],\n");
567 out.push_str(&format!(
568 " \"summary\": {{ \"add_columns\": {}, \"drop_columns\": {}, \"type_changes\": {}, \"rename_candidates\": {} }}\n",
569 diff.summary.add_columns,
570 diff.summary.drop_columns,
571 diff.summary.type_changes,
572 diff.summary.rename_candidates
573 ));
574 out.push_str("}\n");
575 out
576}
577
578fn json_op(op: &DiffOp) -> String {
579 match op {
580 DiffOp::AddColumn(col) => format!(
581 "{{ \"op\": \"add_column\", \"column\": {}, \"reason\": \"column present in target but not in current contract\" }}",
582 json_column(col)
583 ),
584 DiffOp::DropColumn(name) => format!(
585 "{{ \"op\": \"drop_column\", \"name\": {}, \"reason\": \"column present in current contract but not in target\" }}",
586 json_string(name)
587 ),
588 DiffOp::TypeChange { name, from, to } => format!(
589 "{{ \"op\": \"type_change\", \"name\": {}, \"from\": {}, \"to\": {} }}",
590 json_string(name),
591 json_column(from),
592 json_column(to)
593 ),
594 }
595}
596
597fn json_column(col: &DeclaredColumnContract) -> String {
598 let mut out = String::from("{ ");
599 out.push_str(&format!("\"name\": {}", json_string(&col.name)));
600 let sql_type = col
601 .sql_type
602 .as_ref()
603 .map(|t| t.to_string())
604 .unwrap_or_else(|| col.data_type.clone());
605 out.push_str(&format!(", \"sql_type\": {}", json_string(&sql_type)));
606 out.push_str(&format!(", \"not_null\": {}", col.not_null));
607 out.push_str(&format!(", \"primary_key\": {}", col.primary_key));
608 out.push_str(&format!(", \"unique\": {}", col.unique));
609 if let Some(default) = col.default.as_ref() {
610 out.push_str(&format!(", \"default\": {}", json_string(default)));
611 }
612 out.push_str(" }");
613 out
614}
615
616fn json_rename(cand: &RenameCandidate) -> String {
617 format!(
618 "{{ \"from\": {}, \"to\": {}, \"confidence\": {}, \"basis\": {} }}",
619 json_string(&cand.from),
620 json_string(&cand.to),
621 json_string(cand.confidence.as_str()),
622 json_string(cand.basis)
623 )
624}
625
626fn json_string(s: &str) -> String {
630 let mut out = String::with_capacity(s.len() + 2);
631 out.push('"');
632 for ch in s.chars() {
633 match ch {
634 '"' => out.push_str("\\\""),
635 '\\' => out.push_str("\\\\"),
636 '\n' => out.push_str("\\n"),
637 '\r' => out.push_str("\\r"),
638 '\t' => out.push_str("\\t"),
639 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
640 c => out.push(c),
641 }
642 }
643 out.push('"');
644 out
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use crate::storage::schema::SqlTypeName;
651
652 fn declared(name: &str, sql_type: &str, not_null: bool) -> DeclaredColumnContract {
653 DeclaredColumnContract {
654 name: name.to_string(),
655 data_type: sql_type.to_string(),
656 sql_type: Some(SqlTypeName::new(sql_type)),
657 not_null,
658 default: None,
659 compress: None,
660 unique: false,
661 primary_key: false,
662 enum_variants: Vec::new(),
663 array_element: None,
664 decimal_precision: None,
665 }
666 }
667
668 fn target(name: &str, sql_type: &str, not_null: bool) -> CreateColumnDef {
669 CreateColumnDef {
670 name: name.to_string(),
671 data_type: sql_type.to_string(),
672 sql_type: SqlTypeName::new(sql_type),
673 not_null,
674 default: None,
675 compress: None,
676 unique: false,
677 primary_key: false,
678 enum_variants: Vec::new(),
679 array_element: None,
680 decimal_precision: None,
681 }
682 }
683
684 #[test]
685 fn diff_identical_columns_returns_empty() {
686 let current = vec![
687 declared("id", "TEXT", true),
688 declared("name", "TEXT", false),
689 ];
690 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
691 let diff = compute_column_diff("users", ¤t, &target);
692 assert!(!diff.drifted);
693 assert!(diff.operations.is_empty());
694 assert_eq!(diff.summary.add_columns, 0);
695 }
696
697 #[test]
698 fn diff_adds_missing_column() {
699 let current = vec![declared("id", "TEXT", true)];
700 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
701 let diff = compute_column_diff("users", ¤t, &target);
702 assert!(diff.drifted);
703 assert_eq!(diff.summary.add_columns, 1);
704 assert_eq!(diff.summary.drop_columns, 0);
705 assert!(matches!(&diff.operations[0], DiffOp::AddColumn(_)));
706 }
707
708 #[test]
709 fn diff_drops_extra_column() {
710 let current = vec![
711 declared("id", "TEXT", true),
712 declared("legacy", "TEXT", false),
713 ];
714 let target = vec![target("id", "TEXT", true)];
715 let diff = compute_column_diff("users", ¤t, &target);
716 assert!(diff.drifted);
717 assert_eq!(diff.summary.add_columns, 0);
718 assert_eq!(diff.summary.drop_columns, 1);
719 assert!(matches!(&diff.operations[0], DiffOp::DropColumn(_)));
720 }
721
722 #[test]
723 fn diff_detects_type_change() {
724 let current = vec![declared("age", "TEXT", false)];
725 let target = vec![target("age", "INTEGER", false)];
726 let diff = compute_column_diff("users", ¤t, &target);
727 assert_eq!(diff.summary.type_changes, 1);
728 assert_eq!(diff.summary.add_columns, 0);
729 assert_eq!(diff.summary.drop_columns, 0);
730 }
731
732 #[test]
733 fn diff_detects_not_null_change() {
734 let current = vec![declared("email", "TEXT", false)];
735 let target = vec![target("email", "TEXT", true)];
736 let diff = compute_column_diff("users", ¤t, &target);
737 assert_eq!(diff.summary.type_changes, 1);
738 }
739
740 #[test]
741 fn rename_candidate_medium_confidence() {
742 let current = vec![declared("legacy_ts", "TIMESTAMP", false)];
743 let target = vec![target("created_at", "TIMESTAMP", false)];
744 let diff = compute_column_diff("events", ¤t, &target);
745 assert_eq!(diff.summary.add_columns, 1);
747 assert_eq!(diff.summary.drop_columns, 1);
748 assert_eq!(diff.rename_candidates.len(), 1);
750 assert_eq!(diff.rename_candidates[0].from, "legacy_ts");
751 assert_eq!(diff.rename_candidates[0].to, "created_at");
752 assert_eq!(diff.rename_candidates[0].confidence, RenameConfidence::High);
753 }
754
755 #[test]
756 fn rename_candidate_low_confidence_constraints_differ() {
757 let current = vec![declared("legacy", "TEXT", false)];
758 let target = vec![target("renamed", "TEXT", true)]; let diff = compute_column_diff("t", ¤t, &target);
760 assert_eq!(diff.rename_candidates.len(), 1);
761 assert_eq!(diff.rename_candidates[0].confidence, RenameConfidence::Low);
762 }
763
764 #[test]
765 fn no_rename_when_type_differs() {
766 let current = vec![declared("legacy", "TEXT", false)];
767 let target = vec![target("renamed", "INTEGER", false)];
768 let diff = compute_column_diff("t", ¤t, &target);
769 assert!(diff.rename_candidates.is_empty());
770 }
771
772 #[test]
773 fn legacy_contract_without_sql_type_falls_back_to_data_type() {
774 let mut c = declared("id", "TEXT", true);
776 c.sql_type = None;
777 let current = vec![c];
778 let target = vec![target("id", "TEXT", true)];
779 let diff = compute_column_diff("users", ¤t, &target);
780 assert!(!diff.drifted, "legacy data_type should match TEXT target");
781 }
782
783 #[test]
784 fn format_sql_output_shape() {
785 let current = vec![declared("id", "TEXT", true)];
786 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
787 let diff = compute_column_diff("users", ¤t, &target);
788 let sql = format_as_sql(&diff);
789 assert!(sql.contains("-- EXPLAIN ALTER FOR users"));
790 assert!(sql.contains("ALTER TABLE users ADD COLUMN name TEXT"));
791 }
792
793 #[test]
794 fn format_json_output_shape() {
795 let current = vec![declared("id", "TEXT", true)];
796 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
797 let diff = compute_column_diff("users", ¤t, &target);
798 let json = format_as_json(&diff);
799 assert!(json.contains("\"drifted\": true"));
800 assert!(json.contains("\"add_column\""));
801 assert!(json.contains("\"summary\""));
802 }
803
804 #[test]
805 fn empty_diff_renders_no_drift_marker() {
806 let current = vec![declared("id", "TEXT", true)];
807 let target = vec![target("id", "TEXT", true)];
808 let diff = compute_column_diff("users", ¤t, &target);
809 let sql = format_as_sql(&diff);
810 assert!(sql.contains("-- no drift detected"));
811 }
812}