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        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
428// ────────────────────────────────────────────────────────────────
429// SQL formatter
430// ────────────────────────────────────────────────────────────────
431
432/// Format a `SchemaDiff` as a copy-paste-friendly SQL string.
433///
434/// Layout:
435///
436/// ```text
437/// -- EXPLAIN ALTER FOR <table>
438/// -- N changes detected (A adds, D drops, T type changes)
439/// -- rename candidates: R
440/// ALTER TABLE <table> ADD COLUMN <col> <type>;
441/// ALTER TABLE <table> DROP COLUMN <col>;
442/// -- hint: `from` -> `to` could be a rename (confidence: ..., basis: ...)
443/// ```
444///
445/// When the diff is empty ("no drift"), the function returns
446/// just the `-- ` header so clients can grep for "0 changes".
447pub 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                // No native ALTER COLUMN ... TYPE in reddb yet,
484                // so emit DROP + ADD with a comment that the
485                // column was a type change.
486                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
517/// Render a column's type modifier suffix for inline SQL.
518/// Falls back to the legacy `data_type` string when
519/// `sql_type` is absent.
520fn 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
550// ────────────────────────────────────────────────────────────────
551// JSON formatter
552// ────────────────────────────────────────────────────────────────
553
554/// Format a `SchemaDiff` as a structured JSON string. Hand-
555/// rolled emitter so this module stays free of `serde_json`
556/// — reddb's existing `crate::serde_json` module provides
557/// the tiny JSON writer the rest of the codebase uses, but
558/// here we just produce text directly because the schema is
559/// small and stable.
560pub 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
655/// Minimal JSON string escaper — handles `"` and `\` and
656/// control characters. Inline because reddb avoids dragging
657/// `serde_json` into the runtime dep graph.
658fn 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", &current, &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", &current, &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", &current, &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", &current, &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", &current, &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", &current, &target);
781        // Both DROP + ADD are still emitted.
782        assert_eq!(diff.summary.add_columns, 1);
783        assert_eq!(diff.summary.drop_columns, 1);
784        // Plus a single rename hint at high confidence (defaults match → High).
785        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)]; // not_null differs
795        let diff = compute_column_diff("t", &current, &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", &current, &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        // Current contract carries `sql_type: None` (legacy table).
811        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", &current, &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", &current, &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", &current, &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", &current, &target);
845        let sql = format_as_sql(&diff);
846        assert!(sql.contains("-- no drift detected"));
847    }
848}