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 crate::storage::query::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: 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: 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.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    // 3. default — normalize before compare.
254    if normalize_default(&c.default) != normalize_default(&t.default) {
255        return false;
256    }
257
258    // 4. enum_variants — exact ordered.
259    if c.enum_variants != t.enum_variants {
260        return false;
261    }
262
263    // 5. array_element — string compare.
264    if c.array_element != t.array_element {
265        return false;
266    }
267
268    // 6. decimal_precision.
269    if c.decimal_precision != t.decimal_precision {
270        return false;
271    }
272
273    true
274}
275
276/// Normalize a default-value string for cross-source comparison.
277/// Strips outer whitespace and a single layer of surrounding
278/// `'…'` or `"…"` quotes. Returns `None` for empty / missing.
279fn 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
295// ────────────────────────────────────────────────────────────────
296// Rename detection
297// ────────────────────────────────────────────────────────────────
298
299/// Walks every (drop, add) cross product and emits a candidate
300/// when the heuristic matches. Greedy: an add can only be
301/// matched against one drop at a time — once paired, we move on
302/// to the next drop. Higher-confidence matches are preferred.
303fn 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        // Find the best (highest-confidence) unpaired add for
312        // this drop. Walk twice so we prefer High over Medium
313        // over Low without N² sort overhead.
314        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
353/// Score a (drop, add) pair as a potential rename. Returns
354/// `None` when the pair fails the minimum bar (sql_type
355/// mismatch). Otherwise returns the strongest tier the pair
356/// qualifies for.
357fn score_rename_pair(
358    drop_col: &DeclaredColumnContract,
359    add_col: &CreateColumnDef,
360) -> Option<(RenameConfidence, &'static str)> {
361    // Minimum bar: sql_type must match.
362    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    // High: type + every constraint + normalized default match.
371    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    // Medium: type + every constraint match (default may differ).
383    if constraints_match {
384        return Some((RenameConfidence::Medium, "type_match+constraints"));
385    }
386
387    // Low: only the type matches.
388    Some((RenameConfidence::Low, "type_match"))
389}
390
391// ────────────────────────────────────────────────────────────────
392// Helpers
393// ────────────────────────────────────────────────────────────────
394
395/// Convert a `CreateColumnDef` (parser AST) into a
396/// `DeclaredColumnContract` (live contract shape). Mirrors the
397/// existing `runtime::impl_ddl::declared_column_contract_from_ddl`
398/// — duplicated here to avoid pulling the whole `impl_ddl`
399/// module into the diff path's import graph. The two functions
400/// must stay in sync; if one grows a field, the other must too.
401fn 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
417// ────────────────────────────────────────────────────────────────
418// SQL formatter
419// ────────────────────────────────────────────────────────────────
420
421/// Format a `SchemaDiff` as a copy-paste-friendly SQL string.
422///
423/// Layout:
424///
425/// ```text
426/// -- EXPLAIN ALTER FOR <table>
427/// -- N changes detected (A adds, D drops, T type changes)
428/// -- rename candidates: R
429/// ALTER TABLE <table> ADD COLUMN <col> <type>;
430/// ALTER TABLE <table> DROP COLUMN <col>;
431/// -- hint: `from` -> `to` could be a rename (confidence: ..., basis: ...)
432/// ```
433///
434/// When the diff is empty ("no drift"), the function returns
435/// just the `-- ` header so clients can grep for "0 changes".
436pub 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                // No native ALTER COLUMN ... TYPE in reddb yet,
473                // so emit DROP + ADD with a comment that the
474                // column was a type change.
475                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
506/// Render a column's type modifier suffix for inline SQL.
507/// Falls back to the legacy `data_type` string when
508/// `sql_type` is absent.
509fn 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
530// ────────────────────────────────────────────────────────────────
531// JSON formatter
532// ────────────────────────────────────────────────────────────────
533
534/// Format a `SchemaDiff` as a structured JSON string. Hand-
535/// rolled emitter so this module stays free of `serde_json`
536/// — reddb's existing `crate::serde_json` module provides
537/// the tiny JSON writer the rest of the codebase uses, but
538/// here we just produce text directly because the schema is
539/// small and stable.
540pub 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
626/// Minimal JSON string escaper — handles `"` and `\` and
627/// control characters. Inline because reddb avoids dragging
628/// `serde_json` into the runtime dep graph.
629fn 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", &current, &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", &current, &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", &current, &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", &current, &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", &current, &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", &current, &target);
745        // Both DROP + ADD are still emitted.
746        assert_eq!(diff.summary.add_columns, 1);
747        assert_eq!(diff.summary.drop_columns, 1);
748        // Plus a single rename hint at high confidence (defaults match → High).
749        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)]; // not_null differs
759        let diff = compute_column_diff("t", &current, &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", &current, &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        // Current contract carries `sql_type: None` (legacy table).
775        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", &current, &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", &current, &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", &current, &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", &current, &target);
809        let sql = format_as_sql(&diff);
810        assert!(sql.contains("-- no drift detected"));
811    }
812}