Skip to main content

gize_generator/
diff.rs

1//! Model-change migration diffing (ADR-011 revision).
2//!
3//! The checked-in migration SQL is the schema of record: we parse the columns a table
4//! already has from its generated `.sql` files (never touching a live database), diff them
5//! against the fields declared for the module in `gize.toml`, and emit the `ALTER TABLE`
6//! needed to reconcile. Additive changes (new columns) are safe and generated automatically;
7//! destructive ones (dropping a column) are gated behind `--force`, and a rename — which at
8//! the column level is indistinguishable from a drop plus an add — is always surfaced for a
9//! human to decide rather than inferred.
10
11use std::collections::BTreeSet;
12use std::path::Path;
13
14use anyhow::{Context, Result};
15use gize_core::{Field, ModelSpec};
16
17/// Columns Gize always adds via the template, regardless of the model's declared fields.
18/// They are never proposed as drops.
19const IMPLICIT_COLUMNS: [&str; 3] = ["id", "created_at", "updated_at"];
20
21/// The schema delta between a module's declared fields and its migrated columns.
22#[derive(Debug, Default, PartialEq, Eq)]
23pub struct SchemaDiff {
24    /// Declared fields with no column yet — safe, additive `ADD COLUMN`.
25    pub added: Vec<Field>,
26    /// Columns present in the migrations but no longer declared — potential drops (or the
27    /// old half of a rename). Reported; only emitted under `--force`.
28    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
37/// Compute the diff for `model` (a module's declared shape) against the columns already
38/// present in its table's generated migrations under `migrations_dir`.
39///
40/// Returns `Ok(None)` when the table has no `CREATE TABLE` migration yet — there is nothing
41/// to diff against, and the caller should scaffold the resource (`make crud` / `sync`) first.
42pub 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
70/// The set of column names a table already has, parsed from every generated migration that
71/// touches it (`*_create_<table>.sql` and any `ALTER TABLE <table> ADD COLUMN`). Returns
72/// `None` if the table has no create migration yet.
73pub 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    // Sort by filename so migrations are read in version (timestamp) order.
81    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
99/// Extract the columns a single migration file declares for `table`. Returns whether it
100/// contained the `CREATE TABLE`, plus the column names found (from the create body and any
101/// `ADD COLUMN`s). We parse only the SQL shapes Gize itself generates.
102fn columns_in_sql(sql: &str, table: &str) -> (bool, BTreeSet<String>) {
103    let mut cols = BTreeSet::new();
104    let mut created = false;
105
106    // CREATE TABLE <table> ( ... ); — match the balanced closing paren, since column
107    // defaults like `gen_random_uuid()` contain their own parentheses.
108    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    // ALTER TABLE <table> ADD COLUMN <name> ...
136    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
148/// The column name a `CREATE TABLE` body line declares, or `None` for blank lines and
149/// table-level constraints. Column lines start with a lowercase snake_case identifier
150/// (`name TEXT NOT NULL,`); constraints start with an uppercase keyword (`FOREIGN KEY ...`,
151/// `PRIMARY KEY ...`) and are skipped.
152fn 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
163/// Render an `ALTER TABLE` migration that reconciles `table` to its declared shape.
164///
165/// Added columns are emitted **nullable** (not `NOT NULL`) even though Gize's create tables
166/// are `NOT NULL`: adding a `NOT NULL` column without a default to a table that may hold rows
167/// fails, so the safe move is a nullable column plus a `-- TODO` to backfill and tighten
168/// (ADR-011 revision). Drops are only included when `force` is set.
169pub 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        // pid + a process-local counter make the path unique even when two tests run in the
205        // same nanosecond on parallel threads.
206        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    /// Whether the SQL contains an actual (non-comment) `DROP COLUMN` statement.
224    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        // Regression: `gen_random_uuid()` / `now()` contain parens; the create body must be
265        // matched by the *balanced* closing paren, not the first `)`.
266        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        // A model matching the table exactly has no diff.
279        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        // `views` already migrated -> no longer counted as added.
303        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        // implicit columns never proposed for drop
323        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        // No *executable* drop statement — the drop is only mentioned inside a `--` comment.
328        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}