1use std::collections::HashMap;
46
47use crate::physical::DeclaredColumnContract;
48use reddb_rql::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: Box<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: Box::new(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.generated.as_ref().map(|expression| expression.source())
246 != t.generated.as_ref().map(|expression| expression.source())
247 || c.check.as_ref().map(|expression| expression.source())
248 != t.check.as_ref().map(|expression| expression.source())
249 {
250 return false;
251 }
252 if c.not_null != t.not_null
253 || c.unique != t.unique
254 || c.primary_key != t.primary_key
255 || c.compress != t.compress
256 {
257 return false;
258 }
259
260 if normalize_default(&c.default) != normalize_default(&t.default) {
262 return false;
263 }
264
265 if c.enum_variants != t.enum_variants {
267 return false;
268 }
269
270 if c.array_element != t.array_element {
272 return false;
273 }
274
275 if c.decimal_precision != t.decimal_precision {
277 return false;
278 }
279
280 true
281}
282
283fn normalize_default(d: &Option<String>) -> Option<String> {
287 let s = d.as_ref()?;
288 let trimmed = s.trim();
289 if trimmed.is_empty() {
290 return None;
291 }
292 let stripped = if (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
293 || (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
294 {
295 &trimmed[1..trimmed.len() - 1]
296 } else {
297 trimmed
298 };
299 Some(stripped.to_string())
300}
301
302fn detect_rename_candidates(
311 drops: &[&DeclaredColumnContract],
312 adds: &[&CreateColumnDef],
313) -> Vec<RenameCandidate> {
314 let mut candidates = Vec::new();
315 let mut taken_adds: Vec<bool> = vec![false; adds.len()];
316
317 for drop_col in drops {
318 let mut best: Option<(usize, RenameConfidence, &'static str)> = None;
322 for (i, add_col) in adds.iter().enumerate() {
323 if taken_adds[i] {
324 continue;
325 }
326 let pair_score = score_rename_pair(drop_col, add_col);
327 if let Some((conf, basis)) = pair_score {
328 let better = match (&best, conf) {
329 (None, _) => true,
330 (Some((_, prev, _)), new) => confidence_rank(new) > confidence_rank(*prev),
331 };
332 if better {
333 best = Some((i, conf, basis));
334 }
335 }
336 }
337
338 if let Some((idx, conf, basis)) = best {
339 taken_adds[idx] = true;
340 candidates.push(RenameCandidate {
341 from: drop_col.name.clone(),
342 to: adds[idx].name.clone(),
343 confidence: conf,
344 basis,
345 });
346 }
347 }
348
349 candidates
350}
351
352fn confidence_rank(c: RenameConfidence) -> u8 {
353 match c {
354 RenameConfidence::Low => 1,
355 RenameConfidence::Medium => 2,
356 RenameConfidence::High => 3,
357 }
358}
359
360fn score_rename_pair(
365 drop_col: &DeclaredColumnContract,
366 add_col: &CreateColumnDef,
367) -> Option<(RenameConfidence, &'static str)> {
368 let type_match = match drop_col.sql_type.as_ref() {
370 Some(cur) => *cur == add_col.sql_type,
371 None => drop_col.data_type.eq_ignore_ascii_case(&add_col.data_type),
372 };
373 if !type_match {
374 return None;
375 }
376
377 let constraints_match = drop_col.not_null == add_col.not_null
379 && drop_col.unique == add_col.unique
380 && drop_col.primary_key == add_col.primary_key
381 && drop_col.compress == add_col.compress;
382
383 if constraints_match
384 && normalize_default(&drop_col.default) == normalize_default(&add_col.default)
385 {
386 return Some((RenameConfidence::High, "type_match+constraints+default"));
387 }
388
389 if constraints_match {
391 return Some((RenameConfidence::Medium, "type_match+constraints"));
392 }
393
394 Some((RenameConfidence::Low, "type_match"))
396}
397
398fn declared_column_contract_from_create(column: &CreateColumnDef) -> DeclaredColumnContract {
409 DeclaredColumnContract {
410 name: column.name.clone(),
411 data_type: column.data_type.clone(),
412 sql_type: Some(column.sql_type.clone()),
413 not_null: column.not_null,
414 default: column.default.clone(),
415 generated: column.generated.clone(),
416 check: column.check.clone(),
417 compress: column.compress,
418 unique: column.unique,
419 primary_key: column.primary_key,
420 enum_variants: column.enum_variants.clone(),
421 array_element: column.array_element.clone(),
422 decimal_precision: column.decimal_precision,
423 }
424}
425
426pub fn format_as_sql(diff: &SchemaDiff) -> String {
446 let mut out = String::new();
447 out.push_str(&format!("-- EXPLAIN ALTER FOR {}\n", diff.table));
448 let total = diff.summary.add_columns + diff.summary.drop_columns + diff.summary.type_changes;
449 out.push_str(&format!(
450 "-- {} changes detected ({} adds, {} drops, {} type changes)\n",
451 total, diff.summary.add_columns, diff.summary.drop_columns, diff.summary.type_changes
452 ));
453 if !diff.rename_candidates.is_empty() {
454 out.push_str(&format!(
455 "-- rename candidates: {}\n",
456 diff.rename_candidates.len()
457 ));
458 }
459 if !diff.drifted {
460 out.push_str("-- no drift detected\n");
461 return out;
462 }
463
464 for op in &diff.operations {
465 match op {
466 DiffOp::AddColumn(col) => {
467 out.push_str(&format!(
468 "ALTER TABLE {} ADD COLUMN {} {};\n",
469 diff.table,
470 col.name,
471 render_column_type(col)
472 ));
473 }
474 DiffOp::DropColumn(name) => {
475 out.push_str(&format!(
476 "ALTER TABLE {} DROP COLUMN {};\n",
477 diff.table, name
478 ));
479 }
480 DiffOp::TypeChange { name, to, .. } => {
481 out.push_str(&format!(
485 "-- type change on `{}`: emitting DROP + ADD\n",
486 name
487 ));
488 out.push_str(&format!(
489 "ALTER TABLE {} DROP COLUMN {};\n",
490 diff.table, name
491 ));
492 out.push_str(&format!(
493 "ALTER TABLE {} ADD COLUMN {} {};\n",
494 diff.table,
495 name,
496 render_column_type(to)
497 ));
498 }
499 }
500 }
501
502 for cand in &diff.rename_candidates {
503 out.push_str(&format!(
504 "-- hint: `{}` -> `{}` could be a rename (confidence: {}, basis: {})\n",
505 cand.from,
506 cand.to,
507 cand.confidence.as_str(),
508 cand.basis
509 ));
510 }
511
512 out
513}
514
515fn render_column_type(col: &DeclaredColumnContract) -> String {
519 let base = match col.sql_type.as_ref() {
520 Some(t) => t.to_string(),
521 None => col.data_type.clone(),
522 };
523 let mut out = base;
524 if let Some(expression) = &col.generated {
525 out.push_str(&format!(
526 " GENERATED ALWAYS AS ({}) STORED",
527 expression.source()
528 ));
529 }
530 if let Some(expression) = &col.check {
531 out.push_str(&format!(" CHECK ({})", expression.source()));
532 }
533 if col.primary_key {
534 out.push_str(" PRIMARY KEY");
535 }
536 if col.not_null && !col.primary_key {
537 out.push_str(" NOT NULL");
538 }
539 if col.unique && !col.primary_key {
540 out.push_str(" UNIQUE");
541 }
542 if let Some(default) = col.default.as_ref() {
543 out.push_str(&format!(" DEFAULT {}", default));
544 }
545 out
546}
547
548pub fn format_as_json(diff: &SchemaDiff) -> String {
559 let sql = format_as_sql(diff);
560 let mut out = String::with_capacity(512);
561 out.push_str("{\n");
562 out.push_str(&format!(" \"table\": {},\n", json_string(&diff.table)));
563 out.push_str(&format!(" \"drifted\": {},\n", diff.drifted));
564 out.push_str(&format!(" \"sql\": {},\n", json_string(&sql)));
565 out.push_str(" \"operations\": [\n");
566 for (i, op) in diff.operations.iter().enumerate() {
567 let comma = if i + 1 < diff.operations.len() {
568 ","
569 } else {
570 ""
571 };
572 out.push_str(&format!(" {}{}\n", json_op(op), comma));
573 }
574 out.push_str(" ],\n");
575 out.push_str(" \"rename_candidates\": [\n");
576 for (i, cand) in diff.rename_candidates.iter().enumerate() {
577 let comma = if i + 1 < diff.rename_candidates.len() {
578 ","
579 } else {
580 ""
581 };
582 out.push_str(&format!(" {}{}\n", json_rename(cand), comma));
583 }
584 out.push_str(" ],\n");
585 out.push_str(&format!(
586 " \"summary\": {{ \"add_columns\": {}, \"drop_columns\": {}, \"type_changes\": {}, \"rename_candidates\": {} }}\n",
587 diff.summary.add_columns,
588 diff.summary.drop_columns,
589 diff.summary.type_changes,
590 diff.summary.rename_candidates
591 ));
592 out.push_str("}\n");
593 out
594}
595
596fn json_op(op: &DiffOp) -> String {
597 match op {
598 DiffOp::AddColumn(col) => format!(
599 "{{ \"op\": \"add_column\", \"column\": {}, \"reason\": \"column present in target but not in current contract\" }}",
600 json_column(col)
601 ),
602 DiffOp::DropColumn(name) => format!(
603 "{{ \"op\": \"drop_column\", \"name\": {}, \"reason\": \"column present in current contract but not in target\" }}",
604 json_string(name)
605 ),
606 DiffOp::TypeChange { name, from, to } => format!(
607 "{{ \"op\": \"type_change\", \"name\": {}, \"from\": {}, \"to\": {} }}",
608 json_string(name),
609 json_column(from),
610 json_column(to)
611 ),
612 }
613}
614
615fn json_column(col: &DeclaredColumnContract) -> String {
616 let mut out = String::from("{ ");
617 out.push_str(&format!("\"name\": {}", json_string(&col.name)));
618 let sql_type = col
619 .sql_type
620 .as_ref()
621 .map(|t| t.to_string())
622 .unwrap_or_else(|| col.data_type.clone());
623 out.push_str(&format!(", \"sql_type\": {}", json_string(&sql_type)));
624 out.push_str(&format!(", \"not_null\": {}", col.not_null));
625 out.push_str(&format!(", \"primary_key\": {}", col.primary_key));
626 out.push_str(&format!(", \"unique\": {}", col.unique));
627 if let Some(default) = col.default.as_ref() {
628 out.push_str(&format!(", \"default\": {}", json_string(default)));
629 }
630 for (name, expression) in [("generated", &col.generated), ("check", &col.check)] {
631 if let Some(expression) = expression {
632 out.push_str(&format!(
633 ", {}: {}",
634 json_string(name),
635 json_string(expression.source())
636 ));
637 }
638 }
639 out.push_str(" }");
640 out
641}
642
643fn json_rename(cand: &RenameCandidate) -> String {
644 format!(
645 "{{ \"from\": {}, \"to\": {}, \"confidence\": {}, \"basis\": {} }}",
646 json_string(&cand.from),
647 json_string(&cand.to),
648 json_string(cand.confidence.as_str()),
649 json_string(cand.basis)
650 )
651}
652
653fn json_string(s: &str) -> String {
657 let mut out = String::with_capacity(s.len() + 2);
658 out.push('"');
659 for ch in s.chars() {
660 match ch {
661 '"' => out.push_str("\\\""),
662 '\\' => out.push_str("\\\\"),
663 '\n' => out.push_str("\\n"),
664 '\r' => out.push_str("\\r"),
665 '\t' => out.push_str("\\t"),
666 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
667 c => out.push(c),
668 }
669 }
670 out.push('"');
671 out
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use reddb_types::SqlTypeName;
678
679 fn declared(name: &str, sql_type: &str, not_null: bool) -> DeclaredColumnContract {
680 DeclaredColumnContract {
681 name: name.to_string(),
682 data_type: sql_type.to_string(),
683 sql_type: Some(SqlTypeName::new(sql_type)),
684 not_null,
685 default: None,
686 generated: None,
687 check: None,
688 compress: None,
689 unique: false,
690 primary_key: false,
691 enum_variants: Vec::new(),
692 array_element: None,
693 decimal_precision: None,
694 }
695 }
696
697 fn target(name: &str, sql_type: &str, not_null: bool) -> CreateColumnDef {
698 CreateColumnDef {
699 name: name.to_string(),
700 data_type: sql_type.to_string(),
701 sql_type: SqlTypeName::new(sql_type),
702 not_null,
703 default: None,
704 generated: None,
705 check: None,
706 compress: None,
707 unique: false,
708 primary_key: false,
709 enum_variants: Vec::new(),
710 array_element: None,
711 decimal_precision: None,
712 }
713 }
714
715 #[test]
716 fn diff_identical_columns_returns_empty() {
717 let current = vec![
718 declared("id", "TEXT", true),
719 declared("name", "TEXT", false),
720 ];
721 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
722 let diff = compute_column_diff("users", ¤t, &target);
723 assert!(!diff.drifted);
724 assert!(diff.operations.is_empty());
725 assert_eq!(diff.summary.add_columns, 0);
726 }
727
728 #[test]
729 fn diff_adds_missing_column() {
730 let current = vec![declared("id", "TEXT", true)];
731 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
732 let diff = compute_column_diff("users", ¤t, &target);
733 assert!(diff.drifted);
734 assert_eq!(diff.summary.add_columns, 1);
735 assert_eq!(diff.summary.drop_columns, 0);
736 assert!(matches!(&diff.operations[0], DiffOp::AddColumn(_)));
737 }
738
739 #[test]
740 fn diff_drops_extra_column() {
741 let current = vec![
742 declared("id", "TEXT", true),
743 declared("legacy", "TEXT", false),
744 ];
745 let target = vec![target("id", "TEXT", true)];
746 let diff = compute_column_diff("users", ¤t, &target);
747 assert!(diff.drifted);
748 assert_eq!(diff.summary.add_columns, 0);
749 assert_eq!(diff.summary.drop_columns, 1);
750 assert!(matches!(&diff.operations[0], DiffOp::DropColumn(_)));
751 }
752
753 #[test]
754 fn diff_detects_type_change() {
755 let current = vec![declared("age", "TEXT", false)];
756 let target = vec![target("age", "INTEGER", false)];
757 let diff = compute_column_diff("users", ¤t, &target);
758 assert_eq!(diff.summary.type_changes, 1);
759 assert_eq!(diff.summary.add_columns, 0);
760 assert_eq!(diff.summary.drop_columns, 0);
761 }
762
763 #[test]
764 fn diff_detects_not_null_change() {
765 let current = vec![declared("email", "TEXT", false)];
766 let target = vec![target("email", "TEXT", true)];
767 let diff = compute_column_diff("users", ¤t, &target);
768 assert_eq!(diff.summary.type_changes, 1);
769 }
770
771 #[test]
772 fn rename_candidate_medium_confidence() {
773 let current = vec![declared("legacy_ts", "TIMESTAMP", false)];
774 let target = vec![target("created_at", "TIMESTAMP", false)];
775 let diff = compute_column_diff("events", ¤t, &target);
776 assert_eq!(diff.summary.add_columns, 1);
778 assert_eq!(diff.summary.drop_columns, 1);
779 assert_eq!(diff.rename_candidates.len(), 1);
781 assert_eq!(diff.rename_candidates[0].from, "legacy_ts");
782 assert_eq!(diff.rename_candidates[0].to, "created_at");
783 assert_eq!(diff.rename_candidates[0].confidence, RenameConfidence::High);
784 }
785
786 #[test]
787 fn rename_candidate_low_confidence_constraints_differ() {
788 let current = vec![declared("legacy", "TEXT", false)];
789 let target = vec![target("renamed", "TEXT", true)]; let diff = compute_column_diff("t", ¤t, &target);
791 assert_eq!(diff.rename_candidates.len(), 1);
792 assert_eq!(diff.rename_candidates[0].confidence, RenameConfidence::Low);
793 }
794
795 #[test]
796 fn no_rename_when_type_differs() {
797 let current = vec![declared("legacy", "TEXT", false)];
798 let target = vec![target("renamed", "INTEGER", false)];
799 let diff = compute_column_diff("t", ¤t, &target);
800 assert!(diff.rename_candidates.is_empty());
801 }
802
803 #[test]
804 fn legacy_contract_without_sql_type_falls_back_to_data_type() {
805 let mut c = declared("id", "TEXT", true);
807 c.sql_type = None;
808 let current = vec![c];
809 let target = vec![target("id", "TEXT", true)];
810 let diff = compute_column_diff("users", ¤t, &target);
811 assert!(!diff.drifted, "legacy data_type should match TEXT target");
812 }
813
814 #[test]
815 fn format_sql_output_shape() {
816 let current = vec![declared("id", "TEXT", true)];
817 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
818 let diff = compute_column_diff("users", ¤t, &target);
819 let sql = format_as_sql(&diff);
820 assert!(sql.contains("-- EXPLAIN ALTER FOR users"));
821 assert!(sql.contains("ALTER TABLE users ADD COLUMN name TEXT"));
822 }
823
824 #[test]
825 fn format_json_output_shape() {
826 let current = vec![declared("id", "TEXT", true)];
827 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
828 let diff = compute_column_diff("users", ¤t, &target);
829 let json = format_as_json(&diff);
830 assert!(json.contains("\"drifted\": true"));
831 assert!(json.contains("\"add_column\""));
832 assert!(json.contains("\"summary\""));
833 }
834
835 #[test]
836 fn empty_diff_renders_no_drift_marker() {
837 let current = vec![declared("id", "TEXT", true)];
838 let target = vec![target("id", "TEXT", true)];
839 let diff = compute_column_diff("users", ¤t, &target);
840 let sql = format_as_sql(&diff);
841 assert!(sql.contains("-- no drift detected"));
842 }
843}