1use std::collections::BTreeSet;
12use std::path::Path;
13
14use anyhow::Result;
15use gize_core::{Dialect, 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 pub changed: Vec<TypeChange>,
31 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 pub fn is_empty(&self) -> bool {
46 self.added.is_empty() && self.dropped.is_empty() && self.changed.is_empty()
47 }
48}
49
50pub 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
64pub 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
126pub fn known_columns(migrations_dir: &Path, table: &str) -> Result<Option<BTreeSet<String>>> {
128 known_columns_for_dialect(migrations_dir, table, Dialect::Postgres)
129}
130
131pub 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
143pub 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 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 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 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 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 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 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 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 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()); }
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}