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 let dir = std::env::temp_dir().join(format!(
203 "gize-diff-{}-{}",
204 std::process::id(),
205 std::time::SystemTime::now()
206 .duration_since(std::time::UNIX_EPOCH)
207 .unwrap()
208 .as_nanos()
209 ));
210 fs::create_dir_all(&dir).unwrap();
211 dir
212 }
213
214 fn model(fields: &[&str]) -> ModelSpec {
215 ModelSpec::parse(
216 "Post",
217 &fields.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
218 )
219 .unwrap()
220 }
221
222 fn has_executable_drop(sql: &str) -> bool {
224 sql.lines().any(|l| {
225 let l = l.trim_start();
226 !l.starts_with("--") && l.contains("DROP COLUMN")
227 })
228 }
229
230 #[test]
231 fn no_create_migration_yields_none() {
232 let dir = tmpdir();
233 assert!(
234 diff_model(&dir, "posts", &model(&["title:String"]))
235 .unwrap()
236 .is_none()
237 );
238 }
239
240 #[test]
241 fn detects_added_column() {
242 let dir = tmpdir();
243 fs::write(
244 dir.join("001_create_posts.sql"),
245 "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",
246 )
247 .unwrap();
248
249 let diff = diff_model(&dir, "posts", &model(&["title:String", "views:i32"]))
250 .unwrap()
251 .unwrap();
252 assert_eq!(diff.added.len(), 1);
253 assert_eq!(diff.added[0].name, "views");
254 assert!(diff.dropped.is_empty());
255
256 let sql = alter_sql("posts", &diff, false);
257 assert!(sql.contains("ALTER TABLE posts ADD COLUMN views INTEGER;"));
258 assert!(sql.contains("SET NOT NULL"));
259 }
260
261 #[test]
262 fn parses_columns_despite_parenthesized_defaults() {
263 let dir = tmpdir();
266 fs::write(
267 dir.join("001_create_posts.sql"),
268 "-- 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",
269 )
270 .unwrap();
271
272 let known = known_columns(&dir, "posts").unwrap().unwrap();
273 assert!(known.contains("title"), "title should be parsed");
274 assert!(known.contains("body"), "body should be parsed");
275 assert!(known.contains("updated_at"), "updated_at should be parsed");
276
277 let diff = diff_model(&dir, "posts", &model(&["title:String", "body:String"]))
279 .unwrap()
280 .unwrap();
281 assert!(
282 diff.is_empty(),
283 "matching model should produce no diff, got {diff:?}"
284 );
285 }
286
287 #[test]
288 fn already_migrated_alter_column_is_known() {
289 let dir = tmpdir();
290 fs::write(
291 dir.join("001_create_posts.sql"),
292 "CREATE TABLE posts (\n id UUID PRIMARY KEY,\n title TEXT NOT NULL\n);\n",
293 )
294 .unwrap();
295 fs::write(
296 dir.join("002_alter_posts.sql"),
297 "ALTER TABLE posts ADD COLUMN views INTEGER;\n",
298 )
299 .unwrap();
300
301 let diff = diff_model(&dir, "posts", &model(&["title:String", "views:i32"]))
303 .unwrap()
304 .unwrap();
305 assert!(diff.is_empty(), "views is already migrated");
306 }
307
308 #[test]
309 fn dropped_column_is_withheld_without_force() {
310 let dir = tmpdir();
311 fs::write(
312 dir.join("001_create_posts.sql"),
313 "CREATE TABLE posts (\n id UUID PRIMARY KEY,\n title TEXT NOT NULL,\n subtitle TEXT NOT NULL\n);\n",
314 )
315 .unwrap();
316
317 let diff = diff_model(&dir, "posts", &model(&["title:String"]))
318 .unwrap()
319 .unwrap();
320 assert_eq!(diff.dropped, vec!["subtitle".to_string()]);
321 assert!(!diff.dropped.iter().any(|c| c == "id"));
323
324 let withheld = alter_sql("posts", &diff, false);
325 assert!(withheld.contains("DROP of `subtitle` withheld"));
326 assert!(!has_executable_drop(&withheld));
328
329 let forced = alter_sql("posts", &diff, true);
330 assert!(has_executable_drop(&forced));
331 assert!(forced.contains("DROP COLUMN subtitle;"));
332 }
333}