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 description: None,
412 description_generated: false,
413 data_type: column.data_type.clone(),
414 sql_type: Some(column.sql_type.clone()),
415 not_null: column.not_null,
416 default: column.default.clone(),
417 generated: column.generated.clone(),
418 check: column.check.clone(),
419 compress: column.compress,
420 unique: column.unique,
421 primary_key: column.primary_key,
422 enum_variants: column.enum_variants.clone(),
423 array_element: column.array_element.clone(),
424 decimal_precision: column.decimal_precision,
425 }
426}
427
428pub fn format_as_sql(diff: &SchemaDiff) -> String {
448 let mut out = String::new();
449 out.push_str(&format!("-- EXPLAIN ALTER FOR {}\n", diff.table));
450 let total = diff.summary.add_columns + diff.summary.drop_columns + diff.summary.type_changes;
451 out.push_str(&format!(
452 "-- {} changes detected ({} adds, {} drops, {} type changes)\n",
453 total, diff.summary.add_columns, diff.summary.drop_columns, diff.summary.type_changes
454 ));
455 if !diff.rename_candidates.is_empty() {
456 out.push_str(&format!(
457 "-- rename candidates: {}\n",
458 diff.rename_candidates.len()
459 ));
460 }
461 if !diff.drifted {
462 out.push_str("-- no drift detected\n");
463 return out;
464 }
465
466 for op in &diff.operations {
467 match op {
468 DiffOp::AddColumn(col) => {
469 out.push_str(&format!(
470 "ALTER TABLE {} ADD COLUMN {} {};\n",
471 diff.table,
472 col.name,
473 render_column_type(col)
474 ));
475 }
476 DiffOp::DropColumn(name) => {
477 out.push_str(&format!(
478 "ALTER TABLE {} DROP COLUMN {};\n",
479 diff.table, name
480 ));
481 }
482 DiffOp::TypeChange { name, to, .. } => {
483 out.push_str(&format!(
487 "-- type change on `{}`: emitting DROP + ADD\n",
488 name
489 ));
490 out.push_str(&format!(
491 "ALTER TABLE {} DROP COLUMN {};\n",
492 diff.table, name
493 ));
494 out.push_str(&format!(
495 "ALTER TABLE {} ADD COLUMN {} {};\n",
496 diff.table,
497 name,
498 render_column_type(to)
499 ));
500 }
501 }
502 }
503
504 for cand in &diff.rename_candidates {
505 out.push_str(&format!(
506 "-- hint: `{}` -> `{}` could be a rename (confidence: {}, basis: {})\n",
507 cand.from,
508 cand.to,
509 cand.confidence.as_str(),
510 cand.basis
511 ));
512 }
513
514 out
515}
516
517fn render_column_type(col: &DeclaredColumnContract) -> String {
521 let base = match col.sql_type.as_ref() {
522 Some(t) => t.to_string(),
523 None => col.data_type.clone(),
524 };
525 let mut out = base;
526 if let Some(expression) = &col.generated {
527 out.push_str(&format!(
528 " GENERATED ALWAYS AS ({}) STORED",
529 expression.source()
530 ));
531 }
532 if let Some(expression) = &col.check {
533 out.push_str(&format!(" CHECK ({})", expression.source()));
534 }
535 if col.primary_key {
536 out.push_str(" PRIMARY KEY");
537 }
538 if col.not_null && !col.primary_key {
539 out.push_str(" NOT NULL");
540 }
541 if col.unique && !col.primary_key {
542 out.push_str(" UNIQUE");
543 }
544 if let Some(default) = col.default.as_ref() {
545 out.push_str(&format!(" DEFAULT {}", default));
546 }
547 out
548}
549
550pub fn format_as_json(diff: &SchemaDiff) -> String {
561 let sql = format_as_sql(diff);
562 let mut out = String::with_capacity(512);
563 out.push_str("{\n");
564 out.push_str(&format!(" \"table\": {},\n", json_string(&diff.table)));
565 out.push_str(&format!(" \"drifted\": {},\n", diff.drifted));
566 out.push_str(&format!(" \"sql\": {},\n", json_string(&sql)));
567 out.push_str(" \"operations\": [\n");
568 for (i, op) in diff.operations.iter().enumerate() {
569 let comma = if i + 1 < diff.operations.len() {
570 ","
571 } else {
572 ""
573 };
574 out.push_str(&format!(" {}{}\n", json_op(op), comma));
575 }
576 out.push_str(" ],\n");
577 out.push_str(" \"rename_candidates\": [\n");
578 for (i, cand) in diff.rename_candidates.iter().enumerate() {
579 let comma = if i + 1 < diff.rename_candidates.len() {
580 ","
581 } else {
582 ""
583 };
584 out.push_str(&format!(" {}{}\n", json_rename(cand), comma));
585 }
586 out.push_str(" ],\n");
587 out.push_str(&format!(
588 " \"summary\": {{ \"add_columns\": {}, \"drop_columns\": {}, \"type_changes\": {}, \"rename_candidates\": {} }}\n",
589 diff.summary.add_columns,
590 diff.summary.drop_columns,
591 diff.summary.type_changes,
592 diff.summary.rename_candidates
593 ));
594 out.push_str("}\n");
595 out
596}
597
598fn json_op(op: &DiffOp) -> String {
599 match op {
600 DiffOp::AddColumn(col) => format!(
601 "{{ \"op\": \"add_column\", \"column\": {}, \"reason\": \"column present in target but not in current contract\" }}",
602 json_column(col)
603 ),
604 DiffOp::DropColumn(name) => format!(
605 "{{ \"op\": \"drop_column\", \"name\": {}, \"reason\": \"column present in current contract but not in target\" }}",
606 json_string(name)
607 ),
608 DiffOp::TypeChange { name, from, to } => format!(
609 "{{ \"op\": \"type_change\", \"name\": {}, \"from\": {}, \"to\": {} }}",
610 json_string(name),
611 json_column(from),
612 json_column(to)
613 ),
614 }
615}
616
617fn json_column(col: &DeclaredColumnContract) -> String {
618 let mut out = String::from("{ ");
619 out.push_str(&format!("\"name\": {}", json_string(&col.name)));
620 let sql_type = col
621 .sql_type
622 .as_ref()
623 .map(|t| t.to_string())
624 .unwrap_or_else(|| col.data_type.clone());
625 out.push_str(&format!(", \"sql_type\": {}", json_string(&sql_type)));
626 out.push_str(&format!(", \"not_null\": {}", col.not_null));
627 out.push_str(&format!(", \"primary_key\": {}", col.primary_key));
628 out.push_str(&format!(", \"unique\": {}", col.unique));
629 if let Some(default) = col.default.as_ref() {
630 out.push_str(&format!(", \"default\": {}", json_string(default)));
631 }
632 for (name, expression) in [("generated", &col.generated), ("check", &col.check)] {
633 if let Some(expression) = expression {
634 out.push_str(&format!(
635 ", {}: {}",
636 json_string(name),
637 json_string(expression.source())
638 ));
639 }
640 }
641 out.push_str(" }");
642 out
643}
644
645fn json_rename(cand: &RenameCandidate) -> String {
646 format!(
647 "{{ \"from\": {}, \"to\": {}, \"confidence\": {}, \"basis\": {} }}",
648 json_string(&cand.from),
649 json_string(&cand.to),
650 json_string(cand.confidence.as_str()),
651 json_string(cand.basis)
652 )
653}
654
655fn json_string(s: &str) -> String {
659 let mut out = String::with_capacity(s.len() + 2);
660 out.push('"');
661 for ch in s.chars() {
662 match ch {
663 '"' => out.push_str("\\\""),
664 '\\' => out.push_str("\\\\"),
665 '\n' => out.push_str("\\n"),
666 '\r' => out.push_str("\\r"),
667 '\t' => out.push_str("\\t"),
668 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
669 c => out.push(c),
670 }
671 }
672 out.push('"');
673 out
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use reddb_types::SqlTypeName;
680
681 fn declared(name: &str, sql_type: &str, not_null: bool) -> DeclaredColumnContract {
682 DeclaredColumnContract {
683 name: name.to_string(),
684 description: None,
685 description_generated: false,
686 data_type: sql_type.to_string(),
687 sql_type: Some(SqlTypeName::new(sql_type)),
688 not_null,
689 default: None,
690 generated: None,
691 check: None,
692 compress: None,
693 unique: false,
694 primary_key: false,
695 enum_variants: Vec::new(),
696 array_element: None,
697 decimal_precision: None,
698 }
699 }
700
701 fn target(name: &str, sql_type: &str, not_null: bool) -> CreateColumnDef {
702 CreateColumnDef {
703 name: name.to_string(),
704 description: None,
705 data_type: sql_type.to_string(),
706 sql_type: SqlTypeName::new(sql_type),
707 not_null,
708 default: None,
709 generated: None,
710 check: None,
711 compress: None,
712 unique: false,
713 primary_key: false,
714 enum_variants: Vec::new(),
715 array_element: None,
716 decimal_precision: None,
717 }
718 }
719
720 #[test]
721 fn diff_identical_columns_returns_empty() {
722 let current = vec![
723 declared("id", "TEXT", true),
724 declared("name", "TEXT", false),
725 ];
726 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
727 let diff = compute_column_diff("users", ¤t, &target);
728 assert!(!diff.drifted);
729 assert!(diff.operations.is_empty());
730 assert_eq!(diff.summary.add_columns, 0);
731 }
732
733 #[test]
734 fn diff_adds_missing_column() {
735 let current = vec![declared("id", "TEXT", true)];
736 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
737 let diff = compute_column_diff("users", ¤t, &target);
738 assert!(diff.drifted);
739 assert_eq!(diff.summary.add_columns, 1);
740 assert_eq!(diff.summary.drop_columns, 0);
741 assert!(matches!(&diff.operations[0], DiffOp::AddColumn(_)));
742 }
743
744 #[test]
745 fn diff_drops_extra_column() {
746 let current = vec![
747 declared("id", "TEXT", true),
748 declared("legacy", "TEXT", false),
749 ];
750 let target = vec![target("id", "TEXT", true)];
751 let diff = compute_column_diff("users", ¤t, &target);
752 assert!(diff.drifted);
753 assert_eq!(diff.summary.add_columns, 0);
754 assert_eq!(diff.summary.drop_columns, 1);
755 assert!(matches!(&diff.operations[0], DiffOp::DropColumn(_)));
756 }
757
758 #[test]
759 fn diff_detects_type_change() {
760 let current = vec![declared("age", "TEXT", false)];
761 let target = vec![target("age", "INTEGER", false)];
762 let diff = compute_column_diff("users", ¤t, &target);
763 assert_eq!(diff.summary.type_changes, 1);
764 assert_eq!(diff.summary.add_columns, 0);
765 assert_eq!(diff.summary.drop_columns, 0);
766 }
767
768 #[test]
769 fn diff_detects_not_null_change() {
770 let current = vec![declared("email", "TEXT", false)];
771 let target = vec![target("email", "TEXT", true)];
772 let diff = compute_column_diff("users", ¤t, &target);
773 assert_eq!(diff.summary.type_changes, 1);
774 }
775
776 #[test]
777 fn rename_candidate_medium_confidence() {
778 let current = vec![declared("legacy_ts", "TIMESTAMP", false)];
779 let target = vec![target("created_at", "TIMESTAMP", false)];
780 let diff = compute_column_diff("events", ¤t, &target);
781 assert_eq!(diff.summary.add_columns, 1);
783 assert_eq!(diff.summary.drop_columns, 1);
784 assert_eq!(diff.rename_candidates.len(), 1);
786 assert_eq!(diff.rename_candidates[0].from, "legacy_ts");
787 assert_eq!(diff.rename_candidates[0].to, "created_at");
788 assert_eq!(diff.rename_candidates[0].confidence, RenameConfidence::High);
789 }
790
791 #[test]
792 fn rename_candidate_low_confidence_constraints_differ() {
793 let current = vec![declared("legacy", "TEXT", false)];
794 let target = vec![target("renamed", "TEXT", true)]; let diff = compute_column_diff("t", ¤t, &target);
796 assert_eq!(diff.rename_candidates.len(), 1);
797 assert_eq!(diff.rename_candidates[0].confidence, RenameConfidence::Low);
798 }
799
800 #[test]
801 fn no_rename_when_type_differs() {
802 let current = vec![declared("legacy", "TEXT", false)];
803 let target = vec![target("renamed", "INTEGER", false)];
804 let diff = compute_column_diff("t", ¤t, &target);
805 assert!(diff.rename_candidates.is_empty());
806 }
807
808 #[test]
809 fn legacy_contract_without_sql_type_falls_back_to_data_type() {
810 let mut c = declared("id", "TEXT", true);
812 c.sql_type = None;
813 let current = vec![c];
814 let target = vec![target("id", "TEXT", true)];
815 let diff = compute_column_diff("users", ¤t, &target);
816 assert!(!diff.drifted, "legacy data_type should match TEXT target");
817 }
818
819 #[test]
820 fn format_sql_output_shape() {
821 let current = vec![declared("id", "TEXT", true)];
822 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
823 let diff = compute_column_diff("users", ¤t, &target);
824 let sql = format_as_sql(&diff);
825 assert!(sql.contains("-- EXPLAIN ALTER FOR users"));
826 assert!(sql.contains("ALTER TABLE users ADD COLUMN name TEXT"));
827 }
828
829 #[test]
830 fn format_json_output_shape() {
831 let current = vec![declared("id", "TEXT", true)];
832 let target = vec![target("id", "TEXT", true), target("name", "TEXT", false)];
833 let diff = compute_column_diff("users", ¤t, &target);
834 let json = format_as_json(&diff);
835 assert!(json.contains("\"drifted\": true"));
836 assert!(json.contains("\"add_column\""));
837 assert!(json.contains("\"summary\""));
838 }
839
840 #[test]
841 fn empty_diff_renders_no_drift_marker() {
842 let current = vec![declared("id", "TEXT", true)];
843 let target = vec![target("id", "TEXT", true)];
844 let diff = compute_column_diff("users", ¤t, &target);
845 let sql = format_as_sql(&diff);
846 assert!(sql.contains("-- no drift detected"));
847 }
848}