1use std::collections::BTreeSet;
12use std::path::Path;
13
14use anyhow::{Context, Result};
15use gize_core::{Field, ModelSpec};
16
17const IMPLICIT_COLUMNS: [&str; 3] = ["id", "created_at", "updated_at"];
20
21#[derive(Debug, Default, PartialEq, Eq)]
23pub struct SchemaDiff {
24 pub added: Vec<Field>,
26 pub dropped: Vec<String>,
29}
30
31impl SchemaDiff {
32 pub fn is_empty(&self) -> bool {
33 self.added.is_empty() && self.dropped.is_empty()
34 }
35}
36
37pub fn diff_model(
43 migrations_dir: &Path,
44 table: &str,
45 model: &ModelSpec,
46) -> Result<Option<SchemaDiff>> {
47 let Some(known) = known_columns(migrations_dir, table)? else {
48 return Ok(None);
49 };
50
51 let declared: BTreeSet<&str> = model.fields.iter().map(|f| f.name.as_str()).collect();
52
53 let added = model
54 .fields
55 .iter()
56 .filter(|f| !known.contains(&f.name))
57 .cloned()
58 .collect();
59
60 let dropped = known
61 .iter()
62 .filter(|c| !declared.contains(c.as_str()))
63 .filter(|c| !IMPLICIT_COLUMNS.contains(&c.as_str()))
64 .cloned()
65 .collect();
66
67 Ok(Some(SchemaDiff { added, dropped }))
68}
69
70pub fn known_columns(migrations_dir: &Path, table: &str) -> Result<Option<BTreeSet<String>>> {
74 if !migrations_dir.exists() {
75 return Ok(None);
76 }
77 let mut columns = BTreeSet::new();
78 let mut saw_create = false;
79
80 let mut files: Vec<_> = std::fs::read_dir(migrations_dir)
82 .with_context(|| format!("reading {}", migrations_dir.display()))?
83 .filter_map(|e| e.ok().map(|e| e.path()))
84 .filter(|p| p.extension().is_some_and(|x| x == "sql"))
85 .collect();
86 files.sort();
87
88 for path in files {
89 let sql = std::fs::read_to_string(&path)
90 .with_context(|| format!("reading {}", path.display()))?;
91 let (created, cols) = columns_in_sql(&sql, table);
92 saw_create |= created;
93 columns.extend(cols);
94 }
95
96 Ok(saw_create.then_some(columns))
97}
98
99fn columns_in_sql(sql: &str, table: &str) -> (bool, BTreeSet<String>) {
103 let mut cols = BTreeSet::new();
104 let mut created = false;
105
106 let needle = format!("CREATE TABLE {table}");
109 if let Some(start) = sql.find(&needle) {
110 let after = &sql[start..];
111 if let Some(open) = after.find('(') {
112 let bytes = after.as_bytes();
113 let mut depth = 0i32;
114 for (i, &b) in bytes.iter().enumerate().skip(open) {
115 match b {
116 b'(' => depth += 1,
117 b')' => {
118 depth -= 1;
119 if depth == 0 {
120 created = true;
121 for line in after[open + 1..i].lines() {
122 if let Some(name) = column_name_of(line) {
123 cols.insert(name);
124 }
125 }
126 break;
127 }
128 }
129 _ => {}
130 }
131 }
132 }
133 }
134
135 let alter = format!("ALTER TABLE {table} ADD COLUMN ");
137 for line in sql.lines() {
138 if let Some(rest) = line.trim().strip_prefix(&alter) {
139 if let Some(name) = rest.split_whitespace().next() {
140 cols.insert(name.trim_matches('"').to_string());
141 }
142 }
143 }
144
145 (created, cols)
146}
147
148fn column_name_of(line: &str) -> Option<String> {
153 let trimmed = line.trim().trim_end_matches(',');
154 let first = trimmed.split_whitespace().next()?;
155 let starts_lower = first.chars().next().is_some_and(|c| c.is_ascii_lowercase());
156 if starts_lower {
157 Some(first.to_string())
158 } else {
159 None
160 }
161}
162
163pub fn alter_sql(table: &str, diff: &SchemaDiff, force: bool) -> String {
170 let mut out = format!("-- Migration: alter {table} (reconcile from gize.toml)\n");
171 out.push_str("-- Generated by `gize make migration` from a model change (see ADR-011).\n\n");
172
173 for f in &diff.added {
174 out.push_str(&format!(
175 "ALTER TABLE {table} ADD COLUMN {name} {ty}; \
176 -- TODO: nullable for safety — backfill, then `ALTER COLUMN {name} SET NOT NULL`\n",
177 name = f.name,
178 ty = f.ty.sql_type(),
179 ));
180 }
181
182 for col in &diff.dropped {
183 if force {
184 out.push_str(&format!("ALTER TABLE {table} DROP COLUMN {col};\n"));
185 } else {
186 out.push_str(&format!(
187 "-- DROP of `{col}` withheld: re-run with --force to emit \
188 `ALTER TABLE {table} DROP COLUMN {col};` (may be a rename — review first)\n"
189 ));
190 }
191 }
192
193 out
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use std::fs;
200
201 fn tmpdir() -> std::path::PathBuf {
202 use std::sync::atomic::{AtomicUsize, Ordering};
203 static COUNTER: AtomicUsize = AtomicUsize::new(0);
204 let dir = std::env::temp_dir().join(format!(
207 "gize-diff-{}-{}",
208 std::process::id(),
209 COUNTER.fetch_add(1, Ordering::Relaxed)
210 ));
211 fs::create_dir_all(&dir).unwrap();
212 dir
213 }
214
215 fn model(fields: &[&str]) -> ModelSpec {
216 ModelSpec::parse(
217 "Post",
218 &fields.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
219 )
220 .unwrap()
221 }
222
223 fn has_executable_drop(sql: &str) -> bool {
225 sql.lines().any(|l| {
226 let l = l.trim_start();
227 !l.starts_with("--") && l.contains("DROP COLUMN")
228 })
229 }
230
231 #[test]
232 fn no_create_migration_yields_none() {
233 let dir = tmpdir();
234 assert!(
235 diff_model(&dir, "posts", &model(&["title:String"]))
236 .unwrap()
237 .is_none()
238 );
239 }
240
241 #[test]
242 fn detects_added_column() {
243 let dir = tmpdir();
244 fs::write(
245 dir.join("001_create_posts.sql"),
246 "CREATE TABLE posts (\n id UUID PRIMARY KEY,\n title TEXT NOT NULL,\n created_at TIMESTAMPTZ NOT NULL,\n updated_at TIMESTAMPTZ NOT NULL\n);\n",
247 )
248 .unwrap();
249
250 let diff = diff_model(&dir, "posts", &model(&["title:String", "views:i32"]))
251 .unwrap()
252 .unwrap();
253 assert_eq!(diff.added.len(), 1);
254 assert_eq!(diff.added[0].name, "views");
255 assert!(diff.dropped.is_empty());
256
257 let sql = alter_sql("posts", &diff, false);
258 assert!(sql.contains("ALTER TABLE posts ADD COLUMN views INTEGER;"));
259 assert!(sql.contains("SET NOT NULL"));
260 }
261
262 #[test]
263 fn parses_columns_despite_parenthesized_defaults() {
264 let dir = tmpdir();
267 fs::write(
268 dir.join("001_create_posts.sql"),
269 "-- Migration: create posts\nCREATE TABLE posts (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n title TEXT NOT NULL,\n body TEXT NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n",
270 )
271 .unwrap();
272
273 let known = known_columns(&dir, "posts").unwrap().unwrap();
274 assert!(known.contains("title"), "title should be parsed");
275 assert!(known.contains("body"), "body should be parsed");
276 assert!(known.contains("updated_at"), "updated_at should be parsed");
277
278 let diff = diff_model(&dir, "posts", &model(&["title:String", "body:String"]))
280 .unwrap()
281 .unwrap();
282 assert!(
283 diff.is_empty(),
284 "matching model should produce no diff, got {diff:?}"
285 );
286 }
287
288 #[test]
289 fn already_migrated_alter_column_is_known() {
290 let dir = tmpdir();
291 fs::write(
292 dir.join("001_create_posts.sql"),
293 "CREATE TABLE posts (\n id UUID PRIMARY KEY,\n title TEXT NOT NULL\n);\n",
294 )
295 .unwrap();
296 fs::write(
297 dir.join("002_alter_posts.sql"),
298 "ALTER TABLE posts ADD COLUMN views INTEGER;\n",
299 )
300 .unwrap();
301
302 let diff = diff_model(&dir, "posts", &model(&["title:String", "views:i32"]))
304 .unwrap()
305 .unwrap();
306 assert!(diff.is_empty(), "views is already migrated");
307 }
308
309 #[test]
310 fn dropped_column_is_withheld_without_force() {
311 let dir = tmpdir();
312 fs::write(
313 dir.join("001_create_posts.sql"),
314 "CREATE TABLE posts (\n id UUID PRIMARY KEY,\n title TEXT NOT NULL,\n subtitle TEXT NOT NULL\n);\n",
315 )
316 .unwrap();
317
318 let diff = diff_model(&dir, "posts", &model(&["title:String"]))
319 .unwrap()
320 .unwrap();
321 assert_eq!(diff.dropped, vec!["subtitle".to_string()]);
322 assert!(!diff.dropped.iter().any(|c| c == "id"));
324
325 let withheld = alter_sql("posts", &diff, false);
326 assert!(withheld.contains("DROP of `subtitle` withheld"));
327 assert!(!has_executable_drop(&withheld));
329
330 let forced = alter_sql("posts", &diff, true);
331 assert!(has_executable_drop(&forced));
332 assert!(forced.contains("DROP COLUMN subtitle;"));
333 }
334}