Skip to main content

sntl_migrate/diff/
compare.rs

1use sntl_schema::schema::{Column, Schema, Table};
2
3/// All structural diffs between two schemas. Ordering is meaningful for
4/// emit: dependencies first (CREATE TABLE before its columns get touched).
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum Change {
7    AddTable(Table),
8    DropTable {
9        name: String,
10    },
11    AddColumn {
12        table: String,
13        column: Column,
14    },
15    DropColumn {
16        table: String,
17        column: String,
18    },
19    AlterColumnType {
20        table: String,
21        column: String,
22        from: String,
23        to: String,
24    },
25    AlterColumnNullable {
26        table: String,
27        column: String,
28        to: bool,
29    },
30    AlterColumnDefault {
31        table: String,
32        column: String,
33        to: Option<String>,
34    },
35    AddPrimaryKey {
36        table: String,
37        columns: Vec<String>,
38    },
39    DropPrimaryKey {
40        table: String,
41    },
42    AddUnique {
43        table: String,
44        columns: Vec<String>,
45    },
46    DropUnique {
47        table: String,
48        columns: Vec<String>,
49    },
50}
51
52/// Compute `target_state` - `current_state` in terms of executable Changes.
53///
54/// `cache` = the desired state (committed `.sentinel/schema.toml`).
55/// `live`  = what the DB currently shows.
56///
57/// FK changes are **out of v0.3 scope** — `pull_schema` doesn't populate them.
58pub fn compare(cache: &Schema, live: &Schema) -> Vec<Change> {
59    let mut out = Vec::new();
60
61    for t in &cache.tables {
62        if live.find_table(&t.name).is_none() {
63            out.push(Change::AddTable(t.clone()));
64        }
65    }
66    for t in &live.tables {
67        if cache.find_table(&t.name).is_none() {
68            out.push(Change::DropTable {
69                name: t.name.clone(),
70            });
71        }
72    }
73    for cache_t in &cache.tables {
74        let Some(live_t) = live.find_table(&cache_t.name) else {
75            continue;
76        };
77        diff_columns(cache_t, live_t, &mut out);
78        diff_pk(cache_t, live_t, &mut out);
79        diff_unique(cache_t, live_t, &mut out);
80    }
81
82    out
83}
84
85fn diff_columns(cache_t: &Table, live_t: &Table, out: &mut Vec<Change>) {
86    for c in &cache_t.columns {
87        if live_t.columns.iter().any(|lc| lc.name == c.name) {
88            continue;
89        }
90        out.push(Change::AddColumn {
91            table: cache_t.name.clone(),
92            column: c.clone(),
93        });
94    }
95    for c in &live_t.columns {
96        if cache_t.columns.iter().any(|cc| cc.name == c.name) {
97            continue;
98        }
99        out.push(Change::DropColumn {
100            table: cache_t.name.clone(),
101            column: c.name.clone(),
102        });
103    }
104    for cc in &cache_t.columns {
105        let Some(lc) = live_t.columns.iter().find(|lc| lc.name == cc.name) else {
106            continue;
107        };
108        if cc.pg_type.pg_type != lc.pg_type.pg_type {
109            out.push(Change::AlterColumnType {
110                table: cache_t.name.clone(),
111                column: cc.name.clone(),
112                from: lc.pg_type.pg_type.clone(),
113                to: cc.pg_type.pg_type.clone(),
114            });
115        }
116        if cc.nullable != lc.nullable {
117            out.push(Change::AlterColumnNullable {
118                table: cache_t.name.clone(),
119                column: cc.name.clone(),
120                to: cc.nullable,
121            });
122        }
123        if cc.default != lc.default {
124            out.push(Change::AlterColumnDefault {
125                table: cache_t.name.clone(),
126                column: cc.name.clone(),
127                to: cc.default.clone(),
128            });
129        }
130    }
131}
132
133fn diff_pk(cache_t: &Table, live_t: &Table, out: &mut Vec<Change>) {
134    let cache_pk: Vec<String> = cache_t
135        .columns
136        .iter()
137        .filter(|c| c.primary_key)
138        .map(|c| c.name.clone())
139        .collect();
140    let live_pk: Vec<String> = live_t
141        .columns
142        .iter()
143        .filter(|c| c.primary_key)
144        .map(|c| c.name.clone())
145        .collect();
146    match (cache_pk.is_empty(), live_pk.is_empty()) {
147        (false, true) => out.push(Change::AddPrimaryKey {
148            table: cache_t.name.clone(),
149            columns: cache_pk,
150        }),
151        (true, false) => out.push(Change::DropPrimaryKey {
152            table: cache_t.name.clone(),
153        }),
154        (false, false) if cache_pk != live_pk => {
155            out.push(Change::DropPrimaryKey {
156                table: cache_t.name.clone(),
157            });
158            out.push(Change::AddPrimaryKey {
159                table: cache_t.name.clone(),
160                columns: cache_pk,
161            });
162        }
163        _ => {}
164    }
165}
166
167fn diff_unique(cache_t: &Table, live_t: &Table, out: &mut Vec<Change>) {
168    for cc in &cache_t.columns {
169        let lc = live_t.columns.iter().find(|lc| lc.name == cc.name);
170        match (cc.unique, lc.map(|lc| lc.unique)) {
171            (true, Some(false)) | (true, None) => {
172                out.push(Change::AddUnique {
173                    table: cache_t.name.clone(),
174                    columns: vec![cc.name.clone()],
175                });
176            }
177            (false, Some(true)) => {
178                out.push(Change::DropUnique {
179                    table: cache_t.name.clone(),
180                    columns: vec![cc.name.clone()],
181                });
182            }
183            _ => {}
184        }
185    }
186}