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::Result;
15use gize_core::{Dialect, 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    /// Differences from generator defaults; preserve existing SQL and report warnings.
30    pub changed: Vec<TypeChange>,
31    /// Non-optional model fields whose SQL columns still allow NULL (backfill pending).
32    pub nullable: Vec<String>,
33}
34
35#[derive(Debug, PartialEq, Eq)]
36pub struct TypeChange {
37    pub name: String,
38    pub from: String,
39    pub to: String,
40}
41
42impl SchemaDiff {
43    /// No column additions, removals or type changes. Callers must also inspect
44    /// `nullable` for pending backfill/NOT NULL work.
45    pub fn is_empty(&self) -> bool {
46        self.added.is_empty() && self.dropped.is_empty() && self.changed.is_empty()
47    }
48}
49
50/// Compute the diff for `model` (a module's declared shape) against the columns already
51/// present in its table's generated migrations under `migrations_dir`.
52///
53/// Returns `Ok(None)` when the table does not exist after replay — there is nothing
54/// to diff against, and the caller should scaffold the resource (`make crud` / `sync`) first.
55pub fn diff_model(
56    migrations_dir: &Path,
57    table: &str,
58    model: &ModelSpec,
59    dialect: Dialect,
60) -> Result<Option<SchemaDiff>> {
61    Ok(SchemaHistory::load(migrations_dir, dialect)?.diff(table, model))
62}
63
64/// One immutable replay shared by every module in a CLI invocation.
65pub struct SchemaHistory {
66    tables: std::collections::BTreeMap<String, crate::schema::Table>,
67    dialect: Dialect,
68}
69
70impl SchemaHistory {
71    pub fn load(dir: &Path, dialect: Dialect) -> Result<Self> {
72        Ok(Self {
73            tables: crate::schema::replay(dir, dialect)?,
74            dialect,
75        })
76    }
77
78    pub fn diff(&self, table: &str, model: &ModelSpec) -> Option<SchemaDiff> {
79        let known = self.tables.get(table)?;
80        let dialect = self.dialect;
81
82        let declared: BTreeSet<&str> = model.fields.iter().map(|f| f.name.as_str()).collect();
83
84        let added = model
85            .fields
86            .iter()
87            .filter(|f| !known.contains_key(&f.name))
88            .cloned()
89            .collect();
90
91        let dropped = known
92            .keys()
93            .filter(|c| !declared.contains(c.as_str()))
94            .filter(|c| !IMPLICIT_COLUMNS.contains(&c.as_str()))
95            .cloned()
96            .collect();
97
98        let changed = model
99            .fields
100            .iter()
101            .filter_map(|f| {
102                let existing = known.get(&f.name)?;
103                let desired = crate::schema::normalize_type(dialect.column_type(f.ty), dialect);
104                (existing.ty != desired).then(|| TypeChange {
105                    name: f.name.clone(),
106                    from: existing.ty.clone(),
107                    to: desired,
108                })
109            })
110            .collect();
111        let nullable = model
112            .fields
113            .iter()
114            .filter(|f| known.get(&f.name).is_some_and(|c| c.nullable))
115            .map(|f| f.name.clone())
116            .collect();
117        Some(SchemaDiff {
118            added,
119            dropped,
120            changed,
121            nullable,
122        })
123    }
124}
125
126/// The current columns after replaying forward migrations (PostgreSQL compatibility helper).
127pub fn known_columns(migrations_dir: &Path, table: &str) -> Result<Option<BTreeSet<String>>> {
128    known_columns_for_dialect(migrations_dir, table, Dialect::Postgres)
129}
130
131/// Replay columns using the selected dialect (including MySQL quoted identifiers).
132pub fn known_columns_for_dialect(
133    dir: &Path,
134    table: &str,
135    dialect: Dialect,
136) -> Result<Option<BTreeSet<String>>> {
137    Ok(SchemaHistory::load(dir, dialect)?
138        .tables
139        .remove(table)
140        .map(|columns| columns.into_keys().collect()))
141}
142
143/// Render an `ALTER TABLE` migration that reconciles `table` to its declared shape.
144///
145/// Added columns are emitted **nullable** (not `NOT NULL`) even though Gize's create tables
146/// are `NOT NULL`: adding a `NOT NULL` column without a default to a table that may hold rows
147/// fails, so the safe move is a nullable column plus a `-- TODO` to backfill and tighten
148/// (ADR-011 revision). Drops are only included when `force` is set. Type changes
149/// are not inferred: callers must handle `changed` with a manual migration.
150pub fn alter_sql(table: &str, diff: &SchemaDiff, dialect: Dialect, force: bool) -> String {
151    let mut out = format!("-- Migration: alter {table} (reconcile from gize.toml)\n");
152    out.push_str("-- Generated by `gize make migration` from a model change (see ADR-011).\n\n");
153
154    for f in &diff.added {
155        let ty = dialect.column_type(f.ty);
156        let tighten = match dialect {
157            Dialect::Postgres => format!("ALTER TABLE {table} ALTER COLUMN {} SET NOT NULL", f.name),
158            Dialect::MySql => format!("ALTER TABLE {table} MODIFY COLUMN {} {ty} NOT NULL", f.name),
159            Dialect::Sqlite => "rebuild the table with a NOT NULL constraint, preserving data, indexes, triggers and foreign keys".to_string(),
160        };
161        out.push_str(&format!(
162            "ALTER TABLE {table} ADD COLUMN {name} {ty}; \
163             -- TODO: nullable for safety — backfill, then {tighten}\n",
164            name = f.name,
165        ));
166    }
167
168    for col in &diff.dropped {
169        if force {
170            out.push_str(&format!("ALTER TABLE {table} DROP COLUMN {col};\n"));
171        } else {
172            out.push_str(&format!(
173                "-- DROP of `{col}` withheld: re-run with --force to emit \
174                 `ALTER TABLE {table} DROP COLUMN {col};` (may be a rename — review first)\n"
175            ));
176        }
177    }
178
179    out
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::fs;
186
187    fn tmpdir() -> std::path::PathBuf {
188        use std::sync::atomic::{AtomicUsize, Ordering};
189        static COUNTER: AtomicUsize = AtomicUsize::new(0);
190        // pid + a process-local counter make the path unique even when two tests run in the
191        // same nanosecond on parallel threads.
192        let dir = std::env::temp_dir().join(format!(
193            "gize-diff-{}-{}",
194            std::process::id(),
195            COUNTER.fetch_add(1, Ordering::Relaxed)
196        ));
197        fs::create_dir_all(&dir).unwrap();
198        dir
199    }
200
201    fn model(fields: &[&str]) -> ModelSpec {
202        ModelSpec::parse(
203            "Post",
204            &fields.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
205        )
206        .unwrap()
207    }
208
209    /// Whether the SQL contains an actual (non-comment) `DROP COLUMN` statement.
210    fn has_executable_drop(sql: &str) -> bool {
211        sql.lines().any(|l| {
212            let l = l.trim_start();
213            !l.starts_with("--") && l.contains("DROP COLUMN")
214        })
215    }
216
217    #[test]
218    fn alter_columns_use_selected_dialect_and_backfill_guidance() {
219        let fields = model(&[
220            "title:String",
221            "active:bool",
222            "views:i32",
223            "total:i64",
224            "score:f64",
225            "owner:Uuid",
226            "published:DateTime",
227        ])
228        .fields;
229        for (dialect, types, guidance) in [
230            (
231                Dialect::Postgres,
232                [
233                    "TEXT",
234                    "BOOLEAN",
235                    "INTEGER",
236                    "BIGINT",
237                    "DOUBLE PRECISION",
238                    "UUID",
239                    "TIMESTAMPTZ",
240                ],
241                "ALTER TABLE posts ALTER COLUMN published SET NOT NULL",
242            ),
243            (
244                Dialect::Sqlite,
245                [
246                    "TEXT", "INTEGER", "INTEGER", "INTEGER", "REAL", "TEXT", "TEXT",
247                ],
248                "rebuild the table with a NOT NULL constraint",
249            ),
250            (
251                Dialect::MySql,
252                [
253                    "VARCHAR(255)",
254                    "BOOLEAN",
255                    "INT",
256                    "BIGINT",
257                    "DOUBLE",
258                    "BINARY(16)",
259                    "DATETIME",
260                ],
261                "ALTER TABLE posts MODIFY COLUMN published DATETIME NOT NULL",
262            ),
263        ] {
264            let diff = SchemaDiff {
265                added: fields.clone(),
266                dropped: vec!["obsolete".into()],
267                ..SchemaDiff::default()
268            };
269            let sql = alter_sql("posts", &diff, dialect, false);
270            let statements: Vec<_> = sql
271                .lines()
272                .filter(|line| line.starts_with("ALTER TABLE"))
273                .map(|line| line.split_once(';').unwrap().0)
274                .collect();
275            let expected: Vec<_> = fields
276                .iter()
277                .zip(types)
278                .map(|(field, ty)| format!("ALTER TABLE posts ADD COLUMN {} {ty}", field.name))
279                .collect();
280            assert_eq!(statements, expected, "{dialect:?}");
281            assert!(sql.contains(guidance), "{dialect:?}: {sql}");
282            assert!(!has_executable_drop(&sql));
283            assert!(has_executable_drop(&alter_sql(
284                "posts", &diff, dialect, true
285            )));
286            if dialect == Dialect::Sqlite {
287                assert!(!sql.contains("ALTER COLUMN"));
288                assert!(!sql.contains("MODIFY COLUMN"));
289            }
290        }
291    }
292
293    #[test]
294    fn no_create_migration_yields_none() {
295        let dir = tmpdir();
296        assert!(
297            diff_model(&dir, "posts", &model(&["title:String"]), Dialect::Postgres)
298                .unwrap()
299                .is_none()
300        );
301    }
302
303    #[test]
304    fn detects_added_column() {
305        let dir = tmpdir();
306        fs::write(
307            dir.join("001_create_posts.sql"),
308            "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",
309        )
310        .unwrap();
311
312        let diff = diff_model(
313            &dir,
314            "posts",
315            &model(&["title:String", "views:i32"]),
316            Dialect::Postgres,
317        )
318        .unwrap()
319        .unwrap();
320        assert_eq!(diff.added.len(), 1);
321        assert_eq!(diff.added[0].name, "views");
322        assert!(diff.dropped.is_empty());
323
324        let sql = alter_sql("posts", &diff, Dialect::Postgres, false);
325        assert!(sql.contains("ALTER TABLE posts ADD COLUMN views INTEGER;"));
326        assert!(sql.contains("SET NOT NULL"));
327    }
328
329    #[test]
330    fn parses_columns_despite_parenthesized_defaults() {
331        // Regression: `gen_random_uuid()` / `now()` contain parens; the create body must be
332        // matched by the *balanced* closing paren, not the first `)`.
333        let dir = tmpdir();
334        fs::write(
335            dir.join("001_create_posts.sql"),
336            "-- 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",
337        )
338        .unwrap();
339
340        let known = known_columns(&dir, "posts").unwrap().unwrap();
341        assert!(known.contains("title"), "title should be parsed");
342        assert!(known.contains("body"), "body should be parsed");
343        assert!(known.contains("updated_at"), "updated_at should be parsed");
344
345        // A model matching the table exactly has no diff.
346        let diff = diff_model(
347            &dir,
348            "posts",
349            &model(&["title:String", "body:String"]),
350            Dialect::Postgres,
351        )
352        .unwrap()
353        .unwrap();
354        assert!(
355            diff.is_empty(),
356            "matching model should produce no diff, got {diff:?}"
357        );
358    }
359
360    #[test]
361    fn already_migrated_alter_column_is_known() {
362        let dir = tmpdir();
363        fs::write(
364            dir.join("001_create_posts.sql"),
365            "CREATE TABLE posts (\n  id UUID PRIMARY KEY,\n  title TEXT NOT NULL\n);\n",
366        )
367        .unwrap();
368        fs::write(
369            dir.join("002_alter_posts.sql"),
370            "ALTER TABLE posts ADD COLUMN views INTEGER;\n",
371        )
372        .unwrap();
373
374        // `views` already migrated -> no longer counted as added.
375        let diff = diff_model(
376            &dir,
377            "posts",
378            &model(&["title:String", "views:i32"]),
379            Dialect::Postgres,
380        )
381        .unwrap()
382        .unwrap();
383        assert!(diff.is_empty(), "views is already migrated");
384    }
385
386    #[test]
387    fn dropped_column_is_withheld_without_force() {
388        let dir = tmpdir();
389        fs::write(
390            dir.join("001_create_posts.sql"),
391            "CREATE TABLE posts (\n  id UUID PRIMARY KEY,\n  title TEXT NOT NULL,\n  subtitle TEXT NOT NULL\n);\n",
392        )
393        .unwrap();
394
395        let diff = diff_model(&dir, "posts", &model(&["title:String"]), Dialect::Postgres)
396            .unwrap()
397            .unwrap();
398        assert_eq!(diff.dropped, vec!["subtitle".to_string()]);
399        // implicit columns never proposed for drop
400        assert!(!diff.dropped.iter().any(|c| c == "id"));
401
402        let withheld = alter_sql("posts", &diff, Dialect::Postgres, false);
403        assert!(withheld.contains("DROP of `subtitle` withheld"));
404        // No *executable* drop statement — the drop is only mentioned inside a `--` comment.
405        assert!(!has_executable_drop(&withheld));
406
407        let forced = alter_sql("posts", &diff, Dialect::Postgres, true);
408        assert!(has_executable_drop(&forced));
409        assert!(forced.contains("DROP COLUMN subtitle;"));
410    }
411    #[test]
412    fn replays_drops_reintroductions_and_numeric_versions() {
413        let dir = tmpdir();
414        fs::write(dir.join("2_create_posts.up.sql"), "CREATE TABLE posts (id UUID PRIMARY KEY, title TEXT NOT NULL, views INTEGER NOT NULL);").unwrap();
415        fs::write(dir.join("2_create_posts.down.sql"), "DROP TABLE posts;").unwrap();
416        fs::write(
417            dir.join("10_drop.sql"),
418            "ALTER TABLE posts DROP COLUMN views;",
419        )
420        .unwrap();
421        let diff = diff_model(&dir, "posts", &model(&["title:String"]), Dialect::Postgres)
422            .unwrap()
423            .unwrap();
424        assert!(diff.is_empty());
425        let diff = diff_model(
426            &dir,
427            "posts",
428            &model(&["title:String", "views:i64"]),
429            Dialect::Postgres,
430        )
431        .unwrap()
432        .unwrap();
433        assert_eq!(diff.added[0].name, "views");
434        fs::write(
435            dir.join("11_reintroduce.sql"),
436            alter_sql("posts", &diff, Dialect::Postgres, false),
437        )
438        .unwrap();
439        let diff = diff_model(
440            &dir,
441            "posts",
442            &model(&["title:String", "views:i64"]),
443            Dialect::Postgres,
444        )
445        .unwrap()
446        .unwrap();
447        assert!(diff.is_empty());
448        assert_eq!(diff.nullable, ["views"]);
449        fs::write(dir.join("12_tighten.sql"), "UPDATE posts SET views = 0 WHERE views IS NULL; ALTER TABLE posts ALTER COLUMN views SET NOT NULL;").unwrap();
450        assert!(
451            diff_model(
452                &dir,
453                "posts",
454                &model(&["title:String", "views:i64"]),
455                Dialect::Postgres
456            )
457            .unwrap()
458            .unwrap()
459            .nullable
460            .is_empty()
461        );
462    }
463
464    #[test]
465    fn recognizes_manual_type_changes_in_postgres_and_mysql() {
466        for (dialect, create_type, alter) in [
467            (
468                Dialect::Postgres,
469                "INTEGER",
470                "ALTER TABLE posts ALTER COLUMN views TYPE BIGINT USING views::bigint;",
471            ),
472            (
473                Dialect::MySql,
474                "INT",
475                "ALTER TABLE posts MODIFY COLUMN views BIGINT NOT NULL;",
476            ),
477        ] {
478            let dir = tmpdir();
479            fs::write(
480                dir.join("1_create.sql"),
481                format!("CREATE TABLE posts (views {create_type} NOT NULL);"),
482            )
483            .unwrap();
484            let desired = model(&["views:i64"]);
485            let diff = diff_model(&dir, "posts", &desired, dialect)
486                .unwrap()
487                .unwrap();
488            assert!(!diff.is_empty());
489            assert_eq!(diff.changed.len(), 1);
490            assert_eq!(diff.changed[0].from, "INTEGER");
491            assert_eq!(diff.changed[0].to, "BIGINT");
492            fs::write(dir.join("2_change.sql"), alter).unwrap();
493            let diff = diff_model(&dir, "posts", &desired, dialect)
494                .unwrap()
495                .unwrap();
496            assert!(diff.is_empty());
497            assert!(diff.nullable.is_empty());
498        }
499    }
500
501    #[test]
502    fn sqlite_rebuild_replaces_type_and_nullability() {
503        let dir = tmpdir();
504        fs::write(dir.join("1_create.sql"), "CREATE TABLE posts (views TEXT);").unwrap();
505        assert_eq!(
506            diff_model(&dir, "posts", &model(&["views:i32"]), Dialect::Sqlite)
507                .unwrap()
508                .unwrap()
509                .changed
510                .len(),
511            1
512        );
513        fs::write(dir.join("2_rebuild.sql"), "BEGIN; CREATE TABLE posts_new (views INTEGER NOT NULL); INSERT INTO posts_new SELECT CAST(views AS INTEGER) FROM posts; DROP TABLE posts; ALTER TABLE posts_new RENAME TO posts; COMMIT;").unwrap();
514        let diff = diff_model(&dir, "posts", &model(&["views:i32"]), Dialect::Sqlite)
515            .unwrap()
516            .unwrap();
517        assert!(diff.is_empty());
518        assert!(diff.nullable.is_empty());
519    }
520
521    #[test]
522    fn parses_comments_quotes_multiline_and_exact_table_names() {
523        let dir = tmpdir();
524        fs::write(
525            dir.join("1_create.sql"),
526            r#"
527            -- CREATE TABLE posts (fake INTEGER);
528            CREATE TABLE posts_archive (unrelated INTEGER);
529            create table "posts" ("title" TEXT NOT NULL DEFAULT 'a; b, (c)', views INT4 NOT NULL);
530            /* ALTER TABLE posts DROP COLUMN title; */
531            ALTER TABLE "posts"
532                RENAME COLUMN "views" TO "count";
533            ALTER TABLE posts DROP COLUMN count, ADD COLUMN total BIGINT NOT NULL;
534        "#,
535        )
536        .unwrap();
537        let diff = diff_model(
538            &dir,
539            "posts",
540            &model(&["title:String", "total:i64"]),
541            Dialect::Postgres,
542        )
543        .unwrap()
544        .unwrap();
545        assert!(diff.is_empty());
546        assert!(diff.nullable.is_empty());
547    }
548
549    #[test]
550    fn rejects_unknown_or_inconsistent_history_with_file_context() {
551        for sql in [
552            "ALTER TABLE posts ENABLE ROW LEVEL SECURITY;",
553            "ALTER TABLE posts DROP COLUMN missing;",
554            "ALTER TABLE posts ADD COLUMN views INTEGER;",
555            "CREATE TABLE posts (views INTEGER);",
556            "CREATE TABLE copied AS SELECT * FROM posts;",
557            "CREATE TABLE public.other (views INTEGER);",
558            "this is not SQL;",
559            "DROP FUNCTION old_function() CASCADE;",
560            "DROP TRIGGER old_trigger ON posts CASCADE;",
561            "DROP EXTENSION pgcrypto;",
562            "DROP TYPE mood CASCADE;",
563            "ALTER TYPE mood ADD VALUE 'sleepy';",
564        ] {
565            let dir = tmpdir();
566            fs::write(
567                dir.join("1_create.sql"),
568                "CREATE TABLE posts (views INTEGER NOT NULL);",
569            )
570            .unwrap();
571            fs::write(dir.join("2_manual.sql"), sql).unwrap();
572            let err =
573                diff_model(&dir, "posts", &model(&["views:i32"]), Dialect::Postgres).unwrap_err();
574            assert!(format!("{err:#}").contains("2_manual.sql"), "{err:#}");
575        }
576    }
577
578    #[test]
579    fn generated_create_templates_replay_for_all_dialects() {
580        for dialect in [Dialect::Postgres, Dialect::Sqlite, Dialect::MySql] {
581            let dir = tmpdir();
582            let resource = model(&[
583                "title:String",
584                "active:bool",
585                "views:i32",
586                "total:i64",
587                "score:f64",
588                "owner:Uuid",
589                "published:DateTime",
590                "author:belongs_to:users",
591            ]);
592            fs::write(
593                dir.join("1_users.sql"),
594                gize_templates::user::migration_sql(dialect),
595            )
596            .unwrap();
597            fs::write(
598                dir.join("2_posts.sql"),
599                gize_templates::model::migration_sql(&resource, dialect),
600            )
601            .unwrap();
602            let diff = diff_model(&dir, "posts", &resource, dialect)
603                .unwrap()
604                .unwrap();
605            assert!(diff.is_empty(), "{dialect:?}: {diff:?}");
606            assert!(diff.nullable.is_empty());
607        }
608    }
609
610    #[test]
611    fn duplicate_versions_are_not_silently_ordered() {
612        let dir = tmpdir();
613        fs::write(dir.join("1_a.sql"), "CREATE TABLE posts (views INTEGER);").unwrap();
614        fs::write(dir.join("1_b.sql"), "ALTER TABLE posts DROP COLUMN views;").unwrap();
615        assert!(
616            format!(
617                "{:#}",
618                diff_model(&dir, "posts", &model(&[]), Dialect::Postgres).unwrap_err()
619            )
620            .contains("duplicate migration version")
621        );
622    }
623    #[test]
624    fn mysql_change_column_and_conditional_operations_replay() {
625        let dir = tmpdir();
626        fs::write(dir.join("1_create.sql"), "CREATE TABLE posts (old INT NOT NULL); CREATE TABLE IF NOT EXISTS posts (ignored TEXT); ALTER TABLE posts CHANGE COLUMN old views BIGINT NOT NULL; ALTER TABLE posts DROP COLUMN IF EXISTS missing;").unwrap();
627        let diff = diff_model(&dir, "posts", &model(&["views:i64"]), Dialect::MySql)
628            .unwrap()
629            .unwrap();
630        assert!(diff.is_empty());
631        assert!(diff.nullable.is_empty());
632        fs::write(
633            dir.join("2_conditional.sql"),
634            "/*!80000 ALTER TABLE posts DROP COLUMN views */;",
635        )
636        .unwrap();
637        assert!(
638            format!(
639                "{:#}",
640                diff_model(&dir, "posts", &model(&["views:i64"]), Dialect::MySql).unwrap_err()
641            )
642            .contains("executable comment")
643        );
644    }
645
646    #[test]
647    fn sqlite_foreign_key_pragmas_do_not_change_column_shape() {
648        let dir = tmpdir();
649        fs::write(dir.join("1_create.sql"), "PRAGMA foreign_keys = 0; CREATE TABLE posts (views INTEGER NOT NULL); PRAGMA foreign_keys = 1;").unwrap();
650        assert!(
651            diff_model(&dir, "posts", &model(&["views:i32"]), Dialect::Sqlite)
652                .unwrap()
653                .unwrap()
654                .is_empty()
655        );
656        fs::write(
657            dir.join("2_unknown_pragma.sql"),
658            "PRAGMA writable_schema = 1;",
659        )
660        .unwrap();
661        assert!(
662            format!(
663                "{:#}",
664                diff_model(&dir, "posts", &model(&["views:i32"]), Dialect::Sqlite).unwrap_err()
665            )
666            .contains("unsupported PRAGMA")
667        );
668    }
669    #[test]
670    fn postgres_auxiliary_ddl_does_not_block_columns() {
671        let dir = tmpdir();
672        fs::write(
673            dir.join("1_schema.sql"),
674            r#"
675CREATE TYPE mood AS ENUM ('happy', 'sad');
676CREATE TYPE legacy_mood AS ENUM ('meh');
677DROP TYPE IF EXISTS legacy_mood;
678DROP FUNCTION IF EXISTS old_function();
679DROP TRIGGER IF EXISTS old_trigger ON posts;
680CREATE EXTENSION IF NOT EXISTS pgcrypto;
681CREATE TABLE tags (id UUID PRIMARY KEY);
682CREATE TABLE posts (id UUID PRIMARY KEY, tag_id UUID, title VARCHAR(120) NOT NULL);
683ALTER TABLE posts ADD CONSTRAINT fk FOREIGN KEY (tag_id) REFERENCES tags(id);
684ALTER TABLE posts ADD CONSTRAINT title_nonempty CHECK (title <> '');
685ALTER TABLE posts DROP CONSTRAINT title_nonempty;
686COMMENT ON TABLE posts IS 'custom table';
687GRANT SELECT ON posts TO PUBLIC;
688REVOKE SELECT ON posts FROM PUBLIC;
689CREATE VIEW post_titles AS SELECT title FROM posts;
690CREATE FUNCTION touch_post() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql;
691CREATE TRIGGER touch BEFORE UPDATE ON posts FOR EACH ROW EXECUTE FUNCTION touch_post();
692"#,
693        )
694        .unwrap();
695        let history = SchemaHistory::load(&dir, Dialect::Postgres).unwrap();
696        // Remove the source after loading: multiple module diffs use the same snapshot.
697        fs::remove_file(dir.join("1_schema.sql")).unwrap();
698        let diff = history
699            .diff(
700                "posts",
701                &model(&["tag_id:Uuid", "title:String", "views:i32"]),
702            )
703            .unwrap();
704        assert_eq!(diff.added[0].name, "views");
705        assert_eq!(diff.changed[0].from, "VARCHAR(120)");
706        assert!(history.diff("tags", &model(&[])).unwrap().is_empty());
707    }
708
709    #[test]
710    fn primary_key_constraints_update_nullability() {
711        let dir = tmpdir();
712        fs::write(dir.join("1_schema.sql"), "CREATE TABLE posts (views INTEGER); ALTER TABLE posts ADD CONSTRAINT posts_pk PRIMARY KEY (views); ALTER TABLE posts DROP CONSTRAINT posts_pk;").unwrap();
713        let diff = diff_model(&dir, "posts", &model(&["views:i32"]), Dialect::Postgres)
714            .unwrap()
715            .unwrap();
716        assert!(diff.nullable.is_empty()); // PostgreSQL retains the implied NOT NULL.
717    }
718
719    #[test]
720    fn mysql_known_columns_supports_backticks() {
721        let dir = tmpdir();
722        fs::write(
723            dir.join("1_schema.sql"),
724            "CREATE TABLE `posts` (`title` VARCHAR(120) NOT NULL);",
725        )
726        .unwrap();
727        assert!(
728            known_columns_for_dialect(&dir, "posts", Dialect::MySql)
729                .unwrap()
730                .unwrap()
731                .contains("title")
732        );
733    }
734}