Skip to main content

reddb_server/runtime/
schema_diff.rs

1//! Schema diff engine — `EXPLAIN ALTER FOR` runtime.
2//!
3//! Computes the set of `ALTER TABLE` operations that would
4//! close the gap between an existing `CollectionContract` and
5//! the column shape of a new `CreateTableQuery`. Used by the
6//! `EXPLAIN ALTER FOR` SQL command exposed at the parser /
7//! executor boundary.
8//!
9//! Pure logic — zero side effects, no DB access. The executor
10//! loads the current contract, calls `compute_column_diff`,
11//! and formats the result via `format_as_sql` /
12//! `format_as_json`.
13//!
14//! ## Design highlights
15//!
16//! - **Equivalence check matches `apply_alter_operations_to_contract`'s
17//!   semantics** so the round-trip property holds: applying the
18//!   diff to a clone of `current` produces a contract byte-equal
19//!   to the target.
20//! - **`sql_type` is `Option<SqlTypeName>`** in the live contract
21//!   (legacy tables don't carry it). When the current side has
22//!   `None`, we fall back to comparing the legacy `data_type`
23//!   string instead of declaring everything a TypeChange.
24//! - **Rename detection is consultative.** Even when the heuristic
25//!   is high-confidence, the SQL output still emits `DROP + ADD`
26//!   plus a `-- hint:` comment. Only a human (or a client with
27//!   more context) confirms.
28//! - **Three confidence tiers** explicit:
29//!   - `High`   → `sql_type` + every constraint + `default` match
30//!   - `Medium` → `sql_type` + every constraint match
31//!   - `Low`    → only `sql_type` matches (constraints differ)
32//!     Lower than `Low` produces no candidate.
33//!
34//! ## Out of scope (v1)
35//!
36//! - Indexes (`CREATE INDEX`) — not in `CollectionContract`.
37//! - Constraint-only changes (e.g. `NOT NULL` added with the
38//!   same type) — folded into `TypeChange` for now.
39//! - `default_ttl_ms`, `context_index_fields`, `timestamps`
40//!   from `CreateTableQuery` — ignored. Reserved for v2 once
41//!   the corresponding `AlterOperation` variants exist.
42//! - Constraint normalisation (e.g. `'foo'` vs `foo`) — best-
43//!   effort string compare with leading/trailing-quote strip.
44
45use std::collections::HashMap;
46
47use crate::physical::DeclaredColumnContract;
48use reddb_rql::ast::CreateColumnDef;
49
50/// Aggregate result of a column-level schema diff.
51#[derive(Debug, Clone)]
52pub struct SchemaDiff {
53    pub table: String,
54    /// True when at least one operation is required to make the
55    /// current contract match the target.
56    pub drifted: bool,
57    pub operations: Vec<DiffOp>,
58    pub rename_candidates: Vec<RenameCandidate>,
59    pub summary: DiffSummary,
60}
61
62/// One operation in a schema diff.
63#[derive(Debug, Clone)]
64pub enum DiffOp {
65    /// Column present in target but not in current.
66    AddColumn(DeclaredColumnContract),
67    /// Column present in current but not in target.
68    DropColumn(String),
69    /// Same name, different shape — emitted as a single
70    /// `TypeChange` so callers can render `DROP + ADD` or a
71    /// future `ALTER COLUMN ... TYPE ...` form.
72    TypeChange {
73        name: String,
74        from: DeclaredColumnContract,
75        to: Box<DeclaredColumnContract>,
76    },
77}
78
79/// Heuristic match between an unpaired `DropColumn` and an
80/// unpaired `AddColumn`. Always advisory — clients decide.
81#[derive(Debug, Clone)]
82pub struct RenameCandidate {
83    pub from: String,
84    pub to: String,
85    pub confidence: RenameConfidence,
86    /// Short tag describing the heuristic that matched the
87    /// pair. Stable for tooling.
88    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/// Per-category counters for the diff. Useful for dashboards
109/// and CI guardrails ("fail the build if a migration would
110/// drop more than N columns").
111#[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
119// ────────────────────────────────────────────────────────────────
120// Core diff entry point
121// ────────────────────────────────────────────────────────────────
122
123/// Compute the column-level diff between a live `current`
124/// contract and a target list of `CreateColumnDef`s parsed
125/// from the embedded `CREATE TABLE` statement.
126pub 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    // Pass 1: walk target → emit Add or TypeChange.
141    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    // Pass 2: walk current → emit Drop for anything not in target.
159    for (name, c) in &current_by_name {
160        if !target_by_name.contains_key(name) {
161            unpaired_drops.push(*c);
162        }
163    }
164
165    // Pass 3: rename detection across unpaired drop ↔ add pairs.
166    // We greedy-match each drop against the best candidate add
167    // by confidence tier (High > Medium > Low). Once paired,
168    // both sides are still emitted as DROP + ADD operations
169    // (rename is consultative — clients decide). We only attach
170    // a rename hint to the output.
171    let rename_candidates = detect_rename_candidates(&unpaired_drops, &unpaired_adds);
172
173    // Emit all unpaired drops + adds as raw DropColumn /
174    // AddColumn operations.
175    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
208// ────────────────────────────────────────────────────────────────
209// Equivalence
210// ────────────────────────────────────────────────────────────────
211
212/// Returns true when a live `current` column and a target
213/// `CreateColumnDef` describe the same column shape.
214///
215/// Comparison rules (must mirror the no-op behavior of
216/// `apply_alter_operations_to_contract`):
217///
218/// 1. **`sql_type`** — primary identity. `current.sql_type` is
219///    `Option`; when `None` (legacy contract), fall back to
220///    comparing the legacy `data_type` string. Both sides
221///    `None` → use `data_type`.
222/// 2. **constraint flags** — `not_null`, `unique`,
223///    `primary_key`, `compress` must all match exactly.
224/// 3. **`default`** — string compare after normalisation
225///    (trim + strip surrounding single quotes).
226/// 4. **enum_variants** — exact ordered Vec match.
227/// 5. **array_element** — string compare.
228/// 6. **decimal_precision** — exact match.
229///
230/// Legacy `data_type` field is intentionally not compared
231/// when both sides have `sql_type` — `data_type` is derived
232/// from `sql_type` and may differ in case or aliasing
233/// without semantic impact.
234pub fn column_equivalent(c: &DeclaredColumnContract, t: &CreateColumnDef) -> bool {
235    // 1. sql_type / data_type equivalence with Option fallback.
236    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    // 2. flags
245    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    // 3. default — normalize before compare.
261    if normalize_default(&c.default) != normalize_default(&t.default) {
262        return false;
263    }
264
265    // 4. enum_variants — exact ordered.
266    if c.enum_variants != t.enum_variants {
267        return false;
268    }
269
270    // 5. array_element — string compare.
271    if c.array_element != t.array_element {
272        return false;
273    }
274
275    // 6. decimal_precision.
276    if c.decimal_precision != t.decimal_precision {
277        return false;
278    }
279
280    true
281}
282
283/// Normalize a default-value string for cross-source comparison.
284/// Strips outer whitespace and a single layer of surrounding
285/// `'…'` or `"…"` quotes. Returns `None` for empty / missing.
286fn 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
302// ────────────────────────────────────────────────────────────────
303// Rename detection
304// ────────────────────────────────────────────────────────────────
305
306/// Walks every (drop, add) cross product and emits a candidate
307/// when the heuristic matches. Greedy: an add can only be
308/// matched against one drop at a time — once paired, we move on
309/// to the next drop. Higher-confidence matches are preferred.
310fn 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        // Find the best (highest-confidence) unpaired add for
319        // this drop. Walk twice so we prefer High over Medium
320        // over Low without N² sort overhead.
321        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
360/// Score a (drop, add) pair as a potential rename. Returns
361/// `None` when the pair fails the minimum bar (sql_type
362/// mismatch). Otherwise returns the strongest tier the pair
363/// qualifies for.
364fn score_rename_pair(
365    drop_col: &DeclaredColumnContract,
366    add_col: &CreateColumnDef,
367) -> Option<(RenameConfidence, &'static str)> {
368    // Minimum bar: sql_type must match.
369    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    // High: type + every constraint + normalized default match.
378    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    // Medium: type + every constraint match (default may differ).
390    if constraints_match {
391        return Some((RenameConfidence::Medium, "type_match+constraints"));
392    }
393
394    // Low: only the type matches.
395    Some((RenameConfidence::Low, "type_match"))
396}
397
398// ────────────────────────────────────────────────────────────────
399// Helpers
400// ────────────────────────────────────────────────────────────────
401
402/// Convert a `CreateColumnDef` (parser AST) into a
403/// `DeclaredColumnContract` (live contract shape). Mirrors the
404/// existing `runtime::impl_ddl::declared_column_contract_from_ddl`
405/// — duplicated here to avoid pulling the whole `impl_ddl`
406/// module into the diff path's import graph. The two functions
407/// must stay in sync; if one grows a field, the other must too.
408fn 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
426// ────────────────────────────────────────────────────────────────
427// SQL formatter
428// ────────────────────────────────────────────────────────────────
429
430/// Format a `SchemaDiff` as a copy-paste-friendly SQL string.
431///
432/// Layout:
433///
434/// ```text
435/// -- EXPLAIN ALTER FOR <table>
436/// -- N changes detected (A adds, D drops, T type changes)
437/// -- rename candidates: R
438/// ALTER TABLE <table> ADD COLUMN <col> <type>;
439/// ALTER TABLE <table> DROP COLUMN <col>;
440/// -- hint: `from` -> `to` could be a rename (confidence: ..., basis: ...)
441/// ```
442///
443/// When the diff is empty ("no drift"), the function returns
444/// just the `-- ` header so clients can grep for "0 changes".
445pub 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                // No native ALTER COLUMN ... TYPE in reddb yet,
482                // so emit DROP + ADD with a comment that the
483                // column was a type change.
484                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
515/// Render a column's type modifier suffix for inline SQL.
516/// Falls back to the legacy `data_type` string when
517/// `sql_type` is absent.
518fn 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
548// ────────────────────────────────────────────────────────────────
549// JSON formatter
550// ────────────────────────────────────────────────────────────────
551
552/// Format a `SchemaDiff` as a structured JSON string. Hand-
553/// rolled emitter so this module stays free of `serde_json`
554/// — reddb's existing `crate::serde_json` module provides
555/// the tiny JSON writer the rest of the codebase uses, but
556/// here we just produce text directly because the schema is
557/// small and stable.
558pub 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
653/// Minimal JSON string escaper — handles `"` and `\` and
654/// control characters. Inline because reddb avoids dragging
655/// `serde_json` into the runtime dep graph.
656fn 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", &current, &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", &current, &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", &current, &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", &current, &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", &current, &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", &current, &target);
776        // Both DROP + ADD are still emitted.
777        assert_eq!(diff.summary.add_columns, 1);
778        assert_eq!(diff.summary.drop_columns, 1);
779        // Plus a single rename hint at high confidence (defaults match → High).
780        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)]; // not_null differs
790        let diff = compute_column_diff("t", &current, &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", &current, &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        // Current contract carries `sql_type: None` (legacy table).
806        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", &current, &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", &current, &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", &current, &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", &current, &target);
840        let sql = format_as_sql(&diff);
841        assert!(sql.contains("-- no drift detected"));
842    }
843}