use super::*; // ── Pure helpers (PG constraint row mapping) ──────────────────────── #[test] fn pg_fk_action_maps_codes() { assert_eq!(pg_fk_action("a"), "NO ACTION"); assert_eq!(pg_fk_action("r"), "RESTRICT"); assert_eq!(pg_fk_action("c"), "CASCADE"); assert_eq!(pg_fk_action("n"), "SET NULL"); assert_eq!(pg_fk_action("d"), "SET DEFAULT"); assert_eq!(pg_fk_action("x"), "NO ACTION"); } #[test] fn snapshot_cap_sets_truncated() { // 5,001 objects → truncated report, never silent (spec §2) let mut snap = SchemaSnapshot::default(); snap.engine = "postgresql".into(); snap.schema = "s".into(); for i in 0..5_001 { snap.tables.push(SnapshotTable { schema: "s".into(), name: format!("t{i}"), columns: vec![], primary_key: vec![], uniques: vec![], checks: vec![], indexes: vec![], foreign_keys: vec![], }); } apply_snapshot_cap(&mut snap); assert!(snap.truncated); assert!(snap.object_count <= 5_000); assert_eq!(snap.tables.len(), snap.object_count); } #[test] fn query_builders_target_the_right_catalogs() { let cols = crate::db::introspection::pg_diff_columns_query(); assert!(cols.contains("pg_attribute") && cols.contains("format_type")); let cons = crate::db::introspection::pg_diff_constraints_query(); assert!(cons.contains("pg_constraint") && cons.contains("contype")); let idx = crate::db::introspection::pg_diff_indexes_query(); assert!(idx.contains("pg_index") && idx.contains("indisunique")); let views = crate::db::introspection::pg_diff_views_query(); assert!(views.contains("pg_views") && views.contains("pg_matviews")); let seqs = crate::db::introspection::pg_diff_sequences_query(); assert!(seqs.contains("information_schema.sequences")); let enums = crate::db::introspection::pg_diff_enums_query(); assert!(enums.contains("pg_type") && enums.contains("pg_enum")); } // ── SQLite capture (in-memory, real, NOT ignored) ────────────────── #[test] fn sqlite_capture_reads_pragmas() { let conn = rusqlite::Connection::open_in_memory().unwrap(); conn.execute_batch( "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL DEFAULT 'x', email TEXT); CREATE UNIQUE INDEX idx_users_email ON users(email); CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE); CREATE VIEW v_users AS SELECT id, name FROM users;", ) .unwrap(); let snap = capture_sqlite(&conn).expect("capture"); assert_eq!(snap.engine, "sqlite"); assert_eq!(snap.tables.len(), 2); let users = snap.tables.iter().find(|t| t.name == "users").unwrap(); assert_eq!(users.primary_key, vec!["id"]); assert_eq!(users.columns.len(), 3); assert!(users.columns.iter().any(|c| c.name == "name" && !c.nullable)); assert!(users.columns.iter().any(|c| c.name == "email" && c.nullable)); assert!(users.indexes.iter().any(|i| i.name == "idx_users_email" && i.unique)); let orders = snap.tables.iter().find(|t| t.name == "orders").unwrap(); assert_eq!(orders.foreign_keys.len(), 1); let fk = &orders.foreign_keys[0]; assert!(fk.ref_table.contains("users")); assert_eq!(fk.on_delete, "CASCADE"); assert_eq!(snap.views.len(), 1); assert!(snap.views[0].definition.contains("SELECT")); } /// Live PG capture — mirror the env/client conventions of the live PG tests in /// `commands/objects.test.rs`. Create two diverged tables in a temp schema, /// assert capture shape (columns with defaults, PK, FK with actions, indexes, /// views, a sequence, an enum). #[tokio::test] #[ignore] async fn pg_capture_live() { let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST"); let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap(); let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER"); let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB"); let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default(); let (client, conn) = tokio_postgres::connect( &format!("host={h} port={p} user={u} dbname={d} password={pw}"), tokio_postgres::NoTls, ) .await .expect("connect to test PG"); let handle = tokio::spawn(async move { let _ = conn.await; }); let schema = "gridline_diff_test"; client .batch_execute(&format!( "DROP SCHEMA IF EXISTS {schema} CASCADE; CREATE SCHEMA {schema};" )) .await .expect("create temp schema"); // Two diverged tables: one with PK/FK/index/default, one plain. client .batch_execute(&format!( "CREATE TABLE {schema}.parent (id serial PRIMARY KEY, code text UNIQUE); CREATE TABLE {schema}.child ( id serial PRIMARY KEY, parent_id integer REFERENCES {schema}.parent(id) ON DELETE CASCADE ON UPDATE RESTRICT, name text NOT NULL DEFAULT 'x', email text ); CREATE INDEX idx_child_email ON {schema}.child(email); CREATE VIEW {schema}.v_child AS SELECT id, name FROM {schema}.child; CREATE SEQUENCE {schema}.seq_test START 5 INCREMENT 2; CREATE TYPE {schema}.mood AS ENUM ('happy', 'sad');" )) .await .expect("create objects"); let snap = capture_pg(&client, "postgresql", schema).await.expect("capture"); assert_eq!(snap.engine, "postgresql"); assert_eq!(snap.schema, schema); assert_eq!(snap.tables.len(), 2); let child = snap.tables.iter().find(|t| t.name == "child").expect("child table"); assert_eq!(child.primary_key, vec!["id"]); assert!(child.columns.iter().any(|c| c.name == "name" && !c.nullable && c.default_value.is_some())); assert!(child.columns.iter().any(|c| c.name == "email" && c.nullable)); assert!(child.indexes.iter().any(|i| i.name == "idx_child_email")); assert_eq!(child.foreign_keys.len(), 1); let fk = &child.foreign_keys[0]; assert!(fk.ref_table.contains("parent")); assert_eq!(fk.on_delete, "CASCADE"); assert_eq!(fk.on_update, "RESTRICT"); let parent = snap.tables.iter().find(|t| t.name == "parent").expect("parent table"); assert!(parent.uniques.iter().any(|u| u.kind == "unique")); assert!(snap.views.iter().any(|v| v.name == "v_child")); assert!(snap.sequences.iter().any(|s| s.name == "seq_test" && s.start == "5")); assert!(snap.enums.iter().any(|e| e.name == "mood" && e.labels == vec!["happy", "sad"])); client .batch_execute(&format!("DROP SCHEMA {schema} CASCADE;")) .await .expect("drop temp schema"); let _ = handle; } // ── Task 14: pure diff engine + sync-SQL generation ───────────────── fn col(name: &str, ty: &str, nullable: bool, default: Option<&str>) -> SnapshotColumn { SnapshotColumn { name: name.into(), data_type: ty.into(), nullable, default_value: default.map(|d| d.into()), } } fn table(name: &str, columns: Vec) -> SnapshotTable { SnapshotTable { schema: "s".into(), name: name.into(), columns, primary_key: vec![], uniques: vec![], checks: vec![], indexes: vec![], foreign_keys: vec![], } } fn pg_snap(tables: Vec) -> SchemaSnapshot { let object_count = tables.len(); SchemaSnapshot { engine: "postgresql".into(), schema: "s".into(), tables, views: vec![], sequences: vec![], enums: vec![], object_count, truncated: false, } } #[test] fn added_table_generates_create_with_pk_and_is_stageable() { let mut src = pg_snap(vec![]); let mut t = table("users", vec![col("id", "integer", false, None), col("email", "text", true, Some("''"))]); t.primary_key = vec!["id".into()]; src.tables.push(t); let tgt = pg_snap(vec![]); let r = diff_snapshots(&src, &tgt); let item = r.items.iter().find(|i| i.object_type == "table" && i.name == "users").unwrap(); assert_eq!(item.kind, "added"); assert!(!item.destructive); assert_eq!(item.sync_sql.len(), 1); let sql = &item.sync_sql[0]; assert!(sql.starts_with("CREATE TABLE \"s\".\"users\""), "got: {sql}"); assert!(sql.contains("\"id\" integer NOT NULL")); assert!(sql.contains("\"email\" text DEFAULT ''")); assert!(sql.contains("PRIMARY KEY (\"id\")")); } #[test] fn removed_table_is_destructive_copy_only() { let tgt = pg_snap(vec![table("old", vec![col("id", "integer", false, None)])]); let src = pg_snap(vec![]); let r = diff_snapshots(&src, &tgt); let item = r.items.iter().find(|i| i.name == "old").unwrap(); assert_eq!(item.kind, "removed"); assert!(item.destructive); assert_eq!(item.sync_sql, vec!["DROP TABLE \"s\".\"old\";"]); } #[test] fn column_diffs_classify_exactly_per_spec() { let mut src = pg_snap(vec![table( "t", vec![ col("kept", "int", true, None), col("added", "int", true, None), col("retyped", "bigint", true, None), col("tightened", "int", false, None), col("defchanged", "int", true, Some("2")), ], )]); src.tables[0].name = "t".into(); let tgt = pg_snap(vec![table( "t", vec![ col("kept", "int", true, None), col("gone", "int", true, None), col("retyped", "int", true, None), col("tightened", "int", true, None), col("defchanged", "int", true, Some("1")), ], )]); let r = diff_snapshots(&src, &tgt); let add = r.items.iter().find(|i| i.name == "t.added").unwrap(); assert_eq!((add.kind.as_str(), add.destructive), ("added", false)); assert_eq!(add.sync_sql[0], "ALTER TABLE \"s\".\"t\" ADD COLUMN \"added\" int;"); let gone = r.items.iter().find(|i| i.name == "t.gone").unwrap(); assert_eq!((gone.kind.as_str(), gone.destructive), ("removed", true)); assert_eq!(gone.sync_sql[0], "ALTER TABLE \"s\".\"t\" DROP COLUMN \"gone\";"); let retyped = r.items.iter().find(|i| i.name == "t.retyped").unwrap(); assert!(retyped.destructive, "ALTER COLUMN TYPE is destructive per spec"); assert!(retyped.sync_sql[0].contains("ALTER COLUMN \"retyped\" TYPE bigint")); let tight = r.items.iter().find(|i| i.name == "t.tightened").unwrap(); assert!(!tight.destructive); assert!(tight.sync_sql[0].contains("SET NOT NULL")); let def = r.items.iter().find(|i| i.name == "t.defchanged").unwrap(); assert!(!def.destructive); assert!(def.sync_sql[0].contains("SET DEFAULT 2")); } #[test] fn constraint_index_fk_view_sequence_enum_diffs() { let mut src = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]); let mut tgt = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]); src.tables[0].foreign_keys.push(SnapshotFk { name: "fk_t_ref".into(), columns: vec!["id".into()], ref_schema: "s".into(), ref_table: "other".into(), ref_columns: vec!["id".into()], on_delete: "CASCADE".into(), on_update: "NO ACTION".into(), }); src.tables[0].indexes.push(SnapshotIndex { name: "ix_t".into(), columns: vec!["id".into()], unique: false }); src.views.push(SnapshotView { schema: "s".into(), name: "v".into(), definition: "SELECT 1".into(), materialized: false }); src.sequences.push(SnapshotSequence { schema: "s".into(), name: "seq".into(), start: "1".into(), increment: "1".into(), minimum: "1".into(), maximum: "100".into(), cycle: false }); src.enums.push(SnapshotEnum { schema: "s".into(), name: "color".into(), labels: vec!["red".into(), "green".into()] }); tgt.enums.push(SnapshotEnum { schema: "s".into(), name: "color".into(), labels: vec!["red".into()] }); let r = diff_snapshots(&src, &tgt); let fk = r.items.iter().find(|i| i.object_type == "constraint" && i.name == "t.fk_t_ref").unwrap(); assert!(!fk.destructive); assert!(fk.sync_sql[0].contains("ADD CONSTRAINT \"fk_t_ref\" FOREIGN KEY (\"id\")")); assert!(fk.sync_sql[0].contains("REFERENCES \"s\".\"other\" (\"id\")")); assert!(fk.sync_sql[0].contains("ON DELETE CASCADE")); let ix = r.items.iter().find(|i| i.object_type == "index" && i.name == "t.ix_t").unwrap(); assert_eq!(ix.sync_sql[0], "CREATE INDEX \"s\".\"ix_t\" ON \"s\".\"t\" (\"id\");"); let view = r.items.iter().find(|i| i.object_type == "view" && i.name == "v").unwrap(); assert!(view.sync_sql[0].starts_with("CREATE VIEW \"s\".\"v\" AS")); let seq = r.items.iter().find(|i| i.object_type == "sequence" && i.name == "seq").unwrap(); assert!(seq.sync_sql[0].starts_with("CREATE SEQUENCE \"s\".\"seq\"")); let en = r.items.iter().find(|i| i.object_type == "enum" && i.name == "color").unwrap(); assert!(!en.destructive); assert!(en.sync_sql[0].contains("ALTER TYPE \"s\".\"color\" ADD VALUE 'green'")); } #[test] fn mysql_family_uses_backticks() { let mut src = SchemaSnapshot { engine: "mariadb".into(), schema: "app".into(), ..Default::default() }; src.tables.push(SnapshotTable { schema: "app".into(), name: "users".into(), columns: vec![SnapshotColumn { name: "id".into(), data_type: "int".into(), nullable: false, default_value: None }], primary_key: vec!["id".into()], uniques: vec![], checks: vec![], indexes: vec![], foreign_keys: vec![], }); let tgt = SchemaSnapshot { engine: "mysql".into(), schema: "app".into(), ..Default::default() }; let r = diff_snapshots(&src, &tgt); let item = r.items.iter().find(|i| i.name == "users").unwrap(); assert!(item.sync_sql[0].starts_with("CREATE TABLE `app`.`users`"), "got: {}", item.sync_sql[0]); } #[test] fn sqlite_changed_view_is_destructive_note_only() { let mut src = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() }; src.views.push(SnapshotView { schema: "main".into(), name: "v".into(), definition: "SELECT 2".into(), materialized: false }); let mut tgt = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() }; tgt.views.push(SnapshotView { schema: "main".into(), name: "v".into(), definition: "SELECT 1".into(), materialized: false }); let r = diff_snapshots(&src, &tgt); let item = r.items.iter().find(|i| i.object_type == "view").unwrap(); assert_eq!(item.kind, "changed"); assert!(item.destructive); assert!(item.sync_sql.is_empty(), "SQLite cannot CREATE OR REPLACE a view"); } #[test] fn identical_snapshots_diff_empty_and_stageable_filter() { let snap = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]); let r = diff_snapshots(&snap, &snap); assert!(r.items.is_empty()); assert!(stageable_items(&r).is_empty()); let mut src2 = pg_snap(vec![]); src2.tables.push(table("new", vec![col("id", "int", false, None)])); src2.tables.push(table("dropme", vec![col("id", "int", false, None)])); let r2 = diff_snapshots(&src2, &snap); assert!(stageable_items(&r2).iter().all(|i| !i.destructive && !i.sync_sql.is_empty())); }